mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 10:53:23 +00:00
Compare commits
7 Commits
79de7910d4
...
v0.1.28
| Author | SHA1 | Date | |
|---|---|---|---|
| f2ab50214b | |||
| 20030b435c | |||
| 1eb9d282b3 | |||
| 291ce255b4 | |||
| 2904d30a5c | |||
| bf6f546371 | |||
| 7e4ec86e00 |
59
README.md
59
README.md
@@ -175,13 +175,15 @@ mollieProvider({
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
MOLLIE_API_KEY=test_...
|
MOLLIE_API_KEY=test_...
|
||||||
MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook
|
MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook # Optional if server URL is set
|
||||||
PAYLOAD_PUBLIC_SERVER_URL=https://yourdomain.com
|
NEXT_PUBLIC_SERVER_URL=https://yourdomain.com # Or PAYLOAD_PUBLIC_SERVER_URL or SERVER_URL
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
- Mollie requires HTTPS URLs in production (no localhost)
|
- Mollie requires HTTPS URLs in production (no localhost)
|
||||||
- Webhook URL defaults to `{PAYLOAD_PUBLIC_SERVER_URL}/api/payload-billing/mollie/webhook`
|
- Webhook URL is auto-generated from server URL environment variables (checked in order: `NEXT_PUBLIC_SERVER_URL`, `PAYLOAD_PUBLIC_SERVER_URL`, `SERVER_URL`)
|
||||||
|
- Falls back to `https://localhost:3000` only in non-production environments
|
||||||
|
- In production, throws an error if no valid URL can be determined
|
||||||
- Amounts are formatted as decimal strings (e.g., "50.00")
|
- Amounts are formatted as decimal strings (e.g., "50.00")
|
||||||
|
|
||||||
### Test Provider
|
### Test Provider
|
||||||
@@ -408,6 +410,7 @@ Tracks payment transactions with provider integration.
|
|||||||
currency: string // ISO 4217 currency code
|
currency: string // ISO 4217 currency code
|
||||||
description?: string
|
description?: string
|
||||||
checkoutUrl?: string // Checkout URL (if applicable)
|
checkoutUrl?: string // Checkout URL (if applicable)
|
||||||
|
redirectUrl?: string // URL to redirect user after payment
|
||||||
invoice?: Invoice | string // Linked invoice
|
invoice?: Invoice | string // Linked invoice
|
||||||
metadata?: Record<string, any> // Custom metadata
|
metadata?: Record<string, any> // Custom metadata
|
||||||
providerData?: ProviderData // Raw provider response (read-only)
|
providerData?: ProviderData // Raw provider response (read-only)
|
||||||
@@ -434,6 +437,36 @@ succeeded → partially_refunded → refunded
|
|||||||
- Provider's `initPayment()` called on creation
|
- Provider's `initPayment()` called on creation
|
||||||
- Linked invoice updated when status becomes `succeeded`
|
- Linked invoice updated when status becomes `succeeded`
|
||||||
|
|
||||||
|
**Per-Payment Redirect URLs:**
|
||||||
|
|
||||||
|
The `redirectUrl` field allows customizing where users are redirected after payment completion on a per-payment basis. This is useful when different payments need different destinations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Invoice payment redirects to invoice confirmation
|
||||||
|
await payload.create({
|
||||||
|
collection: 'payments',
|
||||||
|
data: {
|
||||||
|
provider: 'mollie',
|
||||||
|
amount: 5000,
|
||||||
|
currency: 'EUR',
|
||||||
|
redirectUrl: 'https://example.com/invoices/123/thank-you'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscription payment redirects to subscription page
|
||||||
|
await payload.create({
|
||||||
|
collection: 'payments',
|
||||||
|
data: {
|
||||||
|
provider: 'mollie',
|
||||||
|
amount: 1999,
|
||||||
|
currency: 'EUR',
|
||||||
|
redirectUrl: 'https://example.com/subscription/confirmed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priority:** `payment.redirectUrl` > provider config `redirectUrl`/`returnUrl` > default fallback
|
||||||
|
|
||||||
### Invoices
|
### Invoices
|
||||||
|
|
||||||
Generate and manage invoices with line items and customer information.
|
Generate and manage invoices with line items and customer information.
|
||||||
@@ -864,10 +897,15 @@ const campaignPayments = await payload.find({
|
|||||||
|
|
||||||
### Mollie Webhook Configuration
|
### Mollie Webhook Configuration
|
||||||
|
|
||||||
1. **Set webhook URL:**
|
1. **Set server URL** (webhook URL is auto-generated):
|
||||||
```bash
|
```bash
|
||||||
MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook
|
# Any of these work (checked in this order):
|
||||||
|
NEXT_PUBLIC_SERVER_URL=https://yourdomain.com
|
||||||
PAYLOAD_PUBLIC_SERVER_URL=https://yourdomain.com
|
PAYLOAD_PUBLIC_SERVER_URL=https://yourdomain.com
|
||||||
|
SERVER_URL=https://yourdomain.com
|
||||||
|
|
||||||
|
# Or set explicit webhook URL:
|
||||||
|
MOLLIE_WEBHOOK_URL=https://yourdomain.com/api/payload-billing/mollie/webhook
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Mollie automatically calls webhook** for payment status updates
|
2. **Mollie automatically calls webhook** for payment status updates
|
||||||
@@ -875,12 +913,13 @@ const campaignPayments = await payload.find({
|
|||||||
3. **Test locally with ngrok:**
|
3. **Test locally with ngrok:**
|
||||||
```bash
|
```bash
|
||||||
ngrok http 3000
|
ngrok http 3000
|
||||||
# Use ngrok URL as PAYLOAD_PUBLIC_SERVER_URL
|
# Use ngrok URL as NEXT_PUBLIC_SERVER_URL
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important:**
|
**Important:**
|
||||||
- Mollie requires HTTPS URLs (no `http://` or `localhost` in production)
|
- Mollie requires HTTPS URLs (no `http://` or `localhost` in production)
|
||||||
- Webhook URL defaults to `{PAYLOAD_PUBLIC_SERVER_URL}/api/payload-billing/mollie/webhook`
|
- Webhook URL auto-generated from `NEXT_PUBLIC_SERVER_URL`, `PAYLOAD_PUBLIC_SERVER_URL`, or `SERVER_URL`
|
||||||
|
- In production, throws an error if no valid server URL is configured
|
||||||
- Mollie validates webhooks by verifying payment ID exists
|
- Mollie validates webhooks by verifying payment ID exists
|
||||||
|
|
||||||
### Webhook Security
|
### Webhook Security
|
||||||
@@ -1096,8 +1135,10 @@ echo $STRIPE_WEBHOOK_SECRET
|
|||||||
|
|
||||||
**Mollie:**
|
**Mollie:**
|
||||||
```bash
|
```bash
|
||||||
# Verify PAYLOAD_PUBLIC_SERVER_URL is set
|
# Verify server URL is set (any of these work):
|
||||||
|
echo $NEXT_PUBLIC_SERVER_URL
|
||||||
echo $PAYLOAD_PUBLIC_SERVER_URL
|
echo $PAYLOAD_PUBLIC_SERVER_URL
|
||||||
|
echo $SERVER_URL
|
||||||
|
|
||||||
# Check webhook URL is accessible (must be HTTPS in production)
|
# Check webhook URL is accessible (must be HTTPS in production)
|
||||||
curl -X POST https://yourdomain.com/api/payload-billing/mollie/webhook \
|
curl -X POST https://yourdomain.com/api/payload-billing/mollie/webhook \
|
||||||
@@ -1134,7 +1175,7 @@ curl -X POST https://yourdomain.com/api/payload-billing/mollie/webhook \
|
|||||||
- Use ngrok or deploy to staging for local testing:
|
- Use ngrok or deploy to staging for local testing:
|
||||||
```bash
|
```bash
|
||||||
ngrok http 3000
|
ngrok http 3000
|
||||||
# Set PAYLOAD_PUBLIC_SERVER_URL to ngrok URL
|
# Set NEXT_PUBLIC_SERVER_URL to ngrok URL
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test Provider Payment Not Processing
|
### Test Provider Payment Not Processing
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-billing",
|
"name": "@xtr-dev/payload-billing",
|
||||||
"version": "0.1.21",
|
"version": "0.1.28",
|
||||||
"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",
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'redirectUrl',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'URL to redirect user after payment completion',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'invoice',
|
name: 'invoice',
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export interface Payment {
|
|||||||
* Checkout URL where user can complete payment (if applicable)
|
* Checkout URL where user can complete payment (if applicable)
|
||||||
*/
|
*/
|
||||||
checkoutUrl?: string | null;
|
checkoutUrl?: string | null;
|
||||||
|
/**
|
||||||
|
* URL to redirect user after payment completion
|
||||||
|
*/
|
||||||
|
redirectUrl?: string | null;
|
||||||
invoice?: (Id | null) | Invoice;
|
invoice?: (Id | null) | Invoice;
|
||||||
/**
|
/**
|
||||||
* Additional metadata for the payment
|
* Additional metadata for the payment
|
||||||
|
|||||||
@@ -85,11 +85,15 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
|||||||
const status = mapMollieStatusToPaymentStatus(molliePayment.status)
|
const status = mapMollieStatusToPaymentStatus(molliePayment.status)
|
||||||
|
|
||||||
// Update the payment status and provider data
|
// Update the payment status and provider data
|
||||||
|
// Use toPlainObject if available, otherwise spread the object
|
||||||
|
const providerData = typeof molliePayment.toPlainObject === 'function'
|
||||||
|
? molliePayment.toPlainObject()
|
||||||
|
: { ...molliePayment }
|
||||||
const updateSuccess = await updatePaymentStatus(
|
const updateSuccess = await updatePaymentStatus(
|
||||||
payload,
|
payload,
|
||||||
payment.id,
|
payment.id,
|
||||||
status,
|
status,
|
||||||
molliePayment.toPlainObject(),
|
providerData,
|
||||||
pluginConfig
|
pluginConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,11 +138,24 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Setup URLs with development defaults
|
// Setup URLs with development defaults
|
||||||
|
// Only use localhost fallbacks in non-production environments
|
||||||
const isProduction = process.env.NODE_ENV === 'production'
|
const isProduction = process.env.NODE_ENV === 'production'
|
||||||
const redirectUrl = mollieConfig.redirectUrl ||
|
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || process.env.SERVER_URL
|
||||||
(!isProduction ? 'https://localhost:3000/payment/success' : undefined)
|
|
||||||
const webhookUrl = mollieConfig.webhookUrl ||
|
// Priority: payment.redirectUrl > config.redirectUrl > dev fallback
|
||||||
`${process.env.PAYLOAD_PUBLIC_SERVER_URL || (!isProduction ? 'https://localhost:3000' : '')}/api/payload-billing/mollie/webhook`
|
let redirectUrl = payment.redirectUrl || mollieConfig.redirectUrl
|
||||||
|
if (!redirectUrl && !isProduction) {
|
||||||
|
redirectUrl = 'https://localhost:3000/payment/success'
|
||||||
|
}
|
||||||
|
|
||||||
|
let webhookUrl = mollieConfig.webhookUrl
|
||||||
|
if (!webhookUrl) {
|
||||||
|
if (serverUrl) {
|
||||||
|
webhookUrl = `${serverUrl}/api/payload-billing/mollie/webhook`
|
||||||
|
} else if (!isProduction) {
|
||||||
|
webhookUrl = 'https://localhost:3000/api/payload-billing/mollie/webhook'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate URLs for production
|
// Validate URLs for production
|
||||||
validateProductionUrl(redirectUrl, 'Redirect')
|
validateProductionUrl(redirectUrl, 'Redirect')
|
||||||
@@ -154,7 +171,10 @@ export const mollieProvider = (mollieConfig: MollieProviderConfig & {
|
|||||||
webhookUrl,
|
webhookUrl,
|
||||||
});
|
});
|
||||||
payment.providerId = molliePayment.id
|
payment.providerId = molliePayment.id
|
||||||
payment.providerData = molliePayment.toPlainObject()
|
// Use toPlainObject if available, otherwise spread the object (for compatibility with different Mollie client versions)
|
||||||
|
payment.providerData = typeof molliePayment.toPlainObject === 'function'
|
||||||
|
? molliePayment.toPlainObject()
|
||||||
|
: { ...molliePayment }
|
||||||
payment.checkoutUrl = molliePayment._links?.checkout?.href || null
|
payment.checkoutUrl = molliePayment._links?.checkout?.href || null
|
||||||
return payment
|
return payment
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -234,6 +234,9 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
|
|
||||||
const stripe = singleton.get(payload)
|
const stripe = singleton.get(payload)
|
||||||
|
|
||||||
|
// Priority: payment.redirectUrl > config.returnUrl
|
||||||
|
const returnUrl = payment.redirectUrl || stripeConfig.returnUrl
|
||||||
|
|
||||||
// Create a payment intent
|
// Create a payment intent
|
||||||
const paymentIntent = await stripe.paymentIntents.create({
|
const paymentIntent = await stripe.paymentIntents.create({
|
||||||
amount: payment.amount, // Stripe handles currency conversion internally
|
amount: payment.amount, // Stripe handles currency conversion internally
|
||||||
@@ -250,6 +253,7 @@ export const stripeProvider = (stripeConfig: StripeProviderConfig) => {
|
|||||||
automatic_payment_methods: {
|
automatic_payment_methods: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
...(returnUrl && { return_url: returnUrl }),
|
||||||
})
|
})
|
||||||
|
|
||||||
payment.providerId = paymentIntent.id
|
payment.providerId = paymentIntent.id
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Payment } from '../plugin/types/payments'
|
import type { Payment } from '../plugin/types/payments'
|
||||||
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
|
import type { PaymentProvider, ProviderData } from '../plugin/types/index'
|
||||||
import type { BillingPluginConfig } from '../plugin/config'
|
import type { BillingPluginConfig } from '../plugin/config'
|
||||||
import type { Payload } from 'payload'
|
import type { CollectionSlug, 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'
|
import { createContextLogger } from '../utils/logger'
|
||||||
@@ -160,6 +160,7 @@ export interface TestPaymentSession {
|
|||||||
method?: PaymentMethod
|
method?: PaymentMethod
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
status: PaymentOutcome
|
status: PaymentOutcome
|
||||||
|
redirectUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the proper BillingPluginConfig type
|
// Use the proper BillingPluginConfig type
|
||||||
@@ -228,7 +229,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || process.env.SERVER_URL || 'http://localhost:3000'
|
||||||
const uiRoute = testConfig.customUiRoute || '/test-payment'
|
const uiRoute = testConfig.customUiRoute || '/test-payment'
|
||||||
|
|
||||||
// Test mode warnings will be logged in onInit when payload is available
|
// Test mode warnings will be logged in onInit when payload is available
|
||||||
@@ -277,7 +278,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
|
|||||||
const paymentsConfig = pluginConfig.collections?.payments
|
const paymentsConfig = pluginConfig.collections?.payments
|
||||||
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
|
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
|
||||||
const result = await req.payload.find({
|
const result = await req.payload.find({
|
||||||
collection: paymentSlug,
|
collection: paymentSlug as CollectionSlug,
|
||||||
where: {
|
where: {
|
||||||
providerId: {
|
providerId: {
|
||||||
equals: paymentId
|
equals: paymentId
|
||||||
@@ -310,8 +311,11 @@ export const testProvider = (testConfig: TestProviderConfig) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine redirect URL: session.redirectUrl > payment.redirectUrl > default
|
||||||
|
const redirectUrl = session.redirectUrl || (session.payment as Payment)?.redirectUrl || `${baseUrl}/payment/success`
|
||||||
|
|
||||||
// Generate test payment UI
|
// Generate test payment UI
|
||||||
const html = generateTestPaymentUI(session, scenarios, uiRoute, baseUrl, testConfig)
|
const html = generateTestPaymentUI(session, scenarios, uiRoute, baseUrl, testConfig, redirectUrl)
|
||||||
return new Response(html, {
|
return new Response(html, {
|
||||||
headers: { 'Content-Type': 'text/html' }
|
headers: { 'Content-Type': 'text/html' }
|
||||||
})
|
})
|
||||||
@@ -370,7 +374,7 @@ export const testProvider = (testConfig: TestProviderConfig) => {
|
|||||||
const paymentsConfig = pluginConfig.collections?.payments
|
const paymentsConfig = pluginConfig.collections?.payments
|
||||||
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
|
const paymentSlug = typeof paymentsConfig === 'string' ? paymentsConfig : (paymentsConfig?.slug || 'payments')
|
||||||
const result = await req.payload.find({
|
const result = await req.payload.find({
|
||||||
collection: paymentSlug,
|
collection: paymentSlug as CollectionSlug,
|
||||||
where: {
|
where: {
|
||||||
providerId: {
|
providerId: {
|
||||||
equals: paymentId
|
equals: paymentId
|
||||||
@@ -420,7 +424,8 @@ export const testProvider = (testConfig: TestProviderConfig) => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
processTestPayment(payload, session, pluginConfig).catch(async (error) => {
|
processTestPayment(payload, session, pluginConfig).catch(async (error) => {
|
||||||
const logger = createContextLogger(payload, 'Test Provider')
|
const logger = createContextLogger(payload, 'Test Provider')
|
||||||
logger.error('Failed to process payment:', error)
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
logger.error(`Failed to process payment: ${errorMessage}`)
|
||||||
|
|
||||||
// Ensure session status is updated consistently
|
// Ensure session status is updated consistently
|
||||||
session.status = 'failed'
|
session.status = 'failed'
|
||||||
@@ -592,12 +597,13 @@ export const testProvider = (testConfig: TestProviderConfig) => {
|
|||||||
// Generate unique test payment ID
|
// Generate unique test payment ID
|
||||||
const testPaymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
const testPaymentId = `test_pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
|
||||||
// Create test payment session
|
// Create test payment session with redirect URL
|
||||||
const session = {
|
const session: TestPaymentSession = {
|
||||||
id: testPaymentId,
|
id: testPaymentId,
|
||||||
payment: { ...payment },
|
payment: { ...payment },
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
status: 'pending' as PaymentOutcome
|
status: 'pending' as PaymentOutcome,
|
||||||
|
redirectUrl: payment.redirectUrl || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
testPaymentSessions.set(testPaymentId, session)
|
testPaymentSessions.set(testPaymentId, session)
|
||||||
@@ -716,7 +722,8 @@ function generateTestPaymentUI(
|
|||||||
scenarios: PaymentScenario[],
|
scenarios: PaymentScenario[],
|
||||||
uiRoute: string,
|
uiRoute: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
testConfig: TestProviderConfig
|
testConfig: TestProviderConfig,
|
||||||
|
redirectUrl: string
|
||||||
): string {
|
): string {
|
||||||
const payment = session.payment
|
const payment = session.payment
|
||||||
const testModeIndicators = testConfig.testModeIndicators || {}
|
const testModeIndicators = testConfig.testModeIndicators || {}
|
||||||
@@ -1030,9 +1037,9 @@ function generateTestPaymentUI(
|
|||||||
|
|
||||||
if (result.status === 'paid') {
|
if (result.status === 'paid') {
|
||||||
status.className = 'status success';
|
status.className = 'status success';
|
||||||
status.textContent = '✅ Payment successful!';
|
status.textContent = '✅ Payment successful! Redirecting...';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '${baseUrl}/success';
|
window.location.href = '${redirectUrl}';
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} else if (result.status === 'failed' || result.status === 'cancelled' || result.status === 'expired') {
|
} else if (result.status === 'failed' || result.status === 'cancelled' || result.status === 'expired') {
|
||||||
status.className = 'status error';
|
status.className = 'status error';
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const webhookResponses = {
|
|||||||
// Log error internally but don't expose details
|
// Log error internally but don't expose details
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const logger = createContextLogger(payload, 'Webhook')
|
const logger = createContextLogger(payload, 'Webhook')
|
||||||
logger.error('Error:', message)
|
logger.error(`Error: ${message}`)
|
||||||
} else {
|
} else {
|
||||||
console.error('[Webhook] Error:', message)
|
console.error('[Webhook] Error:', message)
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,7 @@ export async function updatePaymentStatus(
|
|||||||
pluginConfig: BillingPluginConfig
|
pluginConfig: BillingPluginConfig
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
const paymentsCollection = extractSlug(pluginConfig.collections?.payments, defaults.paymentsCollection)
|
||||||
|
const logger = createContextLogger(payload, 'Payment Update')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, fetch the current payment to get the current version
|
// First, fetch the current payment to get the current version
|
||||||
@@ -69,23 +70,23 @@ export async function updatePaymentStatus(
|
|||||||
}) as Payment
|
}) as Payment
|
||||||
|
|
||||||
if (!currentPayment) {
|
if (!currentPayment) {
|
||||||
const logger = createContextLogger(payload, 'Payment Update')
|
|
||||||
logger.error(`Payment ${paymentId} not found`)
|
logger.error(`Payment ${paymentId} not found`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVersion = currentPayment.version || 1
|
const currentVersion = currentPayment.version || 1
|
||||||
|
|
||||||
// Attempt to update with optimistic locking
|
// Try to use transactions if supported by the database adapter
|
||||||
// We'll use a transaction to ensure atomicity
|
let transactionID: string | number | null = null
|
||||||
const transactionID = await payload.db.beginTransaction()
|
try {
|
||||||
|
transactionID = await payload.db.beginTransaction()
|
||||||
if (!transactionID) {
|
} catch (error) {
|
||||||
const logger = createContextLogger(payload, 'Payment Update')
|
// Transaction support may not be available in all database adapters
|
||||||
logger.error('Failed to begin transaction')
|
logger.debug('Transactions not supported, falling back to direct update')
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transactionID) {
|
||||||
|
// Use transactional update with optimistic locking
|
||||||
try {
|
try {
|
||||||
// Re-fetch within transaction to ensure consistency
|
// Re-fetch within transaction to ensure consistency
|
||||||
const paymentInTransaction = await payload.findByID({
|
const paymentInTransaction = await payload.findByID({
|
||||||
@@ -97,7 +98,6 @@ 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
|
||||||
const logger = createContextLogger(payload, 'Payment Update')
|
|
||||||
logger.warn(`Version conflict for payment ${paymentId} (expected version: ${currentVersion}, got: ${paymentInTransaction.version})`)
|
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
|
||||||
@@ -124,9 +124,33 @@ export async function updatePaymentStatus(
|
|||||||
await payload.db.rollbackTransaction(transactionID)
|
await payload.db.rollbackTransaction(transactionID)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: Direct update without transaction support
|
||||||
|
// This is less safe but allows payment updates on databases without transaction support
|
||||||
|
logger.debug('Using direct update without transaction')
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: paymentsCollection,
|
||||||
|
id: toPayloadId(paymentId),
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
providerData: {
|
||||||
|
...providerData,
|
||||||
|
webhookProcessedAt: new Date().toISOString()
|
||||||
|
},
|
||||||
|
version: currentVersion + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const logger = createContextLogger(payload, 'Payment Update')
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
logger.error(`Failed to update payment ${paymentId}:`, error)
|
const errorStack = error instanceof Error ? error.stack : undefined
|
||||||
|
logger.error(`Failed to update payment ${paymentId}: ${errorMessage}`)
|
||||||
|
if (errorStack) {
|
||||||
|
logger.error(`Stack trace: ${errorStack}`)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,15 +189,22 @@ export function handleWebhookError(
|
|||||||
context?: string,
|
context?: string,
|
||||||
payload?: Payload
|
payload?: Payload
|
||||||
): Response {
|
): Response {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
const stack = error instanceof Error ? error.stack : undefined
|
||||||
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
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const logger = createContextLogger(payload, fullContext)
|
const logger = createContextLogger(payload, fullContext)
|
||||||
logger.error('Error:', error)
|
logger.error(`Error: ${message}`)
|
||||||
|
if (stack) {
|
||||||
|
logger.error(`Stack trace: ${stack}`)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(`[${fullContext}] Error:`, error)
|
console.error(`[${fullContext}] Error: ${message}`)
|
||||||
|
if (stack) {
|
||||||
|
console.error(`[${fullContext}] Stack trace:`, stack)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return generic response to avoid information disclosure
|
// Return generic response to avoid information disclosure
|
||||||
|
|||||||
Reference in New Issue
Block a user