diff --git a/src/collections/payments.ts b/src/collections/payments.ts index 16d5dd3..4fa612f 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), }, + { + name: 'version', + type: 'number', + defaultValue: 1, + admin: { + hidden: true, // Hide from admin UI to prevent manual tampering + }, + index: true, // Index for optimistic locking performance + }, ] if (overrides?.fields) { fields = overrides?.fields({defaultFields: fields}) @@ -144,6 +153,22 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col await initProviderPayment(req.payload, data) } + + if (operation === 'update') { + // Auto-increment version for manual admin updates (webhooks handle their own versioning) + if (!data.version && req.id) { + try { + const currentDoc = await req.payload.findByID({ + collection: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection), + id: req.id as any + }) + data.version = (currentDoc.version || 1) + 1 + } catch (error) { + console.warn(`[Payment Hook] Could not fetch current version for payment ${req.id}, defaulting to version 1:`, error) + data.version = 1 + } + } + } }, ] satisfies CollectionBeforeChangeHook[], }, diff --git a/src/plugin/types/payments.ts b/src/plugin/types/payments.ts index 8307679..2d27739 100644 --- a/src/plugin/types/payments.ts +++ b/src/plugin/types/payments.ts @@ -48,6 +48,10 @@ export interface Payment { | boolean | null; refunds?: (number | Refund)[] | null; + /** + * Version number for optimistic locking (auto-incremented on updates) + */ + version?: number; updatedAt: string; createdAt: string; } diff --git a/src/providers/utils.ts b/src/providers/utils.ts index 89ff533..7c94b39 100644 --- a/src/providers/utils.ts +++ b/src/providers/utils.ts @@ -44,7 +44,7 @@ export async function findPaymentByProviderId( } /** - * Update payment status and provider data + * Update payment status and provider data with optimistic locking */ export async function updatePaymentStatus( payload: Payload, @@ -56,19 +56,42 @@ export async function updatePaymentStatus( const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection) try { - await payload.update({ + // First, fetch the current payment to get the current version + const currentPayment = await findPaymentByProviderId(payload, paymentId.toString(), pluginConfig) + if (!currentPayment) { + console.error(`[Payment Update] Payment not found: ${paymentId}`) + return false + } + + const currentVersion = currentPayment.version || 1 + const nextVersion = currentVersion + 1 + + // Atomic update using updateMany with version check + const result = await payload.updateMany({ collection: paymentsCollection, - id: toPayloadId(paymentId), + where: { + id: { equals: toPayloadId(currentPayment.id) }, + version: { equals: currentVersion } + }, data: { status, + version: nextVersion, providerData: { ...providerData, - webhookProcessedAt: new Date().toISOString() + webhookProcessedAt: new Date().toISOString(), + previousStatus: currentPayment.status } } }) - return true + // Success means exactly 1 document was updated (version matched) + const success = result.docs.length === 1 + + if (!success) { + console.warn(`[Payment Update] Optimistic lock failed for payment ${paymentId} - version conflict detected`) + } + + return success } catch (error) { console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error) return false