Merge pull request #25 from xtr-dev/dev

Dev
This commit is contained in:
Bas
2025-09-19 14:06:09 +02:00
committed by GitHub
6 changed files with 246 additions and 76 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-billing", "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", "description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -2,10 +2,12 @@ import type { Payment } from '../plugin/types/index'
import type { Payload } from 'payload' import type { Payload } from 'payload'
import { useBillingPlugin } from '../plugin/index' import { useBillingPlugin } from '../plugin/index'
export const initProviderPayment = (payload: Payload, payment: Partial<Payment>) => { export const initProviderPayment = async (payload: Payload, payment: Partial<Payment>): Promise<Partial<Payment>> => {
const billing = useBillingPlugin(payload) const billing = useBillingPlugin(payload)
if (!payment.provider || !billing.providerConfig[payment.provider]) { if (!payment.provider || !billing.providerConfig[payment.provider]) {
throw new Error(`Provider ${payment.provider} not found.`) 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)
} }

View File

@@ -11,6 +11,7 @@ export type {
StripeProviderConfig, StripeProviderConfig,
MollieProviderConfig, MollieProviderConfig,
TestProviderConfig, TestProviderConfig,
TestProviderConfigResponse,
PaymentOutcome, PaymentOutcome,
PaymentMethod, PaymentMethod,
PaymentScenario PaymentScenario

View File

@@ -7,4 +7,4 @@ export * from './currency'
// Re-export provider configurations and types // Re-export provider configurations and types
export type { StripeProviderConfig } from './stripe' export type { StripeProviderConfig } from './stripe'
export type { MollieProviderConfig } from './mollie' export type { MollieProviderConfig } from './mollie'
export type { TestProviderConfig, PaymentOutcome, PaymentMethod, PaymentScenario } from './test' export type { TestProviderConfig, TestProviderConfigResponse, PaymentOutcome, PaymentMethod, PaymentScenario } from './test'

View File

@@ -5,6 +5,103 @@ import type { Payload } from 'payload'
import { handleWebhookError, logWebhookEvent } from './utils' import { handleWebhookError, logWebhookEvent } from './utils'
import { isValidAmount, isValidCurrencyCode } from './currency' 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 PaymentOutcome = 'paid' | 'failed' | 'cancelled' | 'expired' | 'pending'
export type PaymentMethod = 'ideal' | 'creditcard' | 'paypal' | 'applepay' | 'banktransfer' export type PaymentMethod = 'ideal' | 'creditcard' | 'paypal' | 'applepay' | 'banktransfer'
@@ -31,6 +128,23 @@ export interface TestProviderConfig {
baseUrl?: string 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 // Properly typed session interface
export interface TestPaymentSession { export interface TestPaymentSession {
id: string id: string
@@ -128,13 +242,29 @@ export const testProvider = (testConfig: TestProviderConfig) => {
// 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] const paymentId = urlParts[urlParts.length - 1]
if (!paymentId) { 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) const session = testPaymentSessions.get(paymentId)
if (!session) { 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 // 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', path: '/payload-billing/test/process',
method: 'post', method: 'post',
@@ -151,7 +306,17 @@ export const testProvider = (testConfig: TestProviderConfig) => {
try { try {
const payload = req.payload const payload = req.payload
const body = await req.json?.() || {} 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) const session = testPaymentSessions.get(paymentId)
if (!session) { if (!session) {
@@ -163,7 +328,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
const scenario = scenarios.find(s => s.id === scenarioId) const scenario = scenarios.find(s => s.id === scenarioId)
if (!scenario) { if (!scenario) {
return new Response(JSON.stringify({ error: 'Invalid scenario' }), { return new Response(JSON.stringify({ error: 'Invalid scenario ID' }), {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}) })
@@ -178,35 +343,35 @@ export const testProvider = (testConfig: TestProviderConfig) => {
setTimeout(() => { setTimeout(() => {
processTestPayment(payload, session, pluginConfig).catch(async (error) => { processTestPayment(payload, session, pluginConfig).catch(async (error) => {
console.error('[Test Provider] Failed to process payment:', error) console.error('[Test Provider] Failed to process payment:', error)
// Ensure session status is updated consistently
session.status = 'failed' session.status = 'failed'
// Also update the payment record in database // Create error provider data
try { const errorProviderData: ProviderData = {
const paymentsCollection = (typeof pluginConfig.collections?.payments === 'string' raw: {
? pluginConfig.collections.payments error: error instanceof Error ? error.message : 'Unknown processing error',
: 'payments') as any processedAt: new Date().toISOString(),
const payments = await payload.find({ testMode: true
collection: paymentsCollection, },
where: { providerId: { equals: session.id } },
limit: 1
})
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(), timestamp: new Date().toISOString(),
provider: 'test' provider: 'test'
} }
}
}) // Update payment record in database with enhanced error handling
} const dbResult = await updatePaymentInDatabase(
} catch (dbError) { payload,
console.error('[Test Provider] Failed to update payment in database:', dbError) 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) }, scenario.delay || testConfig.defaultDelay || 1000)
@@ -227,10 +392,11 @@ export const testProvider = (testConfig: TestProviderConfig) => {
{ {
path: '/payload-billing/test/status/:id', path: '/payload-billing/test/status/:id',
method: 'get', method: 'get',
handler: async (req) => { handler: (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] const paymentId = urlParts[urlParts.length - 1]
if (!paymentId) { if (!paymentId) {
return new Response(JSON.stringify({ error: 'Payment ID required' }), { return new Response(JSON.stringify({ error: 'Payment ID required' }), {
status: 400, 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) const session = testPaymentSessions.get(paymentId)
if (!session) { if (!session) {
return new Response(JSON.stringify({ error: 'Payment session not found' }), { 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') logWebhookEvent('Test Provider', 'Test payment provider initialized')
// Clean up old sessions periodically (older than 1 hour) // Clean up old sessions periodically (older than 1 hour)
@@ -362,23 +537,6 @@ async function processTestPayment(
// Update session status // Update session status
session.status = session.scenario.outcome 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
}
},
limit: 1
})
if (payments.docs.length > 0) {
const payment = payments.docs[0]
// Update payment with final status and provider data // Update payment with final status and provider data
const updatedProviderData: ProviderData = { const updatedProviderData: ProviderData = {
raw: { raw: {
@@ -394,20 +552,29 @@ async function processTestPayment(
provider: 'test' provider: 'test'
} }
await payload.update({ // Use the utility function for database operations
collection: paymentsCollection, const dbResult = await updatePaymentInDatabase(
id: payment.id, payload,
data: { session.id,
status: finalStatus, finalStatus,
providerData: updatedProviderData updatedProviderData,
} pluginConfig
}) )
if (dbResult.success) {
logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`) 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) { } 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' session.status = 'failed'
throw error // Re-throw to be handled by the caller
} }
} }

View File

@@ -2,7 +2,7 @@ import type { Payment } from '../plugin/types/payments'
import type { Config, Payload } from 'payload' import type { Config, Payload } from 'payload'
import type { BillingPluginConfig } from '../plugin/config' import type { BillingPluginConfig } from '../plugin/config'
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>> export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>> | Partial<Payment>
export type PaymentProvider = { export type PaymentProvider = {
key: string key: string