|
| 1 | +/** |
| 2 | + * EventCatalogRegistry — Comprehensive webhook event catalog (30+ events) |
| 3 | + * covering the full subscription lifecycle with typed payloads, versioning, |
| 4 | + * and deprecation support. |
| 5 | + */ |
| 6 | + |
| 7 | +export interface EventDefinition { |
| 8 | + type: string; |
| 9 | + version: number; |
| 10 | + description: string; |
| 11 | + category: EventCategory; |
| 12 | + deprecated?: boolean; |
| 13 | + deprecatedAt?: string; |
| 14 | + sunsetAt?: string; |
| 15 | + replacedBy?: string; |
| 16 | + payloadSchema: Record<string, SchemaField>; |
| 17 | +} |
| 18 | + |
| 19 | +export interface SchemaField { |
| 20 | + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; |
| 21 | + required: boolean; |
| 22 | + description: string; |
| 23 | + example?: unknown; |
| 24 | +} |
| 25 | + |
| 26 | +export type EventCategory = |
| 27 | + | 'subscription' |
| 28 | + | 'payment' |
| 29 | + | 'invoice' |
| 30 | + | 'trial' |
| 31 | + | 'usage' |
| 32 | + | 'plan'; |
| 33 | + |
| 34 | +const basePayloadSchema: Record<string, SchemaField> = { |
| 35 | + id: { type: 'string', required: true, description: 'Unique event ID', example: 'evt_abc123' }, |
| 36 | + type: { type: 'string', required: true, description: 'Event type', example: 'subscription.created' }, |
| 37 | + version: { type: 'number', required: true, description: 'Schema version', example: 1 }, |
| 38 | + occurredAt: { type: 'number', required: true, description: 'Unix timestamp (ms)', example: 1719100000000 }, |
| 39 | + idempotencyKey: { type: 'string', required: true, description: 'Idempotency key for deduplication' }, |
| 40 | + merchantId: { type: 'string', required: true, description: 'Merchant identifier' }, |
| 41 | +}; |
| 42 | + |
| 43 | +const subscriptionDataSchema: Record<string, SchemaField> = { |
| 44 | + subscriptionId: { type: 'string', required: true, description: 'Subscription ID' }, |
| 45 | + planId: { type: 'string', required: true, description: 'Plan ID' }, |
| 46 | + subscriberId: { type: 'string', required: true, description: 'Subscriber address/ID' }, |
| 47 | + status: { type: 'string', required: true, description: 'Current status' }, |
| 48 | + previousStatus: { type: 'string', required: false, description: 'Previous status (for transitions)' }, |
| 49 | +}; |
| 50 | + |
| 51 | +function defineEvent( |
| 52 | + type: string, |
| 53 | + description: string, |
| 54 | + category: EventCategory, |
| 55 | + extraFields: Record<string, SchemaField> = {}, |
| 56 | + opts: Partial<Pick<EventDefinition, 'deprecated' | 'deprecatedAt' | 'sunsetAt' | 'replacedBy'>> = {}, |
| 57 | +): EventDefinition { |
| 58 | + return { |
| 59 | + type, |
| 60 | + version: 1, |
| 61 | + description, |
| 62 | + category, |
| 63 | + ...opts, |
| 64 | + payloadSchema: { ...basePayloadSchema, ...subscriptionDataSchema, ...extraFields }, |
| 65 | + }; |
| 66 | +} |
| 67 | + |
| 68 | +const amountField: SchemaField = { type: 'number', required: true, description: 'Amount in smallest unit' }; |
| 69 | +const currencyField: SchemaField = { type: 'string', required: true, description: 'Token/currency symbol' }; |
| 70 | +const reasonField: SchemaField = { type: 'string', required: false, description: 'Reason for the action' }; |
| 71 | + |
| 72 | +export const EVENT_CATALOG: EventDefinition[] = [ |
| 73 | + // ── Subscription events ────────────────────────────────────────────────── |
| 74 | + defineEvent('subscription.created', 'New subscription created', 'subscription'), |
| 75 | + defineEvent('subscription.updated', 'Subscription details updated', 'subscription'), |
| 76 | + defineEvent('subscription.cancelled', 'Subscription cancelled', 'subscription', { reason: reasonField, cancelledAt: { type: 'number', required: true, description: 'Cancellation timestamp' } }), |
| 77 | + defineEvent('subscription.paused', 'Subscription paused', 'subscription', { pausedAt: { type: 'number', required: true, description: 'Pause timestamp' } }), |
| 78 | + defineEvent('subscription.resumed', 'Subscription resumed from pause', 'subscription', { resumedAt: { type: 'number', required: true, description: 'Resume timestamp' } }), |
| 79 | + defineEvent('subscription.expired', 'Subscription reached end date', 'subscription'), |
| 80 | + defineEvent('subscription.renewed', 'Subscription auto-renewed', 'subscription', { amount: amountField, currency: currencyField }), |
| 81 | + defineEvent('subscription.upgraded', 'Plan upgrade completed', 'subscription', { oldPlanId: { type: 'string', required: true, description: 'Previous plan' }, newPlanId: { type: 'string', required: true, description: 'New plan' } }), |
| 82 | + defineEvent('subscription.downgraded', 'Plan downgrade completed', 'subscription', { oldPlanId: { type: 'string', required: true, description: 'Previous plan' }, newPlanId: { type: 'string', required: true, description: 'New plan' } }), |
| 83 | + defineEvent('subscription.transfer_requested', 'Ownership transfer requested', 'subscription'), |
| 84 | + defineEvent('subscription.transfer_completed', 'Ownership transfer completed', 'subscription'), |
| 85 | + defineEvent('subscription.grace_period_started', 'Grace period after failed payment', 'subscription'), |
| 86 | + defineEvent('subscription.grace_period_ended', 'Grace period expired', 'subscription'), |
| 87 | + |
| 88 | + // ── Payment events ─────────────────────────────────────────────────────── |
| 89 | + defineEvent('payment.succeeded', 'Payment processed successfully', 'payment', { amount: amountField, currency: currencyField, transactionHash: { type: 'string', required: false, description: 'On-chain tx hash' } }), |
| 90 | + defineEvent('payment.failed', 'Payment attempt failed', 'payment', { amount: amountField, currency: currencyField, errorCode: { type: 'string', required: false, description: 'Error code' } }), |
| 91 | + defineEvent('payment.refunded', 'Payment refunded', 'payment', { amount: amountField, currency: currencyField, refundReason: reasonField }), |
| 92 | + defineEvent('payment.disputed', 'Payment disputed by subscriber', 'payment', { amount: amountField, currency: currencyField }), |
| 93 | + defineEvent('payment.chargeback', 'Chargeback initiated', 'payment', { amount: amountField, currency: currencyField }), |
| 94 | + defineEvent('payment.method_updated', 'Payment method changed', 'payment'), |
| 95 | + defineEvent('payment.retry_scheduled', 'Failed payment retry scheduled', 'payment', { retryAt: { type: 'number', required: true, description: 'Retry timestamp' }, attemptNumber: { type: 'number', required: true, description: 'Attempt count' } }), |
| 96 | + |
| 97 | + // ── Invoice events ─────────────────────────────────────────────────────── |
| 98 | + defineEvent('invoice.created', 'Invoice generated', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, amount: amountField }), |
| 99 | + defineEvent('invoice.finalized', 'Invoice finalized and ready for payment', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' } }), |
| 100 | + defineEvent('invoice.paid', 'Invoice paid', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, amount: amountField }), |
| 101 | + defineEvent('invoice.voided', 'Invoice voided', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, reason: reasonField }), |
| 102 | + defineEvent('invoice.overdue', 'Invoice past due', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, daysOverdue: { type: 'number', required: true, description: 'Days overdue' } }), |
| 103 | + |
| 104 | + // ── Trial events ───────────────────────────────────────────────────────── |
| 105 | + defineEvent('trial.started', 'Trial period started', 'trial', { trialEndsAt: { type: 'number', required: true, description: 'Trial end timestamp' } }), |
| 106 | + defineEvent('trial.ending_soon', 'Trial ending within 3 days', 'trial', { trialEndsAt: { type: 'number', required: true, description: 'Trial end timestamp' }, daysRemaining: { type: 'number', required: true, description: 'Days left' } }), |
| 107 | + defineEvent('trial.ended', 'Trial period ended', 'trial', { converted: { type: 'boolean', required: true, description: 'Whether trial converted to paid' } }), |
| 108 | + defineEvent('trial.converted', 'Trial converted to paid subscription', 'trial'), |
| 109 | + |
| 110 | + // ── Usage events ───────────────────────────────────────────────────────── |
| 111 | + defineEvent('usage.threshold_reached', 'Usage threshold reached', 'usage', { metric: { type: 'string', required: true, description: 'Usage metric name' }, currentUsage: { type: 'number', required: true, description: 'Current value' }, threshold: { type: 'number', required: true, description: 'Threshold value' } }), |
| 112 | + defineEvent('usage.limit_exceeded', 'Usage limit exceeded', 'usage', { metric: { type: 'string', required: true, description: 'Usage metric name' }, currentUsage: { type: 'number', required: true, description: 'Current value' }, limit: { type: 'number', required: true, description: 'Limit value' } }), |
| 113 | + defineEvent('usage.recorded', 'Usage data point recorded', 'usage', { metric: { type: 'string', required: true, description: 'Usage metric name' }, value: { type: 'number', required: true, description: 'Recorded value' } }), |
| 114 | + |
| 115 | + // ── Plan events ────────────────────────────────────────────────────────── |
| 116 | + defineEvent('plan.created', 'New plan created', 'plan', { planName: { type: 'string', required: true, description: 'Plan name' }, price: amountField }), |
| 117 | + defineEvent('plan.updated', 'Plan details updated', 'plan'), |
| 118 | + defineEvent('plan.archived', 'Plan archived (no new subscriptions)', 'plan'), |
| 119 | + defineEvent('plan.price_changed', 'Plan price changed', 'plan', { oldPrice: amountField, newPrice: { type: 'number', required: true, description: 'New price' } }), |
| 120 | +]; |
| 121 | + |
| 122 | +export class EventCatalogRegistry { |
| 123 | + private events: Map<string, EventDefinition>; |
| 124 | + |
| 125 | + constructor() { |
| 126 | + this.events = new Map(); |
| 127 | + for (const event of EVENT_CATALOG) { |
| 128 | + this.events.set(event.type, event); |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + getEvent(type: string): EventDefinition | undefined { |
| 133 | + return this.events.get(type); |
| 134 | + } |
| 135 | + |
| 136 | + getAllEvents(): EventDefinition[] { |
| 137 | + return Array.from(this.events.values()); |
| 138 | + } |
| 139 | + |
| 140 | + getByCategory(category: EventCategory): EventDefinition[] { |
| 141 | + return this.getAllEvents().filter(e => e.category === category); |
| 142 | + } |
| 143 | + |
| 144 | + getActiveEvents(): EventDefinition[] { |
| 145 | + return this.getAllEvents().filter(e => !e.deprecated); |
| 146 | + } |
| 147 | + |
| 148 | + matchesWildcard(pattern: string, eventType: string): boolean { |
| 149 | + if (pattern === '*') return true; |
| 150 | + if (pattern.endsWith('.*')) { |
| 151 | + return eventType.startsWith(pattern.slice(0, -1)); |
| 152 | + } |
| 153 | + return pattern === eventType; |
| 154 | + } |
| 155 | + |
| 156 | + filterByPatterns(patterns: string[]): EventDefinition[] { |
| 157 | + return this.getAllEvents().filter(e => |
| 158 | + patterns.some(p => this.matchesWildcard(p, e.type)) |
| 159 | + ); |
| 160 | + } |
| 161 | + |
| 162 | + getDeprecationHeaders(type: string): Record<string, string> { |
| 163 | + const event = this.getEvent(type); |
| 164 | + if (!event?.deprecated) return {}; |
| 165 | + const headers: Record<string, string> = {}; |
| 166 | + if (event.deprecatedAt) headers['Deprecation'] = event.deprecatedAt; |
| 167 | + if (event.sunsetAt) headers['Sunset'] = event.sunsetAt; |
| 168 | + if (event.replacedBy) headers['Link'] = `<${event.replacedBy}>; rel="successor-version"`; |
| 169 | + return headers; |
| 170 | + } |
| 171 | +} |
| 172 | + |
| 173 | +export const eventCatalog = new EventCatalogRegistry(); |
0 commit comments