12 Commits
v0.1.8 ... dev

Author SHA1 Message Date
857fc663b3 docs: add comprehensive usage examples to README
Add detailed usage examples section with practical code samples for:
- Creating payments with different providers
- Creating invoices with embedded and relationship-based customer data
- Creating refunds
- Querying payments and invoices
- Updating payment status
- Using the test provider for local development
- REST API examples with cURL commands

Also add table of contents for easier navigation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 12:44:31 +01:00
552ec700c2 chore: bump package version to 0.1.12 2025-09-30 21:04:56 +02:00
f7d6066d9a chore: bump package version to 0.1.11 2025-09-30 20:59:53 +02:00
b27b5806b1 fix: resolve inconsistent console usage in logging implementation
- Move Stripe provider webhook warning to onInit where payload is available
- Fix client-side logging in test provider UI generation
- Replace server-side logger calls with browser-compatible console in generated HTML
- Maintain proper logging context separation between server and client code

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 21:21:35 +02:00
da96a0a838 chore: bump package version to 0.1.10 2025-09-20 21:18:40 +02:00
2374dbcec8 feat: implement structured logging system throughout the codebase
- Add logger utility adapted from payload-mailing pattern
- Use PAYLOAD_BILLING_LOG_LEVEL environment variable for configuration
- Replace console.* calls with contextual loggers across providers
- Update webhook utilities to support proper logging
- Export logging utilities for external use
- Maintain fallback console logging for compatibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 21:16:55 +02:00
05d612e606 feat: make InitPayment support both async and non-async functions
- Updated InitPayment type to return Promise<Partial<Payment>> | Partial<Payment>
- 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 <noreply@anthropic.com>
2025-09-19 14:00:58 +02:00
dc9bc2db57 chore: bump package version to 0.1.9 and simplify test provider initialization logic 2025-09-19 13:55:48 +02:00
7590a5445c fix: enhance error handling and eliminate type safety issues in test provider
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 <noreply@anthropic.com>
2025-09-19 13:44:13 +02:00
ed27501afc fix: add comprehensive input validation to test provider API endpoints
- 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 <noreply@anthropic.com>
2025-09-19 13:19:15 +02:00
Bas
56bd4fc7ce Merge pull request #23 from xtr-dev/claude/issue-22-20250919-1107
Claude/issue 22 20250919 1107
2025-09-19 13:11:49 +02:00
claude[bot]
eaf54ae893 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 <bvdaakster@users.noreply.github.com>
2025-09-19 11:10:18 +00:00
15 changed files with 683 additions and 128 deletions

283
README.md
View File

@@ -6,6 +6,23 @@ A billing and payment provider plugin for PayloadCMS 3.x. Supports Stripe, Molli
⚠️ **Pre-release Warning**: This package is currently in active development (v0.1.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
## Table of Contents
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Imports](#imports)
- [Usage Examples](#usage-examples)
- [Creating a Payment](#creating-a-payment)
- [Creating an Invoice](#creating-an-invoice)
- [Creating a Refund](#creating-a-refund)
- [Querying Payments](#querying-payments)
- [Using REST API](#using-rest-api)
- [Provider Types](#provider-types)
- [Collections](#collections)
- [Webhook Endpoints](#webhook-endpoints)
- [Development](#development)
## Features
- 💳 Multiple payment providers (Stripe, Mollie, Test)
@@ -190,6 +207,272 @@ The plugin supports flexible customer data handling:
3. **No Customer Collection**: Customer info fields always required and editable, no relationship field available
## Usage Examples
### Creating a Payment
Payments are created through PayloadCMS's local API or REST API. The plugin automatically initializes the payment with the configured provider.
```typescript
// Using Payload Local API
const payment = await payload.create({
collection: 'payments',
data: {
provider: 'stripe', // or 'mollie' or 'test'
amount: 2000, // Amount in cents ($20.00)
currency: 'USD',
description: 'Product purchase',
status: 'pending',
metadata: {
orderId: 'order-123',
customerId: 'cust-456'
}
}
})
```
### Creating an Invoice
Invoices can be created with customer information embedded or linked via relationship:
```typescript
// Create invoice with embedded customer info
const invoice = await payload.create({
collection: 'invoices',
data: {
customerInfo: {
name: 'John Doe',
email: 'john@example.com',
phone: '+1234567890',
company: 'Acme Corp',
taxId: 'TAX-123'
},
billingAddress: {
line1: '123 Main St',
line2: 'Suite 100',
city: 'New York',
state: 'NY',
postalCode: '10001',
country: 'US'
},
currency: 'USD',
items: [
{
description: 'Web Development Services',
quantity: 10,
unitAmount: 5000 // $50.00 per hour
},
{
description: 'Hosting (Monthly)',
quantity: 1,
unitAmount: 2500 // $25.00
}
],
taxAmount: 7500, // $75.00 tax
status: 'open'
}
})
console.log(`Invoice created: ${invoice.number}`)
console.log(`Total amount: $${invoice.amount / 100}`)
```
### Creating an Invoice with Customer Relationship
If you've configured a customer collection with `customerRelationSlug` and `customerInfoExtractor`:
```typescript
// Create invoice linked to customer (info auto-populated)
const invoice = await payload.create({
collection: 'invoices',
data: {
customer: 'customer-id-123', // Customer relationship
currency: 'USD',
items: [
{
description: 'Subscription - Pro Plan',
quantity: 1,
unitAmount: 9900 // $99.00
}
],
status: 'open'
// customerInfo and billingAddress are auto-populated from customer
}
})
```
### Creating a Refund
Refunds are linked to existing payments:
```typescript
const refund = await payload.create({
collection: 'refunds',
data: {
payment: payment.id, // Link to payment
providerId: 'refund-provider-id', // Provider's refund ID
amount: 1000, // Partial refund: $10.00
currency: 'USD',
status: 'succeeded',
reason: 'requested_by_customer',
description: 'Customer requested partial refund'
}
})
```
### Querying Payments
```typescript
// Find all successful payments
const payments = await payload.find({
collection: 'payments',
where: {
status: {
equals: 'succeeded'
}
}
})
// Find payments for a specific invoice
const invoicePayments = await payload.find({
collection: 'payments',
where: {
invoice: {
equals: invoiceId
}
}
})
```
### Updating Payment Status
Payment status is typically updated via webhooks, but you can also update manually:
```typescript
const updatedPayment = await payload.update({
collection: 'payments',
id: payment.id,
data: {
status: 'succeeded',
providerData: {
// Provider-specific data
raw: providerResponse,
timestamp: new Date().toISOString(),
provider: 'stripe'
}
}
})
```
### Marking an Invoice as Paid
```typescript
const paidInvoice = await payload.update({
collection: 'invoices',
id: invoice.id,
data: {
status: 'paid',
payment: payment.id // Link to payment
// paidAt is automatically set by the plugin
}
})
```
### Using the Test Provider
The test provider is useful for local development:
```typescript
// In your payload.config.ts
import { billingPlugin, testProvider } from '@xtr-dev/payload-billing'
billingPlugin({
providers: [
testProvider({
enabled: true,
testModeIndicators: {
showWarningBanners: true,
showTestBadges: true,
consoleWarnings: true
}
})
],
collections: {
payments: 'payments',
invoices: 'invoices',
refunds: 'refunds',
}
})
```
Then create test payments:
```typescript
const testPayment = await payload.create({
collection: 'payments',
data: {
provider: 'test',
amount: 5000,
currency: 'USD',
description: 'Test payment',
status: 'pending'
}
})
// Test provider automatically processes the payment
```
### Using REST API
All collections can be accessed via PayloadCMS REST API:
```bash
# Create a payment
curl -X POST http://localhost:3000/api/payments \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"provider": "stripe",
"amount": 2000,
"currency": "USD",
"description": "Product purchase",
"status": "pending"
}'
# Create an invoice
curl -X POST http://localhost:3000/api/invoices \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"customerInfo": {
"name": "John Doe",
"email": "john@example.com"
},
"billingAddress": {
"line1": "123 Main St",
"city": "New York",
"postalCode": "10001",
"country": "US"
},
"currency": "USD",
"items": [
{
"description": "Service",
"quantity": 1,
"unitAmount": 5000
}
],
"status": "open"
}'
# Get all payments
curl http://localhost:3000/api/payments \
-H "Authorization: Bearer YOUR_TOKEN"
# Get a specific invoice
curl http://localhost:3000/api/invoices/INVOICE_ID \
-H "Authorization: Bearer YOUR_TOKEN"
```
## Webhook Endpoints
Automatic webhook endpoints are created for configured providers:

View File

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

View File

@@ -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<Payment>) => {
export const initProviderPayment = async (payload: Payload, payment: Partial<Payment>): Promise<Partial<Payment>> => {
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)
}

View File

@@ -5,10 +5,11 @@ import {
CollectionBeforeValidateHook,
CollectionConfig, Field,
} from 'payload'
import type { BillingPluginConfig} from '../plugin/config';
import { defaults } from '../plugin/config'
import { extractSlug } from '../plugin/utils'
import type { Invoice } from '../plugin/types/invoices'
import type { BillingPluginConfig} from '@/plugin/config';
import { defaults } from '@/plugin/config'
import { extractSlug } from '@/plugin/utils'
import { createContextLogger } from '@/utils/logger'
import type { Invoice } from '@/plugin/types'
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
@@ -314,7 +315,8 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
afterChange: [
({ doc, operation, req }) => {
if (operation === 'create') {
req.payload.logger.info(`Invoice created: ${doc.number}`)
const logger = createContextLogger(req.payload, 'Invoices Collection')
logger.info(`Invoice created: ${doc.number}`)
}
},
] satisfies CollectionAfterChangeHook<Invoice>[],
@@ -350,7 +352,8 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
data.billingAddress = extractedInfo.billingAddress
}
} catch (error) {
req.payload.logger.error(`Failed to extract customer info: ${error}`)
const logger = createContextLogger(req.payload, 'Invoices Collection')
logger.error(`Failed to extract customer info: ${error}`)
throw new Error('Failed to extract customer information')
}
}

View File

@@ -2,6 +2,7 @@ import type { AccessArgs, CollectionConfig } from 'payload'
import { BillingPluginConfig, defaults } from '../plugin/config'
import { extractSlug } from '../plugin/utils'
import { Payment } from '../plugin/types/index'
import { createContextLogger } from '../utils/logger'
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
// TODO: finish collection overrides
@@ -111,7 +112,8 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
afterChange: [
async ({ doc, operation, req }) => {
if (operation === 'create') {
req.payload.logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
const logger = createContextLogger(req.payload, 'Refunds Collection')
logger.info(`Refund created: ${doc.id} for payment: ${doc.payment}`)
// Update the related payment's refund relationship
try {
@@ -129,7 +131,8 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
},
})
} catch (error) {
req.payload.logger.error(`Failed to update payment refunds: ${error}`)
const logger = createContextLogger(req.payload, 'Refunds Collection')
logger.error(`Failed to update payment refunds: ${error}`)
}
}
},

View File

@@ -5,12 +5,16 @@ export type { BillingPluginConfig, CustomerInfoExtractor, AdvancedTestProviderCo
export type { Invoice, Payment, Refund } from './plugin/types/index.js'
export type { PaymentProvider, ProviderData } from './providers/types.js'
// Export logging utilities
export { getPluginLogger, createContextLogger } from './utils/logger.js'
// Export all providers
export { testProvider } from './providers/test.js'
export type {
StripeProviderConfig,
MollieProviderConfig,
TestProviderConfig,
TestProviderConfigResponse,
PaymentOutcome,
PaymentMethod,
PaymentScenario

View File

@@ -55,6 +55,6 @@ export interface BillingPluginConfig {
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
customerRelationSlug?: string // Customer collection slug for relationship
disabled?: boolean
providers?: PaymentProvider[]
providers?: (PaymentProvider | undefined | null)[]
}

View File

@@ -28,8 +28,8 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
];
(pluginConfig.providers || [])
.filter(provider => provider.onConfig)
.forEach(provider => provider.onConfig!(config, pluginConfig))
.filter(provider => provider?.onConfig)
.forEach(provider => provider?.onConfig!(config, pluginConfig))
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
@@ -38,17 +38,17 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
}
singleton.set(payload, {
config: pluginConfig,
providerConfig: (pluginConfig.providers || []).reduce(
providerConfig: (pluginConfig.providers || []).filter(Boolean).reduce(
(record, provider) => {
record[provider.key] = provider
record[provider!.key] = provider as PaymentProvider
return record
},
{} as Record<string, PaymentProvider>
)
} satisfies BillingPlugin)
await Promise.all((pluginConfig.providers || [])
.filter(provider => provider.onInit)
.map(provider => provider.onInit!(payload)))
.filter(provider => provider?.onInit)
.map(provider => provider?.onInit!(payload)))
}
return config

View File

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

View File

@@ -12,6 +12,7 @@ import {
validateProductionUrl
} from './utils'
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const symbol = Symbol('mollie')
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
@@ -96,12 +97,13 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
if (status === 'succeeded' && updateSuccess) {
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
} else if (!updateSuccess) {
console.warn(`[Mollie Webhook] Failed to update payment ${payment.id}, skipping invoice update`)
const logger = createContextLogger(payload, 'Mollie Webhook')
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
}
return webhookResponses.success()
} catch (error) {
return handleWebhookError('Mollie', error)
return handleWebhookError('Mollie', error, undefined, req.payload)
}
}
}

View File

@@ -12,6 +12,7 @@ import {
logWebhookEvent
} from './utils'
import { isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const symbol = Symbol('stripe')
@@ -60,13 +61,13 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
return webhookResponses.missingBody()
}
} catch (error) {
return handleWebhookError('Stripe', error, 'Failed to read request body')
return handleWebhookError('Stripe', error, 'Failed to read request body', req.payload)
}
const signature = req.headers.get('stripe-signature')
if (!signature) {
return webhookResponses.error('Missing webhook signature', 400)
return webhookResponses.error('Missing webhook signature', 400, req.payload)
}
// webhookSecret is guaranteed to exist since we only register this endpoint when it's configured
@@ -76,7 +77,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
try {
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret!)
} catch (err) {
return handleWebhookError('Stripe', err, 'Signature verification failed')
return handleWebhookError('Stripe', err, 'Signature verification failed', req.payload)
}
// Handle different event types
@@ -90,7 +91,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
if (!payment) {
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`)
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`, undefined, req.payload)
return webhookResponses.success() // Still return 200 to acknowledge receipt
}
@@ -129,7 +130,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
if (status === 'succeeded' && updateSuccess) {
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
} else if (!updateSuccess) {
console.warn(`[Stripe Webhook] Failed to update payment ${payment.id}, skipping invoice update`)
const logger = createContextLogger(payload, 'Stripe Webhook')
logger.warn(`Failed to update payment ${payment.id}, skipping invoice update`)
}
break
}
@@ -172,7 +174,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
)
if (!updateSuccess) {
console.warn(`[Stripe Webhook] Failed to update refund status for payment ${payment.id}`)
const logger = createContextLogger(payload, 'Stripe Webhook')
logger.warn(`Failed to update refund status for payment ${payment.id}`)
}
}
break
@@ -180,19 +183,16 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
default:
// Unhandled event type
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`)
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`, undefined, req.payload)
}
return webhookResponses.success()
} catch (error) {
return handleWebhookError('Stripe', error)
return handleWebhookError('Stripe', error, undefined, req.payload)
}
}
}
]
} else {
// Log that webhook endpoint is not registered
console.warn('[Stripe Provider] Webhook endpoint not registered - webhookSecret not configured')
}
},
onInit: async (payload: Payload) => {
@@ -201,6 +201,12 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
apiVersion: stripeConfig.apiVersion || DEFAULT_API_VERSION,
})
singleton.set(payload, stripe)
// Log webhook registration status
if (!stripeConfig.webhookSecret) {
const logger = createContextLogger(payload, 'Stripe Provider')
logger.warn('Webhook endpoint not registered - webhookSecret not configured')
}
},
initPayment: async (payload, payment) => {
// Validate required fields

View File

@@ -4,6 +4,110 @@ import type { BillingPluginConfig } from '../plugin/config'
import type { Payload } from 'payload'
import { handleWebhookError, logWebhookEvent } from './utils'
import { isValidAmount, isValidCurrencyCode } from './currency'
import { createContextLogger } from '../utils/logger'
const TestModeWarningSymbol = Symbol('TestModeWarning')
const hasGivenTestModeWarning = () => TestModeWarningSymbol in globalThis
const setTestModeWarning = () => ((<any>globalThis)[TestModeWarningSymbol] = true)
// 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'
const logger = createContextLogger(payload, 'Test Provider')
logger.error('Database update failed:', errorMessage)
return { success: false, error: errorMessage }
}
}
export type PaymentOutcome = 'paid' | 'failed' | 'cancelled' | 'expired' | 'pending'
@@ -31,6 +135,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
@@ -103,17 +224,14 @@ const testPaymentSessions = new Map<string, TestPaymentSession>()
export const testProvider = (testConfig: TestProviderConfig) => {
if (!testConfig.enabled) {
throw new Error('Test provider is disabled')
return
}
const scenarios = testConfig.scenarios || DEFAULT_SCENARIOS
const baseUrl = testConfig.baseUrl || (process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000')
const uiRoute = testConfig.customUiRoute || '/test-payment'
// Log test mode warnings if enabled
if (testConfig.testModeIndicators?.consoleWarnings !== false) {
console.warn('🧪 [TEST PROVIDER] Payment system is running in test mode')
}
// Test mode warnings will be logged in onInit when payload is available
return {
key: 'test',
@@ -124,17 +242,33 @@ export const testProvider = (testConfig: TestProviderConfig) => {
{
path: '/payload-billing/test/payment/: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('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 +278,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 +310,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 +332,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' }
})
@@ -177,36 +346,38 @@ export const testProvider = (testConfig: TestProviderConfig) => {
// Process payment after delay
setTimeout(() => {
processTestPayment(payload, session, pluginConfig).catch(async (error) => {
console.error('[Test Provider] Failed to process payment:', error)
const logger = createContextLogger(payload, 'Test Provider')
logger.error('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) {
const logger = createContextLogger(payload, 'Test Provider')
logger.error('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`, undefined, req.payload)
}
})
}, scenario.delay || testConfig.defaultDelay || 1000)
@@ -220,17 +391,18 @@ export const testProvider = (testConfig: TestProviderConfig) => {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return handleWebhookError('Test Provider', error, 'Failed to process test payment')
return handleWebhookError('Test Provider', error, 'Failed to process test payment', req.payload)
}
}
},
{
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 +410,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,8 +438,15 @@ export const testProvider = (testConfig: TestProviderConfig) => {
}
]
},
onInit: async (payload: Payload) => {
logWebhookEvent('Test Provider', 'Test payment provider initialized')
onInit: (payload: Payload) => {
logWebhookEvent('Test Provider', 'Test payment provider initialized', undefined, payload)
// Log test mode warnings if enabled
if (testConfig.testModeIndicators?.consoleWarnings !== false && !hasGivenTestModeWarning()) {
setTestModeWarning()
const logger = createContextLogger(payload, 'Test Provider')
logger.warn('🧪 Payment system is running in test mode')
}
// Clean up old sessions periodically (older than 1 hour)
setInterval(() => {
@@ -270,7 +458,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
})
}, 10 * 60 * 1000) // Clean every 10 minutes
},
initPayment: async (payload, payment) => {
initPayment: (payload, payment) => {
// Validate required fields
if (!payment.amount) {
throw new Error('Amount is required')
@@ -362,52 +550,46 @@ 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]
// Use the utility function for database operations
const dbResult = await updatePaymentInDatabase(
payload,
session.id,
finalStatus,
updatedProviderData,
pluginConfig
)
// 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
}
})
logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`)
if (dbResult.success) {
logWebhookEvent('Test Provider', `Payment ${session.id} processed with outcome: ${session.scenario.outcome}`, undefined, payload)
} else {
const logger = createContextLogger(payload, 'Test Provider')
logger.error('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'
const logger = createContextLogger(payload, 'Test Provider')
logger.error('Failed to process payment:', errorMessage)
session.status = 'failed'
throw error // Re-throw to be handled by the caller
}
}
@@ -746,12 +928,12 @@ function generateTestPaymentUI(
setTimeout(() => pollStatus(), 2000);
}
} catch (error) {
console.error('Failed to poll status:', error);
console.error('[Test Provider] Failed to poll status:', error);
}
}
${testModeIndicators.consoleWarnings !== false ? `
console.warn('🧪 TEST MODE: This is a simulated payment interface for development purposes');
console.warn('[Test Provider] 🧪 TEST MODE: This is a simulated payment interface for development purposes');
` : ''}
</script>
</body>

View File

@@ -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<Payment>) => Promise<Partial<Payment>>
export type InitPayment = (payload: Payload, payment: Partial<Payment>) => Promise<Partial<Payment>> | Partial<Payment>
export type PaymentProvider = {
key: string

View File

@@ -4,6 +4,7 @@ import type { BillingPluginConfig } from '../plugin/config'
import type { ProviderData } from './types'
import { defaults } from '../plugin/config'
import { extractSlug, toPayloadId } from '../plugin/utils'
import { createContextLogger } from '../utils/logger'
/**
* Common webhook response utilities
@@ -11,9 +12,14 @@ import { extractSlug, toPayloadId } from '../plugin/utils'
*/
export const webhookResponses = {
success: () => Response.json({ received: true }, { status: 200 }),
error: (message: string, status = 400) => {
error: (message: string, status = 400, payload?: Payload) => {
// Log error internally but don't expose details
console.error('[Webhook] Error:', message)
if (payload) {
const logger = createContextLogger(payload, 'Webhook')
logger.error('Error:', message)
} else {
console.error('[Webhook] Error:', message)
}
return Response.json({ error: 'Invalid request' }, { status })
},
missingBody: () => Response.json({ received: true }, { status: 200 }),
@@ -63,7 +69,8 @@ export async function updatePaymentStatus(
}) as Payment
if (!currentPayment) {
console.error(`[Payment Update] Payment ${paymentId} not found`)
const logger = createContextLogger(payload, 'Payment Update')
logger.error(`Payment ${paymentId} not found`)
return false
}
@@ -74,7 +81,8 @@ export async function updatePaymentStatus(
const transactionID = await payload.db.beginTransaction()
if (!transactionID) {
console.error(`[Payment Update] Failed to begin transaction`)
const logger = createContextLogger(payload, 'Payment Update')
logger.error('Failed to begin transaction')
return false
}
@@ -89,7 +97,8 @@ export async function updatePaymentStatus(
// Check if version still matches
if ((paymentInTransaction.version || 1) !== currentVersion) {
// Version conflict detected - payment was modified by another process
console.warn(`[Payment Update] Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
const logger = createContextLogger(payload, 'Payment Update')
logger.warn(`Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
await payload.db.rollbackTransaction(transactionID)
return false
}
@@ -116,7 +125,8 @@ export async function updatePaymentStatus(
throw error
}
} catch (error) {
console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error)
const logger = createContextLogger(payload, 'Payment Update')
logger.error(`Failed to update payment ${paymentId}:`, error)
return false
}
}
@@ -152,13 +162,19 @@ export async function updateInvoiceOnPaymentSuccess(
export function handleWebhookError(
provider: string,
error: unknown,
context?: string
context?: string,
payload?: Payload
): Response {
const message = error instanceof Error ? error.message : 'Unknown error'
const fullContext = context ? `[${provider} Webhook - ${context}]` : `[${provider} Webhook]`
const fullContext = context ? `${provider} Webhook - ${context}` : `${provider} Webhook`
// Log detailed error internally for debugging
console.error(`${fullContext} Error:`, error)
if (payload) {
const logger = createContextLogger(payload, fullContext)
logger.error('Error:', error)
} else {
console.error(`[${fullContext}] Error:`, error)
}
// Return generic response to avoid information disclosure
return Response.json({
@@ -173,9 +189,15 @@ export function handleWebhookError(
export function logWebhookEvent(
provider: string,
event: string,
details?: any
details?: any,
payload?: Payload
): void {
console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '')
if (payload) {
const logger = createContextLogger(payload, `${provider} Webhook`)
logger.info(event, details ? JSON.stringify(details) : '')
} else {
console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '')
}
}
/**

48
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { Payload } from 'payload'
let pluginLogger: any = null
/**
* Get or create the plugin logger instance
* Uses PAYLOAD_BILLING_LOG_LEVEL environment variable to configure log level
* Defaults to 'info' if not set
*/
export function getPluginLogger(payload: Payload) {
if (!pluginLogger && payload.logger) {
const logLevel = process.env.PAYLOAD_BILLING_LOG_LEVEL || 'info'
pluginLogger = payload.logger.child({
level: logLevel,
plugin: '@xtr-dev/payload-billing'
})
// Log the configured log level on first initialization
pluginLogger.info(`Logger initialized with level: ${logLevel}`)
}
// Fallback to console if logger not available (shouldn't happen in normal operation)
if (!pluginLogger) {
return {
debug: (...args: any[]) => console.log('[BILLING DEBUG]', ...args),
info: (...args: any[]) => console.log('[BILLING INFO]', ...args),
warn: (...args: any[]) => console.warn('[BILLING WARN]', ...args),
error: (...args: any[]) => console.error('[BILLING ERROR]', ...args),
}
}
return pluginLogger
}
/**
* Create a context-specific logger for a particular operation
*/
export function createContextLogger(payload: Payload, context: string) {
const logger = getPluginLogger(payload)
return {
debug: (message: string, ...args: any[]) => logger.debug(`[${context}] ${message}`, ...args),
info: (message: string, ...args: any[]) => logger.info(`[${context}] ${message}`, ...args),
warn: (message: string, ...args: any[]) => logger.warn(`[${context}] ${message}`, ...args),
error: (message: string, ...args: any[]) => logger.error(`[${context}] ${message}`, ...args),
}
}