mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 10:53:23 +00:00
56
README.md
56
README.md
@@ -24,30 +24,46 @@ pnpm add @xtr-dev/payload-billing
|
|||||||
yarn add @xtr-dev/payload-billing
|
yarn add @xtr-dev/payload-billing
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Provider Dependencies
|
||||||
|
|
||||||
|
Payment providers are peer dependencies and must be installed separately based on which providers you plan to use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For Stripe support
|
||||||
|
npm install stripe
|
||||||
|
# or
|
||||||
|
pnpm add stripe
|
||||||
|
|
||||||
|
# For Mollie support
|
||||||
|
npm install @mollie/api-client
|
||||||
|
# or
|
||||||
|
pnpm add @mollie/api-client
|
||||||
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { buildConfig } from 'payload'
|
import { buildConfig } from 'payload'
|
||||||
import { billingPlugin } from '@xtr-dev/payload-billing'
|
import { billingPlugin, stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
|
||||||
|
|
||||||
export default buildConfig({
|
export default buildConfig({
|
||||||
// ... your config
|
// ... your config
|
||||||
plugins: [
|
plugins: [
|
||||||
billingPlugin({
|
billingPlugin({
|
||||||
providers: {
|
providers: [
|
||||||
stripe: {
|
stripeProvider({
|
||||||
secretKey: process.env.STRIPE_SECRET_KEY!,
|
secretKey: process.env.STRIPE_SECRET_KEY!,
|
||||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!,
|
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
||||||
webhookEndpointSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
}),
|
||||||
},
|
mollieProvider({
|
||||||
mollie: {
|
|
||||||
apiKey: process.env.MOLLIE_API_KEY!,
|
apiKey: process.env.MOLLIE_API_KEY!,
|
||||||
webhookUrl: process.env.MOLLIE_WEBHOOK_URL!,
|
webhookUrl: process.env.MOLLIE_WEBHOOK_URL,
|
||||||
},
|
}),
|
||||||
test: {
|
],
|
||||||
enabled: process.env.NODE_ENV === 'development',
|
collections: {
|
||||||
autoComplete: true,
|
payments: 'payments',
|
||||||
}
|
invoices: 'invoices',
|
||||||
|
refunds: 'refunds',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
@@ -60,11 +76,11 @@ export default buildConfig({
|
|||||||
// Main plugin
|
// Main plugin
|
||||||
import { billingPlugin } from '@xtr-dev/payload-billing'
|
import { billingPlugin } from '@xtr-dev/payload-billing'
|
||||||
|
|
||||||
// Provider utilities
|
// Payment providers
|
||||||
import { getPaymentProvider } from '@xtr-dev/payload-billing'
|
import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { PaymentProvider, CreatePaymentOptions, Payment } from '@xtr-dev/payload-billing'
|
import type { PaymentProvider, Payment, Invoice, Refund } from '@xtr-dev/payload-billing'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Provider Types
|
## Provider Types
|
||||||
@@ -83,16 +99,14 @@ Local development testing with configurable scenarios, automatic completion, deb
|
|||||||
The plugin adds these collections:
|
The plugin adds these collections:
|
||||||
|
|
||||||
- **payments** - Payment transactions with status and provider data
|
- **payments** - Payment transactions with status and provider data
|
||||||
- **customers** - Customer profiles with billing information
|
|
||||||
- **invoices** - Invoice generation with line items and PDF support
|
- **invoices** - Invoice generation with line items and PDF support
|
||||||
- **refunds** - Refund tracking and management
|
- **refunds** - Refund tracking and management
|
||||||
|
|
||||||
## Webhook Endpoints
|
## Webhook Endpoints
|
||||||
|
|
||||||
Automatic webhook endpoints are created:
|
Automatic webhook endpoints are created for configured providers:
|
||||||
- `/api/billing/webhooks/stripe`
|
- `/api/payload-billing/stripe/webhook` - Stripe payment notifications
|
||||||
- `/api/billing/webhooks/mollie`
|
- `/api/payload-billing/mollie/webhook` - Mollie payment notifications
|
||||||
- `/api/billing/webhooks/test`
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-billing",
|
"name": "@xtr-dev/payload-billing",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"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",
|
||||||
@@ -100,16 +100,17 @@
|
|||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
"sharp": "0.34.2",
|
"sharp": "0.34.2",
|
||||||
"sort-package-json": "^2.10.0",
|
"sort-package-json": "^2.10.0",
|
||||||
|
"stripe": "^18.5.0",
|
||||||
"typescript": "5.7.3",
|
"typescript": "5.7.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.1.2"
|
"vitest": "^3.1.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@mollie/api-client": "^3.7.0",
|
"@mollie/api-client": "^3.7.0",
|
||||||
"payload": "^3.37.0"
|
"payload": "^3.37.0",
|
||||||
|
"stripe": "^18.5.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"stripe": "^14.15.0",
|
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -8,9 +8,6 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
stripe:
|
|
||||||
specifier: ^14.15.0
|
|
||||||
version: 14.25.0
|
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.22.4
|
specifier: ^3.22.4
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -111,6 +108,9 @@ importers:
|
|||||||
sort-package-json:
|
sort-package-json:
|
||||||
specifier: ^2.10.0
|
specifier: ^2.10.0
|
||||||
version: 2.15.1
|
version: 2.15.1
|
||||||
|
stripe:
|
||||||
|
specifier: ^18.5.0
|
||||||
|
version: 18.5.0(@types/node@22.18.1)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 5.7.3
|
specifier: 5.7.3
|
||||||
version: 5.7.3
|
version: 5.7.3
|
||||||
@@ -5165,9 +5165,14 @@ packages:
|
|||||||
strip-literal@3.0.0:
|
strip-literal@3.0.0:
|
||||||
resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==}
|
resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==}
|
||||||
|
|
||||||
stripe@14.25.0:
|
stripe@18.5.0:
|
||||||
resolution: {integrity: sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==}
|
resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==}
|
||||||
engines: {node: '>=12.*'}
|
engines: {node: '>=12.*'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=12.x.x'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
strtok3@10.3.4:
|
strtok3@10.3.4:
|
||||||
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
|
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
|
||||||
@@ -11434,10 +11439,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
stripe@14.25.0:
|
stripe@18.5.0(@types/node@22.18.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.18.1
|
|
||||||
qs: 6.14.0
|
qs: 6.14.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.18.1
|
||||||
|
|
||||||
strtok3@10.3.4:
|
strtok3@10.3.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
},
|
},
|
||||||
label: 'Provider Payment ID',
|
label: 'Provider Payment ID',
|
||||||
unique: true,
|
unique: true,
|
||||||
|
index: true, // Ensure this field is indexed for webhook lookups
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'status',
|
name: 'status',
|
||||||
|
|||||||
@@ -10,18 +10,6 @@ export const defaults = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Provider configurations
|
// Provider configurations
|
||||||
export interface StripeConfig {
|
|
||||||
apiVersion?: string
|
|
||||||
publishableKey: string
|
|
||||||
secretKey: string
|
|
||||||
webhookEndpointSecret: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MollieConfig {
|
|
||||||
apiKey: string
|
|
||||||
testMode?: boolean
|
|
||||||
webhookUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestProviderConfig {
|
export interface TestProviderConfig {
|
||||||
autoComplete?: boolean
|
autoComplete?: boolean
|
||||||
@@ -65,13 +53,5 @@ export interface BillingPluginConfig {
|
|||||||
customerRelationSlug?: string // Customer collection slug for relationship
|
customerRelationSlug?: string // Customer collection slug for relationship
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
providers?: PaymentProvider[]
|
providers?: PaymentProvider[]
|
||||||
webhooks?: {
|
|
||||||
basePath?: string
|
|
||||||
cors?: boolean
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin type
|
|
||||||
export interface BillingPluginOptions extends BillingPluginConfig {
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
|||||||
createPaymentsCollection(pluginConfig),
|
createPaymentsCollection(pluginConfig),
|
||||||
createInvoicesCollection(pluginConfig),
|
createInvoicesCollection(pluginConfig),
|
||||||
createRefundsCollection(pluginConfig),
|
createRefundsCollection(pluginConfig),
|
||||||
]
|
];
|
||||||
|
|
||||||
|
(pluginConfig.providers || [])
|
||||||
|
.filter(provider => provider.onConfig)
|
||||||
|
.forEach(provider => provider.onConfig!(config, pluginConfig))
|
||||||
|
|
||||||
const incomingOnInit = config.onInit
|
const incomingOnInit = config.onInit
|
||||||
config.onInit = async (payload) => {
|
config.onInit = async (payload) => {
|
||||||
@@ -35,15 +39,16 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config
|
|||||||
singleton.set(payload, {
|
singleton.set(payload, {
|
||||||
config: pluginConfig,
|
config: pluginConfig,
|
||||||
providerConfig: (pluginConfig.providers || []).reduce(
|
providerConfig: (pluginConfig.providers || []).reduce(
|
||||||
(acc, val) => {
|
(record, provider) => {
|
||||||
acc[val.key] = val
|
record[provider.key] = provider
|
||||||
return acc
|
return record
|
||||||
},
|
},
|
||||||
{} as Record<string, PaymentProvider>
|
{} as Record<string, PaymentProvider>
|
||||||
)
|
)
|
||||||
} satisfies BillingPlugin)
|
} satisfies BillingPlugin)
|
||||||
console.log('Billing plugin initialized', singleton.get(payload))
|
await Promise.all((pluginConfig.providers || [])
|
||||||
await Promise.all((pluginConfig.providers || []).map(p => p.onInit(payload)))
|
.filter(provider => provider.onInit)
|
||||||
|
.map(provider => provider.onInit!(payload)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|||||||
94
src/providers/currency.ts
Normal file
94
src/providers/currency.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Currency utilities for payment processing
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Currencies that don't use centesimal units (no decimal places)
|
||||||
|
const NON_CENTESIMAL_CURRENCIES = new Set([
|
||||||
|
'BIF', // Burundian Franc
|
||||||
|
'CLP', // Chilean Peso
|
||||||
|
'DJF', // Djiboutian Franc
|
||||||
|
'GNF', // Guinean Franc
|
||||||
|
'JPY', // Japanese Yen
|
||||||
|
'KMF', // Comorian Franc
|
||||||
|
'KRW', // South Korean Won
|
||||||
|
'MGA', // Malagasy Ariary
|
||||||
|
'PYG', // Paraguayan Guaraní
|
||||||
|
'RWF', // Rwandan Franc
|
||||||
|
'UGX', // Ugandan Shilling
|
||||||
|
'VND', // Vietnamese Đồng
|
||||||
|
'VUV', // Vanuatu Vatu
|
||||||
|
'XAF', // Central African CFA Franc
|
||||||
|
'XOF', // West African CFA Franc
|
||||||
|
'XPF', // CFP Franc
|
||||||
|
])
|
||||||
|
|
||||||
|
// Currencies that use 3 decimal places
|
||||||
|
const THREE_DECIMAL_CURRENCIES = new Set([
|
||||||
|
'BHD', // Bahraini Dinar
|
||||||
|
'IQD', // Iraqi Dinar
|
||||||
|
'JOD', // Jordanian Dinar
|
||||||
|
'KWD', // Kuwaiti Dinar
|
||||||
|
'LYD', // Libyan Dinar
|
||||||
|
'OMR', // Omani Rial
|
||||||
|
'TND', // Tunisian Dinar
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert amount from smallest unit to decimal for display
|
||||||
|
* @param amount - Amount in smallest unit (e.g., cents for USD)
|
||||||
|
* @param currency - ISO 4217 currency code
|
||||||
|
* @returns Formatted amount string for the payment provider
|
||||||
|
*/
|
||||||
|
export function formatAmountForProvider(amount: number, currency: string): string {
|
||||||
|
const upperCurrency = currency.toUpperCase()
|
||||||
|
|
||||||
|
if (NON_CENTESIMAL_CURRENCIES.has(upperCurrency)) {
|
||||||
|
// No decimal places
|
||||||
|
return amount.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (THREE_DECIMAL_CURRENCIES.has(upperCurrency)) {
|
||||||
|
// 3 decimal places
|
||||||
|
return (amount / 1000).toFixed(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: 2 decimal places (most currencies)
|
||||||
|
return (amount / 100).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of decimal places for a currency
|
||||||
|
* @param currency - ISO 4217 currency code
|
||||||
|
* @returns Number of decimal places
|
||||||
|
*/
|
||||||
|
export function getCurrencyDecimals(currency: string): number {
|
||||||
|
const upperCurrency = currency.toUpperCase()
|
||||||
|
|
||||||
|
if (NON_CENTESIMAL_CURRENCIES.has(upperCurrency)) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (THREE_DECIMAL_CURRENCIES.has(upperCurrency)) {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate currency code format
|
||||||
|
* @param currency - Currency code to validate
|
||||||
|
* @returns True if valid ISO 4217 format
|
||||||
|
*/
|
||||||
|
export function isValidCurrencyCode(currency: string): boolean {
|
||||||
|
return /^[A-Z]{3}$/.test(currency.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate amount is positive and within reasonable limits
|
||||||
|
* @param amount - Amount to validate
|
||||||
|
* @returns True if valid
|
||||||
|
*/
|
||||||
|
export function isValidAmount(amount: number): boolean {
|
||||||
|
return Number.isInteger(amount) && amount > 0 && amount <= 99999999999 // Max ~999 million in major units
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export * from './mollie'
|
export * from './mollie'
|
||||||
|
export * from './stripe'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
export * from './currency'
|
||||||
|
|||||||
@@ -1,36 +1,155 @@
|
|||||||
import type { Payment } from '@/plugin/types/payments'
|
import type { Payment } from '@/plugin/types/payments'
|
||||||
import type { InitPayment, PaymentProvider } from '@/plugin/types'
|
import type { PaymentProvider } from '@/plugin/types'
|
||||||
import type { Payload } from 'payload'
|
import type { Payload } from 'payload'
|
||||||
import { createSingleton } from '@/plugin/singleton'
|
import { createSingleton } from '@/plugin/singleton'
|
||||||
import type { createMollieClient, MollieClient } from '@mollie/api-client'
|
import type { createMollieClient, MollieClient } from '@mollie/api-client'
|
||||||
|
import {
|
||||||
|
webhookResponses,
|
||||||
|
findPaymentByProviderId,
|
||||||
|
updatePaymentStatus,
|
||||||
|
updateInvoiceOnPaymentSuccess,
|
||||||
|
handleWebhookError,
|
||||||
|
validateProductionUrl
|
||||||
|
} from './utils'
|
||||||
|
import { formatAmountForProvider, isValidAmount, isValidCurrencyCode } from './currency'
|
||||||
|
|
||||||
const symbol = Symbol('mollie')
|
const symbol = Symbol('mollie')
|
||||||
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
export type MollieProviderConfig = Parameters<typeof createMollieClient>[0]
|
||||||
|
|
||||||
export const mollieProvider = (config: MollieProviderConfig) => {
|
/**
|
||||||
|
* Type-safe mapping of Mollie payment status to internal status
|
||||||
|
*/
|
||||||
|
function mapMollieStatusToPaymentStatus(mollieStatus: string): Payment['status'] {
|
||||||
|
// Define known Mollie statuses for type safety
|
||||||
|
const mollieStatusMap: Record<string, Payment['status']> = {
|
||||||
|
'paid': 'succeeded',
|
||||||
|
'failed': 'failed',
|
||||||
|
'canceled': 'canceled',
|
||||||
|
'expired': 'canceled',
|
||||||
|
'pending': 'pending',
|
||||||
|
'open': 'pending',
|
||||||
|
'authorized': 'pending',
|
||||||
|
}
|
||||||
|
|
||||||
|
return mollieStatusMap[mollieStatus] || 'processing'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
||||||
|
webhookUrl?: string
|
||||||
|
redirectUrl?: string
|
||||||
|
}) => {
|
||||||
|
// Validate required configuration at initialization
|
||||||
|
if (!mollieConfig.apiKey) {
|
||||||
|
throw new Error('Mollie API key is required')
|
||||||
|
}
|
||||||
|
|
||||||
const singleton = createSingleton<MollieClient>(symbol)
|
const singleton = createSingleton<MollieClient>(symbol)
|
||||||
return {
|
return {
|
||||||
key: 'mollie',
|
key: 'mollie',
|
||||||
|
onConfig: (config, pluginConfig) => {
|
||||||
|
// Always register Mollie webhook since it doesn't require a separate webhook secret
|
||||||
|
// Mollie validates webhooks through payment ID verification
|
||||||
|
config.endpoints = [
|
||||||
|
...(config.endpoints || []),
|
||||||
|
{
|
||||||
|
path: '/payload-billing/mollie/webhook',
|
||||||
|
method: 'post',
|
||||||
|
handler: async (req) => {
|
||||||
|
try {
|
||||||
|
const payload = req.payload
|
||||||
|
const mollieClient = singleton.get(payload)
|
||||||
|
|
||||||
|
// Parse the webhook body to get the Mollie payment ID
|
||||||
|
if (!req.text) {
|
||||||
|
return webhookResponses.missingBody()
|
||||||
|
}
|
||||||
|
const body = await req.text()
|
||||||
|
if (!body || !body.startsWith('id=')) {
|
||||||
|
return webhookResponses.invalidPayload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const molliePaymentId = body.slice(3) // Remove 'id=' prefix
|
||||||
|
|
||||||
|
// Fetch the payment details from Mollie
|
||||||
|
const molliePayment = await mollieClient.payments.get(molliePaymentId)
|
||||||
|
|
||||||
|
// Find the corresponding payment in our database
|
||||||
|
const payment = await findPaymentByProviderId(payload, molliePaymentId, pluginConfig)
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return webhookResponses.paymentNotFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map Mollie status to our status using proper type-safe mapping
|
||||||
|
const status = mapMollieStatusToPaymentStatus(molliePayment.status)
|
||||||
|
|
||||||
|
// Update the payment status and provider data
|
||||||
|
const updateSuccess = await updatePaymentStatus(
|
||||||
|
payload,
|
||||||
|
payment.id,
|
||||||
|
status,
|
||||||
|
molliePayment.toPlainObject(),
|
||||||
|
pluginConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
// If payment is successful and update succeeded, update the invoice
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return webhookResponses.success()
|
||||||
|
} catch (error) {
|
||||||
|
return handleWebhookError('Mollie', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
onInit: async (payload: Payload) => {
|
onInit: async (payload: Payload) => {
|
||||||
const createMollieClient = (await import('@mollie/api-client')).default
|
const createMollieClient = (await import('@mollie/api-client')).default
|
||||||
const mollieClient = createMollieClient(config)
|
const mollieClient = createMollieClient(mollieConfig)
|
||||||
singleton.set(payload, mollieClient)
|
singleton.set(payload, mollieClient)
|
||||||
},
|
},
|
||||||
initPayment: async (payload, payment) => {
|
initPayment: async (payload, payment) => {
|
||||||
|
// Validate required fields
|
||||||
if (!payment.amount) {
|
if (!payment.amount) {
|
||||||
throw new Error('Amount is required')
|
throw new Error('Amount is required')
|
||||||
}
|
}
|
||||||
if (!payment.currency) {
|
if (!payment.currency) {
|
||||||
throw new Error('Currency is required')
|
throw new Error('Currency is required')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate amount
|
||||||
|
if (!isValidAmount(payment.amount)) {
|
||||||
|
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate currency code
|
||||||
|
if (!isValidCurrencyCode(payment.currency)) {
|
||||||
|
throw new Error('Invalid currency: must be a 3-letter ISO code')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup URLs with development defaults
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production'
|
||||||
|
const redirectUrl = mollieConfig.redirectUrl ||
|
||||||
|
(!isProduction ? 'https://localhost:3000/payment/success' : undefined)
|
||||||
|
const webhookUrl = mollieConfig.webhookUrl ||
|
||||||
|
`${process.env.PAYLOAD_PUBLIC_SERVER_URL || (!isProduction ? 'https://localhost:3000' : '')}/api/payload-billing/mollie/webhook`
|
||||||
|
|
||||||
|
// Validate URLs for production
|
||||||
|
validateProductionUrl(redirectUrl, 'Redirect')
|
||||||
|
validateProductionUrl(webhookUrl, 'Webhook')
|
||||||
|
|
||||||
const molliePayment = await singleton.get(payload).payments.create({
|
const molliePayment = await singleton.get(payload).payments.create({
|
||||||
amount: {
|
amount: {
|
||||||
value: (payment.amount / 100).toFixed(2),
|
value: formatAmountForProvider(payment.amount, payment.currency),
|
||||||
currency: payment.currency
|
currency: payment.currency.toUpperCase()
|
||||||
},
|
},
|
||||||
description: payment.description || '',
|
description: payment.description || '',
|
||||||
redirectUrl: 'https://localhost:3000/payment/success',
|
redirectUrl,
|
||||||
webhookUrl: 'https://localhost:3000',
|
webhookUrl,
|
||||||
});
|
});
|
||||||
payment.providerId = molliePayment.id
|
payment.providerId = molliePayment.id
|
||||||
payment.providerData = molliePayment.toPlainObject()
|
payment.providerData = molliePayment.toPlainObject()
|
||||||
|
|||||||
260
src/providers/stripe.ts
Normal file
260
src/providers/stripe.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import type { Payment } from '@/plugin/types/payments'
|
||||||
|
import type { PaymentProvider, ProviderData } from '@/plugin/types'
|
||||||
|
import type { Payload } from 'payload'
|
||||||
|
import { createSingleton } from '@/plugin/singleton'
|
||||||
|
import type Stripe from 'stripe'
|
||||||
|
import {
|
||||||
|
webhookResponses,
|
||||||
|
findPaymentByProviderId,
|
||||||
|
updatePaymentStatus,
|
||||||
|
updateInvoiceOnPaymentSuccess,
|
||||||
|
handleWebhookError,
|
||||||
|
logWebhookEvent
|
||||||
|
} from './utils'
|
||||||
|
import { isValidAmount, isValidCurrencyCode } from './currency'
|
||||||
|
|
||||||
|
const symbol = Symbol('stripe')
|
||||||
|
|
||||||
|
export interface StripeProviderConfig {
|
||||||
|
secretKey: string
|
||||||
|
webhookSecret?: string
|
||||||
|
apiVersion?: Stripe.StripeConfig['apiVersion']
|
||||||
|
returnUrl?: string
|
||||||
|
webhookUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default API version for consistency
|
||||||
|
const DEFAULT_API_VERSION: Stripe.StripeConfig['apiVersion'] = '2025-08-27.basil'
|
||||||
|
|
||||||
|
export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
||||||
|
// Validate required configuration at initialization
|
||||||
|
if (!stripeConfig.secretKey) {
|
||||||
|
throw new Error('Stripe secret key is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleton = createSingleton<Stripe>(symbol)
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: 'stripe',
|
||||||
|
onConfig: (config, pluginConfig) => {
|
||||||
|
// Only register webhook endpoint if webhook secret is configured
|
||||||
|
if (stripeConfig.webhookSecret) {
|
||||||
|
config.endpoints = [
|
||||||
|
...(config.endpoints || []),
|
||||||
|
{
|
||||||
|
path: '/payload-billing/stripe/webhook',
|
||||||
|
method: 'post',
|
||||||
|
handler: async (req) => {
|
||||||
|
try {
|
||||||
|
const payload = req.payload
|
||||||
|
const stripe = singleton.get(payload)
|
||||||
|
|
||||||
|
// Get the raw body for signature verification
|
||||||
|
let body: string
|
||||||
|
try {
|
||||||
|
if (!req.text) {
|
||||||
|
return webhookResponses.missingBody()
|
||||||
|
}
|
||||||
|
body = await req.text()
|
||||||
|
if (!body) {
|
||||||
|
return webhookResponses.missingBody()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleWebhookError('Stripe', error, 'Failed to read request body')
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = req.headers.get('stripe-signature')
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
return webhookResponses.error('Missing webhook signature', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookSecret is guaranteed to exist since we only register this endpoint when it's configured
|
||||||
|
|
||||||
|
// Verify webhook signature and construct event
|
||||||
|
let event: Stripe.Event
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(body, signature, stripeConfig.webhookSecret)
|
||||||
|
} catch (err) {
|
||||||
|
return handleWebhookError('Stripe', err, 'Signature verification failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different event types
|
||||||
|
switch (event.type) {
|
||||||
|
case 'payment_intent.succeeded':
|
||||||
|
case 'payment_intent.payment_failed':
|
||||||
|
case 'payment_intent.canceled': {
|
||||||
|
const paymentIntent = event.data.object
|
||||||
|
|
||||||
|
// Find the corresponding payment in our database
|
||||||
|
const payment = await findPaymentByProviderId(payload, paymentIntent.id, pluginConfig)
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
logWebhookEvent('Stripe', `Payment not found for intent: ${paymentIntent.id}`)
|
||||||
|
return webhookResponses.success() // Still return 200 to acknowledge receipt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map Stripe status to our status
|
||||||
|
let status: Payment['status'] = 'pending'
|
||||||
|
|
||||||
|
if (paymentIntent.status === 'succeeded') {
|
||||||
|
status = 'succeeded'
|
||||||
|
} else if (paymentIntent.status === 'canceled') {
|
||||||
|
status = 'canceled'
|
||||||
|
} else if (paymentIntent.status === 'requires_payment_method' ||
|
||||||
|
paymentIntent.status === 'requires_confirmation' ||
|
||||||
|
paymentIntent.status === 'requires_action') {
|
||||||
|
status = 'pending'
|
||||||
|
} else if (paymentIntent.status === 'processing') {
|
||||||
|
status = 'processing'
|
||||||
|
} else {
|
||||||
|
status = 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the payment status and provider data
|
||||||
|
const providerData: ProviderData<Stripe.PaymentIntent> = {
|
||||||
|
raw: paymentIntent,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider: 'stripe'
|
||||||
|
}
|
||||||
|
const updateSuccess = await updatePaymentStatus(
|
||||||
|
payload,
|
||||||
|
payment.id,
|
||||||
|
status,
|
||||||
|
providerData,
|
||||||
|
pluginConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
// If payment is successful and update succeeded, update the invoice
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'charge.refunded': {
|
||||||
|
const charge = event.data.object
|
||||||
|
|
||||||
|
// Find the payment by charge ID or payment intent
|
||||||
|
let payment: Payment | null = null
|
||||||
|
|
||||||
|
// First try to find by payment intent ID
|
||||||
|
if (charge.payment_intent) {
|
||||||
|
payment = await findPaymentByProviderId(
|
||||||
|
payload,
|
||||||
|
charge.payment_intent as string,
|
||||||
|
pluginConfig
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found, try charge ID
|
||||||
|
if (!payment) {
|
||||||
|
payment = await findPaymentByProviderId(payload, charge.id, pluginConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment) {
|
||||||
|
// Determine if fully or partially refunded
|
||||||
|
const isFullyRefunded = charge.amount_refunded === charge.amount
|
||||||
|
|
||||||
|
const providerData: ProviderData<Stripe.Charge> = {
|
||||||
|
raw: charge,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider: 'stripe'
|
||||||
|
}
|
||||||
|
const updateSuccess = await updatePaymentStatus(
|
||||||
|
payload,
|
||||||
|
payment.id,
|
||||||
|
isFullyRefunded ? 'refunded' : 'partially_refunded',
|
||||||
|
providerData,
|
||||||
|
pluginConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!updateSuccess) {
|
||||||
|
console.warn(`[Stripe Webhook] Failed to update refund status for payment ${payment.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unhandled event type
|
||||||
|
logWebhookEvent('Stripe', `Unhandled event type: ${event.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return webhookResponses.success()
|
||||||
|
} catch (error) {
|
||||||
|
return handleWebhookError('Stripe', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
// Log that webhook endpoint is not registered
|
||||||
|
console.warn('[Stripe Provider] Webhook endpoint not registered - webhookSecret not configured')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onInit: async (payload: Payload) => {
|
||||||
|
const { default: Stripe } = await import('stripe')
|
||||||
|
const stripe = new Stripe(stripeConfig.secretKey, {
|
||||||
|
apiVersion: stripeConfig.apiVersion || DEFAULT_API_VERSION,
|
||||||
|
})
|
||||||
|
singleton.set(payload, stripe)
|
||||||
|
},
|
||||||
|
initPayment: async (payload, payment) => {
|
||||||
|
// Validate required fields
|
||||||
|
if (!payment.amount) {
|
||||||
|
throw new Error('Amount is required')
|
||||||
|
}
|
||||||
|
if (!payment.currency) {
|
||||||
|
throw new Error('Currency is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate amount
|
||||||
|
if (!isValidAmount(payment.amount)) {
|
||||||
|
throw new Error('Invalid amount: must be a positive integer within reasonable limits')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate currency code
|
||||||
|
if (!isValidCurrencyCode(payment.currency)) {
|
||||||
|
throw new Error('Invalid currency: must be a 3-letter ISO code')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate description length if provided
|
||||||
|
if (payment.description && payment.description.length > 1000) {
|
||||||
|
throw new Error('Description must be 1000 characters or less')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = singleton.get(payload)
|
||||||
|
|
||||||
|
// Create a payment intent
|
||||||
|
const paymentIntent = await stripe.paymentIntents.create({
|
||||||
|
amount: payment.amount, // Stripe handles currency conversion internally
|
||||||
|
currency: payment.currency.toLowerCase(),
|
||||||
|
description: payment.description || undefined,
|
||||||
|
metadata: {
|
||||||
|
payloadPaymentId: payment.id?.toString() || '',
|
||||||
|
...(typeof payment.metadata === 'object' &&
|
||||||
|
payment.metadata !== null &&
|
||||||
|
!Array.isArray(payment.metadata)
|
||||||
|
? payment.metadata
|
||||||
|
: {})
|
||||||
|
} as Stripe.MetadataParam,
|
||||||
|
automatic_payment_methods: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
payment.providerId = paymentIntent.id
|
||||||
|
const providerData: ProviderData<Stripe.PaymentIntent> = {
|
||||||
|
raw: { ...paymentIntent, client_secret: paymentIntent.client_secret },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider: 'stripe'
|
||||||
|
}
|
||||||
|
payment.providerData = providerData
|
||||||
|
|
||||||
|
return payment
|
||||||
|
},
|
||||||
|
} satisfies PaymentProvider
|
||||||
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import type { Payment } from '@/plugin/types/payments'
|
import type { Payment } from '@/plugin/types/payments'
|
||||||
import type { Payload } from 'payload'
|
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>>
|
||||||
|
|
||||||
export type PaymentProvider = {
|
export type PaymentProvider = {
|
||||||
key: string
|
key: string
|
||||||
onInit: (payload: Payload) => Promise<void> | void
|
onConfig?: (config: Config, pluginConfig: BillingPluginConfig) => void
|
||||||
|
onInit?: (payload: Payload) => Promise<void> | void
|
||||||
initPayment: InitPayment
|
initPayment: InitPayment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe provider data wrapper
|
||||||
|
*/
|
||||||
|
export type ProviderData<T = unknown> = {
|
||||||
|
raw: T
|
||||||
|
timestamp: string
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
|||||||
190
src/providers/utils.ts
Normal file
190
src/providers/utils.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import type { Payload } from 'payload'
|
||||||
|
import type { Payment } from '@/plugin/types/payments'
|
||||||
|
import type { BillingPluginConfig } from '@/plugin/config'
|
||||||
|
import { defaults } from '@/plugin/config'
|
||||||
|
import { extractSlug } from '@/plugin/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common webhook response utilities
|
||||||
|
* Note: Always return 200 for webhook acknowledgment to prevent information disclosure
|
||||||
|
*/
|
||||||
|
export const webhookResponses = {
|
||||||
|
success: () => Response.json({ received: true }, { status: 200 }),
|
||||||
|
error: (message: string, status = 400) => {
|
||||||
|
// Log error internally but don't expose details
|
||||||
|
console.error('[Webhook] Error:', message)
|
||||||
|
return Response.json({ error: 'Invalid request' }, { status })
|
||||||
|
},
|
||||||
|
missingBody: () => Response.json({ received: true }, { status: 200 }),
|
||||||
|
paymentNotFound: () => Response.json({ received: true }, { status: 200 }),
|
||||||
|
invalidPayload: () => Response.json({ received: true }, { status: 200 }),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a payment by provider ID
|
||||||
|
*/
|
||||||
|
export async function findPaymentByProviderId(
|
||||||
|
payload: Payload,
|
||||||
|
providerId: string,
|
||||||
|
pluginConfig: BillingPluginConfig
|
||||||
|
): Promise<Payment | null> {
|
||||||
|
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
|
||||||
|
|
||||||
|
const payments = await payload.find({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
where: {
|
||||||
|
providerId: {
|
||||||
|
equals: providerId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return payments.docs.length > 0 ? payments.docs[0] as Payment : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update payment status and provider data with proper optimistic locking
|
||||||
|
*/
|
||||||
|
export async function updatePaymentStatus(
|
||||||
|
payload: Payload,
|
||||||
|
paymentId: string | number,
|
||||||
|
status: Payment['status'],
|
||||||
|
providerData: any,
|
||||||
|
pluginConfig: BillingPluginConfig
|
||||||
|
): Promise<boolean> {
|
||||||
|
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
|
||||||
|
|
||||||
|
// Get current payment to check for concurrent modifications
|
||||||
|
const currentPayment = await payload.findByID({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
id: paymentId as any // Cast to avoid type mismatch between Id and PayloadCMS types
|
||||||
|
}) as Payment
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// First, try to find payments that match both ID and current updatedAt
|
||||||
|
const conflictCheck = await payload.find({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
where: {
|
||||||
|
id: { equals: paymentId },
|
||||||
|
updatedAt: { equals: currentPayment.updatedAt }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// If no matching payment found, it means it was modified concurrently
|
||||||
|
if (conflictCheck.docs.length === 0) {
|
||||||
|
console.warn(`[Payment Update] Concurrent modification detected for payment ${paymentId}, skipping update`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await payload.update({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
id: paymentId as any, // Cast to avoid type mismatch between Id and PayloadCMS types
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
providerData: {
|
||||||
|
...providerData,
|
||||||
|
webhookProcessedAt: now,
|
||||||
|
previousStatus: currentPayment.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify the update actually happened by checking if updatedAt changed
|
||||||
|
if (result.updatedAt === currentPayment.updatedAt) {
|
||||||
|
console.warn(`[Payment Update] Update may have failed for payment ${paymentId} - updatedAt unchanged`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update invoice status when payment succeeds
|
||||||
|
*/
|
||||||
|
export async function updateInvoiceOnPaymentSuccess(
|
||||||
|
payload: Payload,
|
||||||
|
payment: Payment,
|
||||||
|
pluginConfig: BillingPluginConfig
|
||||||
|
): Promise<void> {
|
||||||
|
if (!payment.invoice) return
|
||||||
|
|
||||||
|
const invoicesCollection = extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection)
|
||||||
|
const invoiceId = typeof payment.invoice === 'object'
|
||||||
|
? payment.invoice.id
|
||||||
|
: payment.invoice
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: invoicesCollection,
|
||||||
|
id: invoiceId as any, // Cast to avoid type mismatch between Id and PayloadCMS types
|
||||||
|
data: {
|
||||||
|
status: 'paid',
|
||||||
|
payment: payment.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle webhook errors with consistent logging
|
||||||
|
*/
|
||||||
|
export function handleWebhookError(
|
||||||
|
provider: string,
|
||||||
|
error: unknown,
|
||||||
|
context?: string
|
||||||
|
): Response {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
const fullContext = context ? `[${provider} Webhook - ${context}]` : `[${provider} Webhook]`
|
||||||
|
|
||||||
|
// Log detailed error internally for debugging
|
||||||
|
console.error(`${fullContext} Error:`, error)
|
||||||
|
|
||||||
|
// Return generic response to avoid information disclosure
|
||||||
|
return Response.json({
|
||||||
|
received: false,
|
||||||
|
error: 'Processing error'
|
||||||
|
}, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log webhook events
|
||||||
|
*/
|
||||||
|
export function logWebhookEvent(
|
||||||
|
provider: string,
|
||||||
|
event: string,
|
||||||
|
details?: any
|
||||||
|
): void {
|
||||||
|
console.log(`[${provider} Webhook] ${event}`, details ? JSON.stringify(details) : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate URL for production use
|
||||||
|
*/
|
||||||
|
export function validateProductionUrl(url: string | undefined, urlType: string): void {
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
if (!isProduction) return
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(`${urlType} URL is required for production`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('localhost') || url.includes('127.0.0.1')) {
|
||||||
|
throw new Error(`${urlType} URL cannot use localhost in production`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.startsWith('https://')) {
|
||||||
|
throw new Error(`${urlType} URL must use HTTPS in production`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
try {
|
||||||
|
new URL(url)
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${urlType} URL is not a valid URL`)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user