mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 02:43:24 +00:00
- Add database index on version field for optimistic locking performance - Implement explicit webhook context tracking with symbols to avoid conflicts - Replace fragile webhook detection logic with robust context-based approach - Add request metadata support for enhanced debugging and audit trails - Simplify version management in payment collection hooks - Fix TypeScript compilation errors and improve type safety 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
178 lines
5.4 KiB
TypeScript
178 lines
5.4 KiB
TypeScript
import type { AccessArgs, CollectionBeforeChangeHook, CollectionConfig, CollectionSlug, Field } from 'payload'
|
|
import type { BillingPluginConfig} from '@/plugin/config';
|
|
import { defaults } from '@/plugin/config'
|
|
import { extractSlug, toPayloadId } from '@/plugin/utils'
|
|
import { isWebhookRequest } from '@/providers/context'
|
|
import { Payment } from '@/plugin/types/payments'
|
|
import { initProviderPayment } from '@/collections/hooks'
|
|
|
|
export function createPaymentsCollection(pluginConfig: BillingPluginConfig): CollectionConfig {
|
|
const overrides = typeof pluginConfig.collections?.payments === 'object' ? pluginConfig.collections?.payments : {}
|
|
let fields: Field[] = [
|
|
{
|
|
name: 'provider',
|
|
type: 'select',
|
|
admin: {
|
|
position: 'sidebar',
|
|
},
|
|
options: [
|
|
{ label: 'Stripe', value: 'stripe' },
|
|
{ label: 'Mollie', value: 'mollie' },
|
|
{ label: 'Test', value: 'test' },
|
|
],
|
|
required: true,
|
|
},
|
|
{
|
|
name: 'providerId',
|
|
type: 'text',
|
|
admin: {
|
|
description: 'The payment ID from the payment provider',
|
|
},
|
|
label: 'Provider Payment ID',
|
|
unique: true,
|
|
index: true, // Ensure this field is indexed for webhook lookups
|
|
},
|
|
{
|
|
name: 'status',
|
|
type: 'select',
|
|
admin: {
|
|
position: 'sidebar',
|
|
},
|
|
options: [
|
|
{ label: 'Pending', value: 'pending' },
|
|
{ label: 'Processing', value: 'processing' },
|
|
{ label: 'Succeeded', value: 'succeeded' },
|
|
{ label: 'Failed', value: 'failed' },
|
|
{ label: 'Canceled', value: 'canceled' },
|
|
{ label: 'Refunded', value: 'refunded' },
|
|
{ label: 'Partially Refunded', value: 'partially_refunded' },
|
|
],
|
|
required: true,
|
|
},
|
|
{
|
|
name: 'amount',
|
|
type: 'number',
|
|
admin: {
|
|
description: 'Amount in cents (e.g., 2000 = $20.00)',
|
|
},
|
|
min: 1,
|
|
required: true,
|
|
},
|
|
{
|
|
name: 'currency',
|
|
type: 'text',
|
|
admin: {
|
|
description: 'ISO 4217 currency code (e.g., USD, EUR)',
|
|
},
|
|
maxLength: 3,
|
|
required: true,
|
|
},
|
|
{
|
|
name: 'description',
|
|
type: 'text',
|
|
admin: {
|
|
description: 'Payment description',
|
|
},
|
|
},
|
|
{
|
|
name: 'invoice',
|
|
type: 'relationship',
|
|
admin: {
|
|
position: 'sidebar',
|
|
},
|
|
relationTo: extractSlug(pluginConfig.collections?.invoices || defaults.invoicesCollection) as CollectionSlug,
|
|
},
|
|
{
|
|
name: 'metadata',
|
|
type: 'json',
|
|
admin: {
|
|
description: 'Additional metadata for the payment',
|
|
},
|
|
},
|
|
{
|
|
name: 'providerData',
|
|
type: 'json',
|
|
admin: {
|
|
description: 'Raw data from the payment provider',
|
|
readOnly: true,
|
|
},
|
|
},
|
|
{
|
|
name: 'refunds',
|
|
type: 'relationship',
|
|
admin: {
|
|
position: 'sidebar',
|
|
readOnly: true,
|
|
},
|
|
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,
|
|
index: true, // Index for faster optimistic lock queries
|
|
},
|
|
]
|
|
if (overrides?.fields) {
|
|
fields = overrides?.fields({defaultFields: fields})
|
|
}
|
|
return {
|
|
slug: extractSlug(pluginConfig.collections?.payments || defaults.paymentsCollection),
|
|
access: overrides?.access || {
|
|
create: ({ req: { user } }: AccessArgs) => !!user,
|
|
delete: ({ req: { user } }: AccessArgs) => !!user,
|
|
read: ({ req: { user } }: AccessArgs) => !!user,
|
|
update: ({ req: { user } }: AccessArgs) => !!user,
|
|
},
|
|
admin: {
|
|
defaultColumns: ['id', 'provider', 'status', 'amount', 'currency', 'createdAt'],
|
|
group: 'Billing',
|
|
useAsTitle: 'id',
|
|
...overrides?.admin
|
|
},
|
|
fields,
|
|
hooks: {
|
|
beforeChange: [
|
|
async ({ data, operation, req, originalDoc }) => {
|
|
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)')
|
|
}
|
|
|
|
// Validate currency format
|
|
if (data.currency) {
|
|
data.currency = data.currency.toUpperCase()
|
|
if (!/^[A-Z]{3}$/.test(data.currency)) {
|
|
throw new Error('Currency must be a 3-letter ISO code')
|
|
}
|
|
}
|
|
|
|
await initProviderPayment(req.payload, data)
|
|
} else if (operation === 'update') {
|
|
// Handle version incrementing for manual updates
|
|
// Webhook updates from providers should already set the version via optimistic locking
|
|
if (!data.version && originalDoc?.id) {
|
|
// Check if this is a webhook update using explicit context tracking
|
|
if (!isWebhookRequest(req)) {
|
|
// This is a manual admin update, safely increment version
|
|
data.version = (originalDoc.version || 1) + 1
|
|
}
|
|
// If it's a webhook update without a version, let it proceed (optimistic locking already handled it)
|
|
}
|
|
}
|
|
},
|
|
] satisfies CollectionBeforeChangeHook<Payment>[],
|
|
},
|
|
timestamps: true,
|
|
}
|
|
}
|