From eaf54ae893d565e503b75ec2ef5f08390a2604d6 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:10:18 +0000 Subject: [PATCH 1/5] feat: add test provider config endpoint Add GET /api/payload-billing/test/config endpoint to retrieve test provider configuration including scenarios, payment methods, and test mode indicators. This allows custom UIs to dynamically sync with plugin configuration instead of hardcoding values. - Add TestProviderConfigResponse interface - Export new type in provider index and main index - Endpoint returns enabled status, scenarios, methods, test mode indicators, default delay, and custom UI route Resolves #22 Co-authored-by: Bas --- src/index.ts | 1 + src/providers/index.ts | 2 +- src/providers/test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 5ecf30c..ddbd14e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export type { StripeProviderConfig, MollieProviderConfig, TestProviderConfig, + TestProviderConfigResponse, PaymentOutcome, PaymentMethod, PaymentScenario diff --git a/src/providers/index.ts b/src/providers/index.ts index c593f82..38cefeb 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -7,4 +7,4 @@ export * from './currency' // Re-export provider configurations and types export type { StripeProviderConfig } from './stripe' export type { MollieProviderConfig } from './mollie' -export type { TestProviderConfig, PaymentOutcome, PaymentMethod, PaymentScenario } from './test' +export type { TestProviderConfig, TestProviderConfigResponse, PaymentOutcome, PaymentMethod, PaymentScenario } from './test' diff --git a/src/providers/test.ts b/src/providers/test.ts index 09236a5..6f629fb 100644 --- a/src/providers/test.ts +++ b/src/providers/test.ts @@ -31,6 +31,23 @@ export interface TestProviderConfig { baseUrl?: string } +export interface TestProviderConfigResponse { + enabled: boolean + scenarios: PaymentScenario[] + methods: Array<{ + id: string + name: string + icon: string + }> + testModeIndicators: { + showWarningBanners: boolean + showTestBadges: boolean + consoleWarnings: boolean + } + defaultDelay: number + customUiRoute: string +} + // Properly typed session interface export interface TestPaymentSession { id: string @@ -144,6 +161,31 @@ export const testProvider = (testConfig: TestProviderConfig) => { }) } }, + { + path: '/payload-billing/test/config', + method: 'get', + handler: async (req) => { + const response: TestProviderConfigResponse = { + enabled: testConfig.enabled, + scenarios: scenarios, + methods: Object.entries(PAYMENT_METHODS).map(([id, method]) => ({ + id, + name: method.name, + icon: method.icon + })), + testModeIndicators: testConfig.testModeIndicators || { + showWarningBanners: true, + showTestBadges: true, + consoleWarnings: true + }, + defaultDelay: testConfig.defaultDelay || 1000, + customUiRoute: uiRoute + } + return new Response(JSON.stringify(response), { + headers: { 'Content-Type': 'application/json' } + }) + } + }, { path: '/payload-billing/test/process', method: 'post', From ed27501afcf5f729087601a44d6abf6d19e0ba22 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 19 Sep 2025 13:19:15 +0200 Subject: [PATCH 2/5] fix: add comprehensive input validation to test provider API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add proper request schema validation for ProcessPaymentRequest interface - Validate paymentId format and ensure it follows test_pay_ pattern - Validate scenarioId and method parameters with type safety - Replace unsafe 'as any' casting with proper validation functions - Add consistent JSON error responses with appropriate HTTP status codes - Improve error messages for better debugging and API usability Security improvements: - Prevent injection attacks through input validation - Ensure all API endpoints validate their inputs properly - Add format validation for payment IDs to prevent invalid requests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/providers/test.ts | 96 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/src/providers/test.ts b/src/providers/test.ts index 6f629fb..50ba01b 100644 --- a/src/providers/test.ts +++ b/src/providers/test.ts @@ -5,6 +5,58 @@ import type { Payload } from 'payload' import { handleWebhookError, logWebhookEvent } from './utils' import { isValidAmount, isValidCurrencyCode } from './currency' +// Request validation schemas +interface ProcessPaymentRequest { + paymentId: string + scenarioId: string + method: PaymentMethod +} + +// Validation functions +function validateProcessPaymentRequest(body: any): { isValid: boolean; data?: ProcessPaymentRequest; error?: string } { + if (!body || typeof body !== 'object') { + return { isValid: false, error: 'Request body must be a valid JSON object' } + } + + const { paymentId, scenarioId, method } = body + + if (!paymentId || typeof paymentId !== 'string') { + return { isValid: false, error: 'paymentId is required and must be a string' } + } + + if (!scenarioId || typeof scenarioId !== 'string') { + return { isValid: false, error: 'scenarioId is required and must be a string' } + } + + if (!method || typeof method !== 'string') { + return { isValid: false, error: 'method is required and must be a string' } + } + + // Validate method is a valid payment method + const validMethods: PaymentMethod[] = ['ideal', 'creditcard', 'paypal', 'applepay', 'banktransfer'] + if (!validMethods.includes(method as PaymentMethod)) { + return { isValid: false, error: `method must be one of: ${validMethods.join(', ')}` } + } + + return { + isValid: true, + data: { paymentId, scenarioId, method: method as PaymentMethod } + } +} + +function validatePaymentId(paymentId: string): { isValid: boolean; error?: string } { + if (!paymentId || typeof paymentId !== 'string') { + return { isValid: false, error: 'Payment ID is required and must be a string' } + } + + // Validate payment ID format (should match test payment ID pattern) + if (!paymentId.startsWith('test_pay_')) { + return { isValid: false, error: 'Invalid payment ID format' } + } + + return { isValid: true } +} + export type PaymentOutcome = 'paid' | 'failed' | 'cancelled' | 'expired' | 'pending' export type PaymentMethod = 'ideal' | 'creditcard' | 'paypal' | 'applepay' | 'banktransfer' @@ -145,13 +197,29 @@ export const testProvider = (testConfig: TestProviderConfig) => { // Extract payment ID from URL path const urlParts = req.url?.split('/') || [] const paymentId = urlParts[urlParts.length - 1] + if (!paymentId) { - return new Response('Payment ID required', { status: 400 }) + return new Response(JSON.stringify({ error: 'Payment ID required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) + } + + // Validate payment ID format + const validation = validatePaymentId(paymentId) + if (!validation.isValid) { + return new Response(JSON.stringify({ error: validation.error }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) } const session = testPaymentSessions.get(paymentId) if (!session) { - return new Response('Payment session not found', { status: 404 }) + return new Response(JSON.stringify({ error: 'Payment session not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }) } // Generate test payment UI @@ -193,7 +261,17 @@ export const testProvider = (testConfig: TestProviderConfig) => { try { const payload = req.payload const body = await req.json?.() || {} - const { paymentId, scenarioId, method } = body as any + + // Validate request body + const validation = validateProcessPaymentRequest(body) + if (!validation.isValid) { + return new Response(JSON.stringify({ error: validation.error }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) + } + + const { paymentId, scenarioId, method } = validation.data! const session = testPaymentSessions.get(paymentId) if (!session) { @@ -205,7 +283,7 @@ export const testProvider = (testConfig: TestProviderConfig) => { const scenario = scenarios.find(s => s.id === scenarioId) if (!scenario) { - return new Response(JSON.stringify({ error: 'Invalid scenario' }), { + return new Response(JSON.stringify({ error: 'Invalid scenario ID' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) @@ -273,6 +351,7 @@ export const testProvider = (testConfig: TestProviderConfig) => { // Extract payment ID from URL path const urlParts = req.url?.split('/') || [] const paymentId = urlParts[urlParts.length - 1] + if (!paymentId) { return new Response(JSON.stringify({ error: 'Payment ID required' }), { status: 400, @@ -280,6 +359,15 @@ export const testProvider = (testConfig: TestProviderConfig) => { }) } + // Validate payment ID format + const validation = validatePaymentId(paymentId) + if (!validation.isValid) { + return new Response(JSON.stringify({ error: validation.error }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) + } + const session = testPaymentSessions.get(paymentId) if (!session) { return new Response(JSON.stringify({ error: 'Payment session not found' }), { From 7590a5445c9ae94de018260ea5cd5dd2f9dbd780 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 19 Sep 2025 13:44:13 +0200 Subject: [PATCH 3/5] fix: enhance error handling and eliminate type safety issues in test provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Database Error Handling: - Add comprehensive error handling utility `updatePaymentInDatabase()` - Ensure consistent session status updates across all error scenarios - Prevent inconsistent states with proper error propagation and logging - Add structured error responses with detailed error messages Type Safety Improvements: - Remove all unsafe `as any` casts except for necessary PayloadCMS collection constraints - Add proper TypeScript interfaces and validation functions - Fix type compatibility issues with TestModeIndicators using nullish coalescing - Enhance error type checking with proper instanceof checks Utility Functions: - Abstract common collection name extraction pattern into `getPaymentsCollectionName()` - Centralize database operation patterns for consistency - Add structured error handling with success/error result patterns - Improve logging with proper error message extraction Code Quality: - Replace ad-hoc error handling with consistent, reusable patterns - Add proper error propagation throughout the payment processing flow - Ensure all database errors are caught and handled gracefully - Maintain session consistency even when database operations fail 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/providers/test.ts | 175 +++++++++++++++++++++++++----------------- 1 file changed, 106 insertions(+), 69 deletions(-) diff --git a/src/providers/test.ts b/src/providers/test.ts index 50ba01b..d7d9895 100644 --- a/src/providers/test.ts +++ b/src/providers/test.ts @@ -57,6 +57,51 @@ function validatePaymentId(paymentId: string): { isValid: boolean; error?: strin return { isValid: true } } +// Utility function to safely extract collection name +function getPaymentsCollectionName(pluginConfig: BillingPluginConfig): string { + if (typeof pluginConfig.collections?.payments === 'string') { + return pluginConfig.collections.payments + } + return 'payments' +} + +// Enhanced error handling utility for database operations +async function updatePaymentInDatabase( + payload: Payload, + sessionId: string, + status: Payment['status'], + providerData: ProviderData, + pluginConfig: BillingPluginConfig +): Promise<{ success: boolean; error?: string }> { + try { + const paymentsCollection = getPaymentsCollectionName(pluginConfig) + const payments = await payload.find({ + collection: paymentsCollection as any, // PayloadCMS collection type constraint + where: { providerId: { equals: sessionId } }, + limit: 1 + }) + + if (payments.docs.length === 0) { + return { success: false, error: 'Payment not found in database' } + } + + await payload.update({ + collection: paymentsCollection as any, // PayloadCMS collection type constraint + id: payments.docs[0].id, + data: { + status, + providerData + } + }) + + return { success: true } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown database error' + console.error('[Test Provider] Database update failed:', errorMessage) + return { success: false, error: errorMessage } + } +} + export type PaymentOutcome = 'paid' | 'failed' | 'cancelled' | 'expired' | 'pending' export type PaymentMethod = 'ideal' | 'creditcard' | 'paypal' | 'applepay' | 'banktransfer' @@ -241,10 +286,10 @@ export const testProvider = (testConfig: TestProviderConfig) => { name: method.name, icon: method.icon })), - testModeIndicators: testConfig.testModeIndicators || { - showWarningBanners: true, - showTestBadges: true, - consoleWarnings: true + testModeIndicators: { + showWarningBanners: testConfig.testModeIndicators?.showWarningBanners ?? true, + showTestBadges: testConfig.testModeIndicators?.showTestBadges ?? true, + consoleWarnings: testConfig.testModeIndicators?.consoleWarnings ?? true }, defaultDelay: testConfig.defaultDelay || 1000, customUiRoute: uiRoute @@ -298,35 +343,35 @@ export const testProvider = (testConfig: TestProviderConfig) => { setTimeout(() => { processTestPayment(payload, session, pluginConfig).catch(async (error) => { console.error('[Test Provider] Failed to process payment:', error) + + // Ensure session status is updated consistently session.status = 'failed' - // Also update the payment record in database - try { - const paymentsCollection = (typeof pluginConfig.collections?.payments === 'string' - ? pluginConfig.collections.payments - : 'payments') as any - const payments = await payload.find({ - collection: paymentsCollection, - where: { providerId: { equals: session.id } }, - limit: 1 - }) + // Create error provider data + const errorProviderData: ProviderData = { + raw: { + error: error instanceof Error ? error.message : 'Unknown processing error', + processedAt: new Date().toISOString(), + testMode: true + }, + timestamp: new Date().toISOString(), + provider: 'test' + } - if (payments.docs.length > 0) { - await payload.update({ - collection: paymentsCollection, - id: payments.docs[0].id, - data: { - status: 'failed', - providerData: { - raw: { error: error.message, processedAt: new Date().toISOString() }, - timestamp: new Date().toISOString(), - provider: 'test' - } - } - }) - } - } catch (dbError) { - console.error('[Test Provider] Failed to update payment in database:', dbError) + // Update payment record in database with enhanced error handling + const dbResult = await updatePaymentInDatabase( + payload, + session.id, + 'failed', + errorProviderData, + pluginConfig + ) + + if (!dbResult.success) { + console.error('[Test Provider] Database error during failure handling:', dbResult.error) + // Even if database update fails, we maintain session consistency + } else { + logWebhookEvent('Test Provider', `Payment ${session.id} marked as failed after processing error`) } }) }, scenario.delay || testConfig.defaultDelay || 1000) @@ -492,52 +537,44 @@ async function processTestPayment( // Update session status session.status = session.scenario.outcome - // Find and update the payment in the database - const paymentsCollection = (typeof pluginConfig.collections?.payments === 'string' - ? pluginConfig.collections.payments - : 'payments') as any - const payments = await payload.find({ - collection: paymentsCollection, - where: { - providerId: { - equals: session.id - } + // Update payment with final status and provider data + const updatedProviderData: ProviderData = { + raw: { + ...session.payment, + id: session.id, + status: session.scenario.outcome, + scenario: session.scenario.name, + method: session.method, + processedAt: new Date().toISOString(), + testMode: true }, - limit: 1 - }) + timestamp: new Date().toISOString(), + provider: 'test' + } - if (payments.docs.length > 0) { - const payment = payments.docs[0] - - // Update payment with final status and provider data - const updatedProviderData: ProviderData = { - raw: { - ...session.payment, - id: session.id, - status: session.scenario.outcome, - scenario: session.scenario.name, - method: session.method, - processedAt: new Date().toISOString(), - testMode: true - }, - timestamp: new Date().toISOString(), - provider: 'test' - } - - await payload.update({ - collection: paymentsCollection, - id: payment.id, - data: { - status: finalStatus, - providerData: updatedProviderData - } - }) + // Use the utility function for database operations + const dbResult = await updatePaymentInDatabase( + payload, + session.id, + finalStatus, + updatedProviderData, + pluginConfig + ) + if (dbResult.success) { logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`) + } else { + console.error('[Test Provider] Failed to update payment in database:', dbResult.error) + // Update session status to indicate database error, but don't throw + // This allows the UI to still show the intended test result + session.status = 'failed' + throw new Error(`Database update failed: ${dbResult.error}`) } } catch (error) { - console.error('[Test Provider] Failed to process payment:', error) + const errorMessage = error instanceof Error ? error.message : 'Unknown processing error' + console.error('[Test Provider] Failed to process payment:', errorMessage) session.status = 'failed' + throw error // Re-throw to be handled by the caller } } From dc9bc2db57be49ab6d2603692d72bd8030f14177 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 19 Sep 2025 13:55:48 +0200 Subject: [PATCH 4/5] chore: bump package version to 0.1.9 and simplify test provider initialization logic --- package.json | 2 +- src/providers/test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d2b981b..a3e6ac8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-billing", - "version": "0.1.8", + "version": "0.1.9", "description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing", "license": "MIT", "type": "module", diff --git a/src/providers/test.ts b/src/providers/test.ts index d7d9895..9e124e2 100644 --- a/src/providers/test.ts +++ b/src/providers/test.ts @@ -432,7 +432,7 @@ export const testProvider = (testConfig: TestProviderConfig) => { } ] }, - onInit: async (payload: Payload) => { + onInit: (payload: Payload) => { logWebhookEvent('Test Provider', 'Test payment provider initialized') // Clean up old sessions periodically (older than 1 hour) From 05d612e606564b5d90ddcc30961f58fe71eca54d Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 19 Sep 2025 14:00:58 +0200 Subject: [PATCH 5/5] feat: make InitPayment support both async and non-async functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated InitPayment type to return Promise> | Partial - Modified initProviderPayment hook to handle both async and sync returns using Promise.resolve() - Enables payment providers to use either async or synchronous initPayment implementations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/collections/hooks.ts | 6 ++++-- src/providers/test.ts | 2 +- src/providers/types.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/collections/hooks.ts b/src/collections/hooks.ts index 0e62ef8..f461159 100644 --- a/src/collections/hooks.ts +++ b/src/collections/hooks.ts @@ -2,10 +2,12 @@ import type { Payment } from '../plugin/types/index' import type { Payload } from 'payload' import { useBillingPlugin } from '../plugin/index' -export const initProviderPayment = (payload: Payload, payment: Partial) => { +export const initProviderPayment = async (payload: Payload, payment: Partial): Promise> => { const billing = useBillingPlugin(payload) if (!payment.provider || !billing.providerConfig[payment.provider]) { throw new Error(`Provider ${payment.provider} not found.`) } - return billing.providerConfig[payment.provider].initPayment(payload, payment) + // Handle both async and non-async initPayment functions + const result = billing.providerConfig[payment.provider].initPayment(payload, payment) + return await Promise.resolve(result) } diff --git a/src/providers/test.ts b/src/providers/test.ts index 9e124e2..b4b653b 100644 --- a/src/providers/test.ts +++ b/src/providers/test.ts @@ -392,7 +392,7 @@ export const testProvider = (testConfig: TestProviderConfig) => { { path: '/payload-billing/test/status/:id', method: 'get', - handler: async (req) => { + handler: (req) => { // Extract payment ID from URL path const urlParts = req.url?.split('/') || [] const paymentId = urlParts[urlParts.length - 1] diff --git a/src/providers/types.ts b/src/providers/types.ts index 311e9ad..cef3858 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -2,7 +2,7 @@ import type { Payment } from '../plugin/types/payments' import type { Config, Payload } from 'payload' import type { BillingPluginConfig } from '../plugin/config' -export type InitPayment = (payload: Payload, payment: Partial) => Promise> +export type InitPayment = (payload: Payload, payment: Partial) => Promise> | Partial export type PaymentProvider = { key: string