mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 02:43:24 +00:00
Compare commits
11 Commits
claude/iss
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 857fc663b3 | |||
| 552ec700c2 | |||
| f7d6066d9a | |||
| b27b5806b1 | |||
| da96a0a838 | |||
| 2374dbcec8 | |||
| 05d612e606 | |||
| dc9bc2db57 | |||
| 7590a5445c | |||
| ed27501afc | |||
|
|
56bd4fc7ce |
283
README.md
283
README.md
@@ -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.
|
⚠️ **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
|
## Features
|
||||||
|
|
||||||
- 💳 Multiple payment providers (Stripe, Mollie, Test)
|
- 💳 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
|
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
|
## Webhook Endpoints
|
||||||
|
|
||||||
Automatic webhook endpoints are created for configured providers:
|
Automatic webhook endpoints are created for configured providers:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-billing",
|
"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",
|
"description": "PayloadCMS plugin for billing and payment provider integrations with tracking and local testing",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
CollectionBeforeValidateHook,
|
CollectionBeforeValidateHook,
|
||||||
CollectionConfig, Field,
|
CollectionConfig, Field,
|
||||||
} from 'payload'
|
} from 'payload'
|
||||||
import type { BillingPluginConfig} from '../plugin/config';
|
import type { BillingPluginConfig} from '@/plugin/config';
|
||||||
import { defaults } from '../plugin/config'
|
import { defaults } from '@/plugin/config'
|
||||||
import { extractSlug } from '../plugin/utils'
|
import { extractSlug } from '@/plugin/utils'
|
||||||
import type { Invoice } from '../plugin/types/invoices'
|
import { createContextLogger } from '@/utils/logger'
|
||||||
|
import type { Invoice } from '@/plugin/types'
|
||||||
|
|
||||||
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
export function createInvoicesCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||||
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
|
const {customerRelationSlug, customerInfoExtractor} = pluginConfig
|
||||||
@@ -314,7 +315,8 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
afterChange: [
|
afterChange: [
|
||||||
({ doc, operation, req }) => {
|
({ doc, operation, req }) => {
|
||||||
if (operation === 'create') {
|
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>[],
|
] satisfies CollectionAfterChangeHook<Invoice>[],
|
||||||
@@ -350,7 +352,8 @@ export function createInvoicesCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
data.billingAddress = extractedInfo.billingAddress
|
data.billingAddress = extractedInfo.billingAddress
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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')
|
throw new Error('Failed to extract customer information')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AccessArgs, CollectionConfig } from 'payload'
|
|||||||
import { BillingPluginConfig, defaults } from '../plugin/config'
|
import { BillingPluginConfig, defaults } from '../plugin/config'
|
||||||
import { extractSlug } from '../plugin/utils'
|
import { extractSlug } from '../plugin/utils'
|
||||||
import { Payment } from '../plugin/types/index'
|
import { Payment } from '../plugin/types/index'
|
||||||
|
import { createContextLogger } from '../utils/logger'
|
||||||
|
|
||||||
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
export function createRefundsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
||||||
// TODO: finish collection overrides
|
// TODO: finish collection overrides
|
||||||
@@ -111,7 +112,8 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
|
|||||||
afterChange: [
|
afterChange: [
|
||||||
async ({ doc, operation, req }) => {
|
async ({ doc, operation, req }) => {
|
||||||
if (operation === 'create') {
|
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
|
// Update the related payment's refund relationship
|
||||||
try {
|
try {
|
||||||
@@ -129,7 +131,8 @@ export function createRefundsCollection(pluginConfig: BillingPluginConfig): Coll
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export type { BillingPluginConfig, CustomerInfoExtractor, AdvancedTestProviderCo
|
|||||||
export type { Invoice, Payment, Refund } from './plugin/types/index.js'
|
export type { Invoice, Payment, Refund } from './plugin/types/index.js'
|
||||||
export type { PaymentProvider, ProviderData } from './providers/types.js'
|
export type { PaymentProvider, ProviderData } from './providers/types.js'
|
||||||
|
|
||||||
|
// Export logging utilities
|
||||||
|
export { getPluginLogger, createContextLogger } from './utils/logger.js'
|
||||||
|
|
||||||
// Export all providers
|
// Export all providers
|
||||||
export { testProvider } from './providers/test.js'
|
export { testProvider } from './providers/test.js'
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -55,6 +55,6 @@ export interface BillingPluginConfig {
|
|||||||
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
|
customerInfoExtractor?: CustomerInfoExtractor // Callback to extract customer info from relationship
|
||||||
customerRelationSlug?: string // Customer collection slug for relationship
|
customerRelationSlug?: string // Customer collection slug for relationship
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
providers?: PaymentProvider[]
|
providers?: (PaymentProvider | undefined | null)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
|||||||
];
|
];
|
||||||
|
|
||||||
(pluginConfig.providers || [])
|
(pluginConfig.providers || [])
|
||||||
.filter(provider => provider.onConfig)
|
.filter(provider => provider?.onConfig)
|
||||||
.forEach(provider => provider.onConfig!(config, pluginConfig))
|
.forEach(provider => provider?.onConfig!(config, pluginConfig))
|
||||||
|
|
||||||
const incomingOnInit = config.onInit
|
const incomingOnInit = config.onInit
|
||||||
config.onInit = async (payload) => {
|
config.onInit = async (payload) => {
|
||||||
@@ -38,17 +38,17 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
|||||||
}
|
}
|
||||||
singleton.set(payload, {
|
singleton.set(payload, {
|
||||||
config: pluginConfig,
|
config: pluginConfig,
|
||||||
providerConfig: (pluginConfig.providers || []).reduce(
|
providerConfig: (pluginConfig.providers || []).filter(Boolean).reduce(
|
||||||
(record, provider) => {
|
(record, provider) => {
|
||||||
record[provider.key] = provider
|
record[provider!.key] = provider as PaymentProvider
|
||||||
return record
|
return record
|
||||||
},
|
},
|
||||||
{} as Record<string, PaymentProvider>
|
{} as Record<string, PaymentProvider>
|
||||||
)
|
)
|
||||||
} satisfies BillingPlugin)
|
} satisfies BillingPlugin)
|
||||||
await Promise.all((pluginConfig.providers || [])
|
await Promise.all((pluginConfig.providers || [])
|
||||||
.filter(provider => provider.onInit)
|
.filter(provider => provider?.onInit)
|
||||||
.map(provider => provider.onInit!(payload)))
|
.map(provider => provider?.onInit!(payload)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
validateProductionUrl
|
validateProductionUrl
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
|
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
|
||||||
|
import { createContextLogger } from '../utils/logger'
|
||||||
|
|
||||||
const symbol = Symbol('mollie')
|
const symbol = Symbol('mollie')
|
||||||
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
||||||
@@ -96,12 +97,13 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
|||||||
if (status === 'succeeded' && updateSuccess) {
|
if (status === 'succeeded' && updateSuccess) {
|
||||||
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
||||||
} else if (!updateSuccess) {
|
} 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()
|
return webhookResponses.success()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleWebhookError('Mollie', error)
|
return handleWebhookError('Mollie', error, undefined, req.payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
logWebhookEvent
|
logWebhookEvent
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { isValidAmount, isValidCurrencyCode } from './currency'
|
import { isValidAmount, isValidCurrencyCode } from './currency'
|
||||||
|
import { createContextLogger } from '../utils/logger'
|
||||||
|
|
||||||
const symbol = Symbol('stripe')
|
const symbol = Symbol('stripe')
|
||||||
|
|
||||||
@@ -60,13 +61,13 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
return webhookResponses.missingBody()
|
return webhookResponses.missingBody()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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')
|
const signature = req.headers.get('stripe-signature')
|
||||||
|
|
||||||
if (!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
|
// 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 {
|
try {
|
||||||
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret!)
|
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret!)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleWebhookError('Stripe', err, 'Signature verification failed')
|
return handleWebhookError('Stripe', err, 'Signature verification failed', req.payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different event types
|
// Handle different event types
|
||||||
@@ -90,7 +91,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
|
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
|
||||||
|
|
||||||
if (!payment) {
|
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
|
return webhookResponses.success() // Still return 200 to acknowledge receipt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +130,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
if (status === 'succeeded' && updateSuccess) {
|
if (status === 'succeeded' && updateSuccess) {
|
||||||
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
await updateInvoiceOnPaymentSuccess(payload, payment, pluginConfig)
|
||||||
} else if (!updateSuccess) {
|
} 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
|
break
|
||||||
}
|
}
|
||||||
@@ -172,7 +174,8 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!updateSuccess) {
|
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
|
break
|
||||||
@@ -180,19 +183,16 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
// Unhandled event type
|
// Unhandled event type
|
||||||
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`)
|
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`, undefined, req.payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
return webhookResponses.success()
|
return webhookResponses.success()
|
||||||
} catch (error) {
|
} 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) => {
|
onInit: async (payload: Payload) => {
|
||||||
@@ -201,6 +201,12 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
apiVersion: stripeConfig.apiVersion || DEFAULT_API_VERSION,
|
apiVersion: stripeConfig.apiVersion || DEFAULT_API_VERSION,
|
||||||
})
|
})
|
||||||
singleton.set(payload, stripe)
|
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) => {
|
initPayment: async (payload, payment) => {
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
|
|||||||
@@ -4,6 +4,110 @@ import type { BillingPluginConfig } from '../plugin/config'
|
|||||||
import type { Payload } from 'payload'
|
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'
|
||||||
|
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'
|
export type PaymentOutcome = 'paid' | 'failed' | 'cancelled' | 'expired' | 'pending'
|
||||||
|
|
||||||
@@ -120,17 +224,14 @@ const testPaymentSessions = new Map<string, TestPaymentSession>()
|
|||||||
|
|
||||||
export const testProvider = (testConfig: TestProviderConfig) => {
|
export const testProvider = (testConfig: TestProviderConfig) => {
|
||||||
if (!testConfig.enabled) {
|
if (!testConfig.enabled) {
|
||||||
throw new Error('Test provider is disabled')
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenarios = testConfig.scenarios || DEFAULT_SCENARIOS
|
const scenarios = testConfig.scenarios || DEFAULT_SCENARIOS
|
||||||
const baseUrl = testConfig.baseUrl || (process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000')
|
const baseUrl = testConfig.baseUrl || (process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000')
|
||||||
const uiRoute = testConfig.customUiRoute || '/test-payment'
|
const uiRoute = testConfig.customUiRoute || '/test-payment'
|
||||||
|
|
||||||
// Log test mode warnings if enabled
|
// Test mode warnings will be logged in onInit when payload is available
|
||||||
if (testConfig.testModeIndicators?.consoleWarnings !== false) {
|
|
||||||
console.warn('🧪 [TEST PROVIDER] Payment system is running in test mode')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: 'test',
|
key: 'test',
|
||||||
@@ -141,17 +242,33 @@ export const testProvider = (testConfig: TestProviderConfig) => {
|
|||||||
{
|
{
|
||||||
path: '/payload-billing/test/payment/:id',
|
path: '/payload-billing/test/payment/: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('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
|
||||||
@@ -173,10 +290,10 @@ export const testProvider = (testConfig: TestProviderConfig) => {
|
|||||||
name: method.name,
|
name: method.name,
|
||||||
icon: method.icon
|
icon: method.icon
|
||||||
})),
|
})),
|
||||||
testModeIndicators: testConfig.testModeIndicators || {
|
testModeIndicators: {
|
||||||
showWarningBanners: true,
|
showWarningBanners: testConfig.testModeIndicators?.showWarningBanners ?? true,
|
||||||
showTestBadges: true,
|
showTestBadges: testConfig.testModeIndicators?.showTestBadges ?? true,
|
||||||
consoleWarnings: true
|
consoleWarnings: testConfig.testModeIndicators?.consoleWarnings ?? true
|
||||||
},
|
},
|
||||||
defaultDelay: testConfig.defaultDelay || 1000,
|
defaultDelay: testConfig.defaultDelay || 1000,
|
||||||
customUiRoute: uiRoute
|
customUiRoute: uiRoute
|
||||||
@@ -193,7 +310,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) {
|
||||||
@@ -205,7 +332,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' }
|
||||||
})
|
})
|
||||||
@@ -219,36 +346,38 @@ export const testProvider = (testConfig: TestProviderConfig) => {
|
|||||||
// Process payment after delay
|
// Process payment after delay
|
||||||
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)
|
const logger = createContextLogger(payload, 'Test Provider')
|
||||||
|
logger.error('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) {
|
||||||
|
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)
|
}, scenario.delay || testConfig.defaultDelay || 1000)
|
||||||
@@ -262,17 +391,18 @@ export const testProvider = (testConfig: TestProviderConfig) => {
|
|||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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',
|
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,
|
||||||
@@ -280,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)
|
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' }), {
|
||||||
@@ -299,8 +438,15 @@ 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', 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)
|
// Clean up old sessions periodically (older than 1 hour)
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -312,7 +458,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
|
|||||||
})
|
})
|
||||||
}, 10 * 60 * 1000) // Clean every 10 minutes
|
}, 10 * 60 * 1000) // Clean every 10 minutes
|
||||||
},
|
},
|
||||||
initPayment: async (payload, payment) => {
|
initPayment: (payload, payment) => {
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!payment.amount) {
|
if (!payment.amount) {
|
||||||
throw new Error('Amount is required')
|
throw new Error('Amount is required')
|
||||||
@@ -404,23 +550,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: {
|
||||||
@@ -436,20 +565,31 @@ 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
|
||||||
})
|
)
|
||||||
|
|
||||||
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) {
|
} 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'
|
session.status = 'failed'
|
||||||
|
throw error // Re-throw to be handled by the caller
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,12 +928,12 @@ function generateTestPaymentUI(
|
|||||||
setTimeout(() => pollStatus(), 2000);
|
setTimeout(() => pollStatus(), 2000);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to poll status:', error);
|
console.error('[Test Provider] Failed to poll status:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
${testModeIndicators.consoleWarnings !== false ? `
|
${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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { BillingPluginConfig } from '../plugin/config'
|
|||||||
import type { ProviderData } from './types'
|
import type { ProviderData } from './types'
|
||||||
import { defaults } from '../plugin/config'
|
import { defaults } from '../plugin/config'
|
||||||
import { extractSlug, toPayloadId } from '../plugin/utils'
|
import { extractSlug, toPayloadId } from '../plugin/utils'
|
||||||
|
import { createContextLogger } from '../utils/logger'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common webhook response utilities
|
* Common webhook response utilities
|
||||||
@@ -11,9 +12,14 @@ import { extractSlug, toPayloadId } from '../plugin/utils'
|
|||||||
*/
|
*/
|
||||||
export const webhookResponses = {
|
export const webhookResponses = {
|
||||||
success: () => Response.json({ received: true }, { status: 200 }),
|
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
|
// Log error internally but don't expose details
|
||||||
|
if (payload) {
|
||||||
|
const logger = createContextLogger(payload, 'Webhook')
|
||||||
|
logger.error('Error:', message)
|
||||||
|
} else {
|
||||||
console.error('[Webhook] Error:', message)
|
console.error('[Webhook] Error:', message)
|
||||||
|
}
|
||||||
return Response.json({ error: 'Invalid request' }, { status })
|
return Response.json({ error: 'Invalid request' }, { status })
|
||||||
},
|
},
|
||||||
missingBody: () => Response.json({ received: true }, { status: 200 }),
|
missingBody: () => Response.json({ received: true }, { status: 200 }),
|
||||||
@@ -63,7 +69,8 @@ export async function updatePaymentStatus(
|
|||||||
}) as Payment
|
}) as Payment
|
||||||
|
|
||||||
if (!currentPayment) {
|
if (!currentPayment) {
|
||||||
console.error(`[Payment Update] Payment ${paymentId} not found`)
|
const logger = createContextLogger(payload, 'Payment Update')
|
||||||
|
logger.error(`Payment ${paymentId} not found`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +81,8 @@ export async function updatePaymentStatus(
|
|||||||
const transactionID = await payload.db.beginTransaction()
|
const transactionID = await payload.db.beginTransaction()
|
||||||
|
|
||||||
if (!transactionID) {
|
if (!transactionID) {
|
||||||
console.error(`[Payment Update] Failed to begin transaction`)
|
const logger = createContextLogger(payload, 'Payment Update')
|
||||||
|
logger.error('Failed to begin transaction')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +97,8 @@ export async function updatePaymentStatus(
|
|||||||
// Check if version still matches
|
// Check if version still matches
|
||||||
if ((paymentInTransaction.version || 1) !== currentVersion) {
|
if ((paymentInTransaction.version || 1) !== currentVersion) {
|
||||||
// Version conflict detected - payment was modified by another process
|
// 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)
|
await payload.db.rollbackTransaction(transactionID)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -116,7 +125,8 @@ export async function updatePaymentStatus(
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
} catch (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
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,13 +162,19 @@ export async function updateInvoiceOnPaymentSuccess(
|
|||||||
export function handleWebhookError(
|
export function handleWebhookError(
|
||||||
provider: string,
|
provider: string,
|
||||||
error: unknown,
|
error: unknown,
|
||||||
context?: string
|
context?: string,
|
||||||
|
payload?: Payload
|
||||||
): Response {
|
): Response {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
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
|
// 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 generic response to avoid information disclosure
|
||||||
return Response.json({
|
return Response.json({
|
||||||
@@ -173,10 +189,16 @@ export function handleWebhookError(
|
|||||||
export function logWebhookEvent(
|
export function logWebhookEvent(
|
||||||
provider: string,
|
provider: string,
|
||||||
event: string,
|
event: string,
|
||||||
details?: any
|
details?: any,
|
||||||
|
payload?: Payload
|
||||||
): void {
|
): void {
|
||||||
|
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) : '')
|
console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate URL for production use
|
* Validate URL for production use
|
||||||
|
|||||||
48
src/utils/logger.ts
Normal file
48
src/utils/logger.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user