diff --git a/README.md b/README.md index 09d9687..1d43012 100644 --- a/README.md +++ b/README.md @@ -24,30 +24,46 @@ pnpm 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 ```typescript import { buildConfig } from 'payload' -import { billingPlugin } from '@xtr-dev/payload-billing' +import { billingPlugin, stripeProvider, mollieProvider } from '@xtr-dev/payload-billing' export default buildConfig({ // ... your config plugins: [ billingPlugin({ - providers: { - stripe: { + providers: [ + stripeProvider({ secretKey: process.env.STRIPE_SECRET_KEY!, - publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!, - webhookEndpointSecret: process.env.STRIPE_WEBHOOK_SECRET!, - }, - mollie: { + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, + }), + mollieProvider({ apiKey: process.env.MOLLIE_API_KEY!, - webhookUrl: process.env.MOLLIE_WEBHOOK_URL!, - }, - test: { - enabled: process.env.NODE_ENV === 'development', - autoComplete: true, - } + webhookUrl: process.env.MOLLIE_WEBHOOK_URL, + }), + ], + collections: { + payments: 'payments', + invoices: 'invoices', + refunds: 'refunds', } }) ] @@ -60,11 +76,11 @@ export default buildConfig({ // Main plugin import { billingPlugin } from '@xtr-dev/payload-billing' -// Provider utilities -import { getPaymentProvider } from '@xtr-dev/payload-billing' +// Payment providers +import { stripeProvider, mollieProvider } from '@xtr-dev/payload-billing' // Types -import type { PaymentProvider, CreatePaymentOptions, Payment } from '@xtr-dev/payload-billing' +import type { PaymentProvider, Payment, Invoice, Refund } from '@xtr-dev/payload-billing' ``` ## Provider Types @@ -83,16 +99,14 @@ Local development testing with configurable scenarios, automatic completion, deb The plugin adds these collections: - **payments** - Payment transactions with status and provider data -- **customers** - Customer profiles with billing information - **invoices** - Invoice generation with line items and PDF support - **refunds** - Refund tracking and management ## Webhook Endpoints -Automatic webhook endpoints are created: -- `/api/billing/webhooks/stripe` -- `/api/billing/webhooks/mollie` -- `/api/billing/webhooks/test` +Automatic webhook endpoints are created for configured providers: +- `/api/payload-billing/stripe/webhook` - Stripe payment notifications +- `/api/payload-billing/mollie/webhook` - Mollie payment notifications ## Requirements diff --git a/package.json b/package.json index bd691ce..4302f3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "license": "MIT", "type": "module", @@ -100,16 +100,17 @@ "rimraf": "3.0.2", "sharp": "0.34.2", "sort-package-json": "^2.10.0", + "stripe": "^18.5.0", "typescript": "5.7.3", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.1.2" }, "peerDependencies": { "@mollie/api-client": "^3.7.0", - "payload": "^3.37.0" + "payload": "^3.37.0", + "stripe": "^18.5.0" }, "dependencies": { - "stripe": "^14.15.0", "zod": "^3.22.4" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2677bf..971afd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - stripe: - specifier: ^14.15.0 - version: 14.25.0 zod: specifier: ^3.22.4 version: 3.25.76 @@ -111,6 +108,9 @@ importers: sort-package-json: specifier: ^2.10.0 version: 2.15.1 + stripe: + specifier: ^18.5.0 + version: 18.5.0(@types/node@22.18.1) typescript: specifier: 5.7.3 version: 5.7.3 @@ -5165,9 +5165,14 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} - stripe@14.25.0: - resolution: {integrity: sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==} + stripe@18.5.0: + resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==} engines: {node: '>=12.*'} + peerDependencies: + '@types/node': '>=12.x.x' + peerDependenciesMeta: + '@types/node': + optional: true strtok3@10.3.4: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} @@ -11434,10 +11439,11 @@ snapshots: dependencies: js-tokens: 9.0.1 - stripe@14.25.0: + stripe@18.5.0(@types/node@22.18.1): dependencies: - '@types/node': 22.18.1 qs: 6.14.0 + optionalDependencies: + '@types/node': 22.18.1 strtok3@10.3.4: dependencies: diff --git a/src/collections/payments.ts b/src/collections/payments.ts index b5d2665..c90e692 100644 --- a/src/collections/payments.ts +++ b/src/collections/payments.ts @@ -29,6 +29,7 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col }, label: 'Provider Payment ID', unique: true, + index: true, // Ensure this field is indexed for webhook lookups }, { name: 'status', diff --git a/src/plugin/config.ts b/src/plugin/config.ts index d2385d8..73861c1 100644 --- a/src/plugin/config.ts +++ b/src/plugin/config.ts @@ -10,18 +10,6 @@ export const defaults = { } // 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 { autoComplete?: boolean @@ -65,13 +53,5 @@ export interface BillingPluginConfig { customerRelationSlug?: string // Customer collection slug for relationship disabled?: boolean providers?: PaymentProvider[] - webhooks?: { - basePath?: string - cors?: boolean - } } -// Plugin type -export interface BillingPluginOptions extends BillingPluginConfig { - disabled?: boolean -} diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 3bac540..d1421eb 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -25,7 +25,11 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config createPaymentsCollection(pluginConfig), createInvoicesCollection(pluginConfig), createRefundsCollection(pluginConfig), - ] + ]; + + (pluginConfig.providers || []) + .filter(provider => provider.onConfig) + .forEach(provider => provider.onConfig!(config, pluginConfig)) const incomingOnInit = config.onInit config.onInit = async (payload) => { @@ -35,15 +39,16 @@ export const billingPlugin = (pluginConfig: BillingPluginConfig = {}) => (config singleton.set(payload, { config: pluginConfig, providerConfig: (pluginConfig.providers || []).reduce( - (acc, val) => { - acc[val.key] = val - return acc + (record, provider) => { + record[provider.key] = provider + return record }, {} as Record ) } satisfies BillingPlugin) - console.log('Billing plugin initialized', singleton.get(payload)) - await Promise.all((pluginConfig.providers || []).map(p => p.onInit(payload))) + await Promise.all((pluginConfig.providers || []) + .filter(provider => provider.onInit) + .map(provider => provider.onInit!(payload))) } return config diff --git a/src/providers/currency.ts b/src/providers/currency.ts new file mode 100644 index 0000000..03078b0 --- /dev/null +++ b/src/providers/currency.ts @@ -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 +} \ No newline at end of file diff --git a/src/providers/index.ts b/src/providers/index.ts index e18301a..6e1e23c 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,2 +1,4 @@ export * from './mollie' +export * from './stripe' export * from './types' +export * from './currency' diff --git a/src/providers/mollie.ts b/src/providers/mollie.ts index 5ef7794..b8d5440 100644 --- a/src/providers/mollie.ts +++ b/src/providers/mollie.ts @@ -1,36 +1,155 @@ 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 { createSingleton } from '@/plugin/singleton' 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') export type MollieProviderConfig = Parameters[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 = { + '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(symbol) return { 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) => { const createMollieClient = (await import('@mollie/api-client')).default - const mollieClient = createMollieClient(config) + const mollieClient = createMollieClient(mollieConfig) singleton.set(payload, mollieClient) }, 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') + } + + // 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({ amount: { - value: (payment.amount / 100).toFixed(2), - currency: payment.currency + value: formatAmountForProvider(payment.amount, payment.currency), + currency: payment.currency.toUpperCase() }, description: payment.description || '', - redirectUrl: 'https://localhost:3000/payment/success', - webhookUrl: 'https://localhost:3000', + redirectUrl, + webhookUrl, }); payment.providerId = molliePayment.id payment.providerData = molliePayment.toPlainObject() diff --git a/src/providers/stripe.ts b/src/providers/stripe.ts new file mode 100644 index 0000000..8738dfd --- /dev/null +++ b/src/providers/stripe.ts @@ -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(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 = { + 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 = { + 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 = { + raw: { ...paymentIntent, client_secret: paymentIntent.client_secret }, + timestamp: new Date().toISOString(), + provider: 'stripe' + } + payment.providerData = providerData + + return payment + }, + } satisfies PaymentProvider +} diff --git a/src/providers/types.ts b/src/providers/types.ts index d15aa84..14f4d9b 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -1,10 +1,21 @@ 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) => Promise> export type PaymentProvider = { key: string - onInit: (payload: Payload) => Promise | void + onConfig?: (config: Config, pluginConfig: BillingPluginConfig) => void + onInit?: (payload: Payload) => Promise | void initPayment: InitPayment } + +/** + * Type-safe provider data wrapper + */ +export type ProviderData = { + raw: T + timestamp: string + provider: string +} diff --git a/src/providers/utils.ts b/src/providers/utils.ts new file mode 100644 index 0000000..d70e284 --- /dev/null +++ b/src/providers/utils.ts @@ -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 { + 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 { + 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 { + 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`) + } +} \ No newline at end of file