mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 02:43:24 +00:00
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:
@@ -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>[],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user