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/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/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..b4b653b 100644 --- a/src/providers/test.ts +++ b/src/providers/test.ts @@ -5,6 +5,103 @@ 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 } +} + +// 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' @@ -31,6 +128,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 @@ -128,13 +242,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 @@ -144,6 +274,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: { + showWarningBanners: testConfig.testModeIndicators?.showWarningBanners ?? true, + showTestBadges: testConfig.testModeIndicators?.showTestBadges ?? true, + consoleWarnings: testConfig.testModeIndicators?.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', @@ -151,7 +306,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) { @@ -163,7 +328,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' } }) @@ -178,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) @@ -227,10 +392,11 @@ 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] + if (!paymentId) { return new Response(JSON.stringify({ error: 'Payment ID required' }), { status: 400, @@ -238,6 +404,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' }), { @@ -257,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) @@ -362,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 } } 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