Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions backend/services/webhook/eventCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* EventCatalogRegistry — Comprehensive webhook event catalog (30+ events)
* covering the full subscription lifecycle with typed payloads, versioning,
* and deprecation support.
*/

export interface EventDefinition {
type: string;
version: number;
description: string;
category: EventCategory;
deprecated?: boolean;
deprecatedAt?: string;
sunsetAt?: string;
replacedBy?: string;
payloadSchema: Record<string, SchemaField>;
}

export interface SchemaField {
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
required: boolean;
description: string;
example?: unknown;
}

export type EventCategory =
| 'subscription'
| 'payment'
| 'invoice'
| 'trial'
| 'usage'
| 'plan';

const basePayloadSchema: Record<string, SchemaField> = {
id: { type: 'string', required: true, description: 'Unique event ID', example: 'evt_abc123' },
type: { type: 'string', required: true, description: 'Event type', example: 'subscription.created' },
version: { type: 'number', required: true, description: 'Schema version', example: 1 },
occurredAt: { type: 'number', required: true, description: 'Unix timestamp (ms)', example: 1719100000000 },
idempotencyKey: { type: 'string', required: true, description: 'Idempotency key for deduplication' },
merchantId: { type: 'string', required: true, description: 'Merchant identifier' },
};

const subscriptionDataSchema: Record<string, SchemaField> = {
subscriptionId: { type: 'string', required: true, description: 'Subscription ID' },
planId: { type: 'string', required: true, description: 'Plan ID' },
subscriberId: { type: 'string', required: true, description: 'Subscriber address/ID' },
status: { type: 'string', required: true, description: 'Current status' },
previousStatus: { type: 'string', required: false, description: 'Previous status (for transitions)' },
};

function defineEvent(
type: string,
description: string,
category: EventCategory,
extraFields: Record<string, SchemaField> = {},
opts: Partial<Pick<EventDefinition, 'deprecated' | 'deprecatedAt' | 'sunsetAt' | 'replacedBy'>> = {},
): EventDefinition {
return {
type,
version: 1,
description,
category,
...opts,
payloadSchema: { ...basePayloadSchema, ...subscriptionDataSchema, ...extraFields },
};
}

const amountField: SchemaField = { type: 'number', required: true, description: 'Amount in smallest unit' };
const currencyField: SchemaField = { type: 'string', required: true, description: 'Token/currency symbol' };
const reasonField: SchemaField = { type: 'string', required: false, description: 'Reason for the action' };

export const EVENT_CATALOG: EventDefinition[] = [
// ── Subscription events ──────────────────────────────────────────────────
defineEvent('subscription.created', 'New subscription created', 'subscription'),
defineEvent('subscription.updated', 'Subscription details updated', 'subscription'),
defineEvent('subscription.cancelled', 'Subscription cancelled', 'subscription', { reason: reasonField, cancelledAt: { type: 'number', required: true, description: 'Cancellation timestamp' } }),
defineEvent('subscription.paused', 'Subscription paused', 'subscription', { pausedAt: { type: 'number', required: true, description: 'Pause timestamp' } }),
defineEvent('subscription.resumed', 'Subscription resumed from pause', 'subscription', { resumedAt: { type: 'number', required: true, description: 'Resume timestamp' } }),
defineEvent('subscription.expired', 'Subscription reached end date', 'subscription'),
defineEvent('subscription.renewed', 'Subscription auto-renewed', 'subscription', { amount: amountField, currency: currencyField }),
defineEvent('subscription.upgraded', 'Plan upgrade completed', 'subscription', { oldPlanId: { type: 'string', required: true, description: 'Previous plan' }, newPlanId: { type: 'string', required: true, description: 'New plan' } }),
defineEvent('subscription.downgraded', 'Plan downgrade completed', 'subscription', { oldPlanId: { type: 'string', required: true, description: 'Previous plan' }, newPlanId: { type: 'string', required: true, description: 'New plan' } }),
defineEvent('subscription.transfer_requested', 'Ownership transfer requested', 'subscription'),
defineEvent('subscription.transfer_completed', 'Ownership transfer completed', 'subscription'),
defineEvent('subscription.grace_period_started', 'Grace period after failed payment', 'subscription'),
defineEvent('subscription.grace_period_ended', 'Grace period expired', 'subscription'),

// ── Payment events ───────────────────────────────────────────────────────
defineEvent('payment.succeeded', 'Payment processed successfully', 'payment', { amount: amountField, currency: currencyField, transactionHash: { type: 'string', required: false, description: 'On-chain tx hash' } }),
defineEvent('payment.failed', 'Payment attempt failed', 'payment', { amount: amountField, currency: currencyField, errorCode: { type: 'string', required: false, description: 'Error code' } }),
defineEvent('payment.refunded', 'Payment refunded', 'payment', { amount: amountField, currency: currencyField, refundReason: reasonField }),
defineEvent('payment.disputed', 'Payment disputed by subscriber', 'payment', { amount: amountField, currency: currencyField }),
defineEvent('payment.chargeback', 'Chargeback initiated', 'payment', { amount: amountField, currency: currencyField }),
defineEvent('payment.method_updated', 'Payment method changed', 'payment'),
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' } }),

// ── Invoice events ───────────────────────────────────────────────────────
defineEvent('invoice.created', 'Invoice generated', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, amount: amountField }),
defineEvent('invoice.finalized', 'Invoice finalized and ready for payment', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' } }),
defineEvent('invoice.paid', 'Invoice paid', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, amount: amountField }),
defineEvent('invoice.voided', 'Invoice voided', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, reason: reasonField }),
defineEvent('invoice.overdue', 'Invoice past due', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, daysOverdue: { type: 'number', required: true, description: 'Days overdue' } }),

// ── Trial events ─────────────────────────────────────────────────────────
defineEvent('trial.started', 'Trial period started', 'trial', { trialEndsAt: { type: 'number', required: true, description: 'Trial end timestamp' } }),
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' } }),
defineEvent('trial.ended', 'Trial period ended', 'trial', { converted: { type: 'boolean', required: true, description: 'Whether trial converted to paid' } }),
defineEvent('trial.converted', 'Trial converted to paid subscription', 'trial'),

// ── Usage events ─────────────────────────────────────────────────────────
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' } }),
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' } }),
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' } }),

// ── Plan events ──────────────────────────────────────────────────────────
defineEvent('plan.created', 'New plan created', 'plan', { planName: { type: 'string', required: true, description: 'Plan name' }, price: amountField }),
defineEvent('plan.updated', 'Plan details updated', 'plan'),
defineEvent('plan.archived', 'Plan archived (no new subscriptions)', 'plan'),
defineEvent('plan.price_changed', 'Plan price changed', 'plan', { oldPrice: amountField, newPrice: { type: 'number', required: true, description: 'New price' } }),
];

export class EventCatalogRegistry {
private events: Map<string, EventDefinition>;

constructor() {
this.events = new Map();
for (const event of EVENT_CATALOG) {
this.events.set(event.type, event);
}
}

getEvent(type: string): EventDefinition | undefined {
return this.events.get(type);
}

getAllEvents(): EventDefinition[] {
return Array.from(this.events.values());
}

getByCategory(category: EventCategory): EventDefinition[] {
return this.getAllEvents().filter(e => e.category === category);
}

getActiveEvents(): EventDefinition[] {
return this.getAllEvents().filter(e => !e.deprecated);
}

matchesWildcard(pattern: string, eventType: string): boolean {
if (pattern === '*') return true;
if (pattern.endsWith('.*')) {
return eventType.startsWith(pattern.slice(0, -1));
}
return pattern === eventType;
}

filterByPatterns(patterns: string[]): EventDefinition[] {
return this.getAllEvents().filter(e =>
patterns.some(p => this.matchesWildcard(p, e.type))
);
}

getDeprecationHeaders(type: string): Record<string, string> {
const event = this.getEvent(type);
if (!event?.deprecated) return {};
const headers: Record<string, string> = {};
if (event.deprecatedAt) headers['Deprecation'] = event.deprecatedAt;
if (event.sunsetAt) headers['Sunset'] = event.sunsetAt;
if (event.replacedBy) headers['Link'] = `<${event.replacedBy}>; rel="successor-version"`;
return headers;
}
}

export const eventCatalog = new EventCatalogRegistry();
133 changes: 133 additions & 0 deletions backend/services/webhook/eventReplayWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Event replay worker — replays webhook events from history
* with idempotency key checking and ordering guarantees.
*/

export interface ReplayRequest {
eventIds: string[];
webhookId: string;
targetUrl: string;
secretKey: string;
}

export interface ReplayResult {
eventId: string;
status: 'replayed' | 'skipped' | 'failed';
error?: string;
responseCode?: number;
}

export interface StoredEvent {
id: string;
type: string;
subscriptionId: string;
payload: Record<string, unknown>;
occurredAt: number;
idempotencyKey: string;
}

export class EventReplayWorker {
private eventStore: Map<string, StoredEvent> = new Map();
private deliveredIdempotencyKeys: Set<string> = new Set();

storeEvent(event: StoredEvent): void {
this.eventStore.set(event.id, event);
}

markDelivered(idempotencyKey: string): void {
this.deliveredIdempotencyKeys.set.add(idempotencyKey);
}

async replay(request: ReplayRequest): Promise<ReplayResult[]> {
const results: ReplayResult[] = [];

// Sort events by occurredAt for ordering guarantee per subscription
const events = request.eventIds
.map(id => this.eventStore.get(id))
.filter((e): e is StoredEvent => e !== undefined)
.sort((a, b) => a.occurredAt - b.occurredAt);

// Group by subscription for per-subscription ordering
const bySubscription = new Map<string, StoredEvent[]>();
for (const event of events) {
const group = bySubscription.get(event.subscriptionId) ?? [];
group.push(event);
bySubscription.set(event.subscriptionId, group);
}

for (const [, subEvents] of bySubscription) {
for (const event of subEvents) {
if (this.deliveredIdempotencyKeys.has(event.idempotencyKey)) {
results.push({ eventId: event.id, status: 'skipped' });
continue;
}

try {
const response = await this.deliverEvent(
request.targetUrl,
event.payload,
request.secretKey,
event.idempotencyKey
);

if (response.ok) {
this.deliveredIdempotencyKeys.add(event.idempotencyKey);
results.push({ eventId: event.id, status: 'replayed', responseCode: response.status });
} else {
results.push({ eventId: event.id, status: 'failed', responseCode: response.status });
}
} catch (err) {
results.push({
eventId: event.id,
status: 'failed',
error: err instanceof Error ? err.message : 'Unknown error',
});
}
}
}

return results;
}

private async deliverEvent(
url: string,
payload: Record<string, unknown>,
secretKey: string,
idempotencyKey: string
): Promise<{ ok: boolean; status: number }> {
const body = JSON.stringify(payload);

const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': this.computeSignature(body, secretKey),
'X-Idempotency-Key': idempotencyKey,
'X-Replay': 'true',
},
body,
signal: AbortSignal.timeout(10000),
});

return { ok: res.ok, status: res.status };
}

private computeSignature(body: string, secret: string): string {
// In production, use HMAC-SHA256
// Placeholder: simple hash for structure
let hash = 0;
const input = secret + body;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
}
return `sha256=${Math.abs(hash).toString(16)}`;
}

getEventHistory(webhookId: string, limit = 50): StoredEvent[] {
return Array.from(this.eventStore.values())
.sort((a, b) => b.occurredAt - a.occurredAt)
.slice(0, limit);
}
}

export const eventReplayWorker = new EventReplayWorker();
85 changes: 85 additions & 0 deletions backend/services/webhook/eventSchemaValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* EventSchemaValidator — Validates webhook event payloads against
* the JSON Schema definitions in the event catalog.
*/

import { eventCatalog, type SchemaField } from './eventCatalog';

export interface ValidationResult {
valid: boolean;
errors: string[];
}

export class EventSchemaValidator {
validate(eventType: string, payload: Record<string, unknown>): ValidationResult {
const definition = eventCatalog.getEvent(eventType);
if (!definition) {
return { valid: false, errors: [`Unknown event type: ${eventType}`] };
}

const errors: string[] = [];
const schema = definition.payloadSchema;

for (const [field, spec] of Object.entries(schema)) {
const value = payload[field];

if (spec.required && (value === undefined || value === null)) {
errors.push(`Missing required field: ${field}`);
continue;
}

if (value !== undefined && value !== null) {
if (!this.checkType(value, spec)) {
errors.push(`Field "${field}" expected type "${spec.type}", got "${typeof value}"`);
}
}
}

return { valid: errors.length === 0, errors };
}

private checkType(value: unknown, spec: SchemaField): boolean {
switch (spec.type) {
case 'string':
return typeof value === 'string';
case 'number':
return typeof value === 'number' && !isNaN(value);
case 'boolean':
return typeof value === 'boolean';
case 'object':
return typeof value === 'object' && value !== null && !Array.isArray(value);
case 'array':
return Array.isArray(value);
default:
return true;
}
}

generateExample(eventType: string): Record<string, unknown> | null {
const definition = eventCatalog.getEvent(eventType);
if (!definition) return null;

const example: Record<string, unknown> = {};
for (const [field, spec] of Object.entries(definition.payloadSchema)) {
if (spec.example !== undefined) {
example[field] = spec.example;
} else {
example[field] = this.defaultForType(spec.type);
}
}
return example;
}

private defaultForType(type: string): unknown {
switch (type) {
case 'string': return '';
case 'number': return 0;
case 'boolean': return false;
case 'object': return {};
case 'array': return [];
default: return null;
}
}
}

export const eventSchemaValidator = new EventSchemaValidator();
Loading