feat: implement optimistic locking for payment updates

- Add version field to Payment interface and collection schema
- Implement atomic updates using updateMany with version checks
- Add collection hook to auto-increment version for manual admin updates
- Prevent race conditions in concurrent webhook processing
- Index version field for performance

Co-authored-by: Bas <bvdaakster@users.noreply.github.com>
This commit is contained in:
claude[bot]
2025-09-18 17:15:10 +00:00
parent a25111444a
commit 84099196b1
3 changed files with 57 additions and 5 deletions

View File

@@ -106,6 +106,15 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
hasMany: true, hasMany: true,
relationTo: extractSlug(pluginConfig.collections?.refunds || defaults.refundsCollection), 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) { if (overrides?.fields) {
fields = overrides?.fields({defaultFields: fields}) fields = overrides?.fields({defaultFields: fields})
@@ -144,6 +153,22 @@ export function createPaymentsCollection(pluginConfig: BillingPluginConfig): Col
await initProviderPayment(req.payload, data) 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<Payment>[], ] satisfies CollectionBeforeChangeHook<Payment>[],
}, },

View File

@@ -48,6 +48,10 @@ export interface Payment {
| boolean | boolean
| null; | null;
refunds?: (number | Refund)[] | null; refunds?: (number | Refund)[] | null;
/**
* Version number for optimistic locking (auto-incremented on updates)
*/
version?: number;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }

View File

@@ -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( export async function updatePaymentStatus(
payload: Payload, payload: Payload,
@@ -56,19 +56,42 @@ export async function updatePaymentStatus(
const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection) const paymentsCollection = extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection)
try { 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, collection: paymentsCollection,
id: toPayloadId(paymentId), where: {
id: { equals: toPayloadId(currentPayment.id) },
version: { equals: currentVersion }
},
data: { data: {
status, status,
version: nextVersion,
providerData: { providerData: {
...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) { } catch (error) {
console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error) console.error(`[Payment Update] Failed to update payment ${paymentId}:`, error)
return false return false