diff --git a/src/collections/payments.ts b/src/collections/payments.ts index c90e692..797e140 100644 --- a/src/collections/payments.ts +++ b/src/collections/payments.ts @@ -106,6 +106,15 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col hasMany: true, relationTo: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection) as CollectionSlug, }, + { + name: 'version', + type: 'number', + admin: { + hidden: true, // Hide from admin UI + }, + defaultValue: 1, + required: true, + }, ] if (overrides?.fields) { fields = overrides?.fields({defaultFields: fields}) @@ -129,6 +138,9 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col beforeChange: [ async ({ data, operation, req }) => { if (operation === 'create') { + // Initialize version for new payments + data.version = 1 + // Validate amount format if (data.amount && !Number.isInteger(data.amount)) { throw new Error('Amount must be an integer (in cents)') @@ -143,6 +155,15 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col } await initProviderPayment(req.payload, data) + } else if (operation === 'update') { + // Auto-increment version for updates (if not already set by optimistic locking) + if (!data.version) { + const currentDoc = await req.payload.findByID({ + collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection), + id: req.id as any + }) + data.version = (currentDoc.version || 1) + 1 + } } }, ] satisfies CollectionBeforeChangeHook[], diff --git a/src/plugin/types/payments.ts b/src/plugin/types/payments.ts index 8307679..94246e5 100644 --- a/src/plugin/types/payments.ts +++ b/src/plugin/types/payments.ts @@ -48,6 +48,7 @@ export interface Payment { | boolean | null; refunds?: (number | Refund)[] | null; + version: number; updatedAt: string; createdAt: string; } diff --git a/src/providers/utils.ts b/src/providers/utils.ts index d70e284..75715fd 100644 --- a/src/providers/utils.ts +++ b/src/providers/utils.ts @@ -43,46 +43,39 @@ export async function findPaymentByProviderId( } /** - * Update payment status and provider data with proper optimistic locking + * Update payment status and provider data with atomic optimistic locking */ export async function updatePaymentStatus( payload: Payload, paymentId: string | number, status: Payment['status'], - providerData: any, + providerData: ProviderData, pluginConfig: BillingPluginConfig ): Promise { const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection) - // Get current payment to check for concurrent modifications + // Get current payment to check version for atomic locking 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 - } + const nextVersion = (currentPayment.version || 1) + 1 try { - const result = await payload.update({ + // Use updateMany for atomic version-based optimistic locking + const result = await payload.updateMany({ collection: paymentsCollection, - id: paymentId as any, // Cast to avoid type mismatch between Id and PayloadCMS types + where: { + and: [ + { id: { equals: paymentId } }, + { version: { equals: currentPayment.version || 1 } } + ] + }, data: { status, + version: nextVersion, providerData: { ...providerData, webhookProcessedAt: now, @@ -91,13 +84,13 @@ export async function updatePaymentStatus( } }) - // 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`) + // Check if the update was successful (affected documents > 0) + if (result.docs && result.docs.length > 0) { + return true + } else { + console.warn(`[Payment Update] Optimistic lock failed for payment ${paymentId} - version mismatch (expected: ${currentPayment.version}, may have been updated by another process)`) return false } - - return true } catch (error) { console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error) return false