diff --git a/backend/services/webhook/eventCatalog.ts b/backend/services/webhook/eventCatalog.ts new file mode 100644 index 00000000..8b0bf5a7 --- /dev/null +++ b/backend/services/webhook/eventCatalog.ts @@ -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; +} + +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 = { + 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 = { + 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 = {}, + opts: Partial> = {}, +): 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; + + 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 { + const event = this.getEvent(type); + if (!event?.deprecated) return {}; + const headers: Record = {}; + 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(); diff --git a/backend/services/webhook/eventReplayWorker.ts b/backend/services/webhook/eventReplayWorker.ts new file mode 100644 index 00000000..29fd72e0 --- /dev/null +++ b/backend/services/webhook/eventReplayWorker.ts @@ -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; + occurredAt: number; + idempotencyKey: string; +} + +export class EventReplayWorker { + private eventStore: Map = new Map(); + private deliveredIdempotencyKeys: Set = 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 { + 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(); + 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, + 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(); diff --git a/backend/services/webhook/eventSchemaValidator.ts b/backend/services/webhook/eventSchemaValidator.ts new file mode 100644 index 00000000..c03d804b --- /dev/null +++ b/backend/services/webhook/eventSchemaValidator.ts @@ -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): 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 | null { + const definition = eventCatalog.getEvent(eventType); + if (!definition) return null; + + const example: Record = {}; + 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(); diff --git a/src/hooks/useDeviceIntegrity.ts b/src/hooks/useDeviceIntegrity.ts new file mode 100644 index 00000000..bf0ca920 --- /dev/null +++ b/src/hooks/useDeviceIntegrity.ts @@ -0,0 +1,46 @@ +import { useState, useEffect } from 'react'; +import { + deviceAttestationService, + DeviceIntegrityResult, +} from '../services/auth/deviceAttestationService'; + +interface DeviceIntegrityState { + result: DeviceIntegrityResult | null; + isChecking: boolean; + error: string | null; + recheck: () => Promise; +} + +export function useDeviceIntegrity(): DeviceIntegrityState { + const [result, setResult] = useState(null); + const [isChecking, setIsChecking] = useState(true); + const [error, setError] = useState(null); + + const check = async () => { + setIsChecking(true); + setError(null); + try { + const integrity = await deviceAttestationService.checkIntegrity(); + setResult(integrity); + } catch (e) { + setError(e instanceof Error ? e.message : 'Device integrity check failed'); + } finally { + setIsChecking(false); + } + }; + + useEffect(() => { + const init = async () => { + const cached = await deviceAttestationService.getCachedIntegrity(); + if (cached && Date.now() - cached.attestedAt < 24 * 60 * 60 * 1000) { + setResult(cached); + setIsChecking(false); + } else { + await check(); + } + }; + init(); + }, []); + + return { result, isChecking, error, recheck: check }; +} diff --git a/src/services/auth/biometricService.ts b/src/services/auth/biometricService.ts index 54ac9b6a..148f2327 100644 --- a/src/services/auth/biometricService.ts +++ b/src/services/auth/biometricService.ts @@ -23,6 +23,25 @@ export interface BiometricSettings { enabled: boolean; /** Whether to fall back to device PIN/passcode when biometrics fail. */ fallbackToPIN: boolean; + /** Hashed app PIN (bcrypt) — minimum 6 digits. */ + pinHash?: string; + /** Consecutive biometric failure count (for exponential backoff). */ + failureCount?: number; + /** Timestamp when lockout expires (0 = no lockout). */ + lockoutUntil?: number; + /** Whether device-bound key pair has been generated. */ + keyBound?: boolean; + /** Hash of enrolled biometrics at time of setup (detect enrollment changes). */ + enrollmentHash?: string; +} + +export interface BiometricPolicy { + /** Lockout tiers: [failures, lockoutMinutes] */ + lockoutTiers: [number, number][]; + /** Minimum PIN length */ + minPinLength: number; + /** Whether device integrity check is required */ + requireDeviceAttestation: boolean; } export interface BiometricAuthResult { @@ -171,6 +190,96 @@ class BiometricService { if (!settings.enabled) return { success: true }; return this.authenticate(reason, settings.fallbackToPIN); } + + // ── Hardening: Exponential backoff ──────────────────────────────────────── + + private static readonly LOCKOUT_TIERS: [number, number][] = [ + [3, 3], // 3 failures → 3 min lockout + [6, 10], // 6 failures → 10 min lockout + [9, 30], // 9 failures → 30 min lockout + ]; + + async checkLockout(): Promise<{ locked: boolean; remainingMs: number }> { + const settings = await this.getSettings(); + const now = Date.now(); + const lockoutUntil = settings.lockoutUntil ?? 0; + if (lockoutUntil > now) { + return { locked: true, remainingMs: lockoutUntil - now }; + } + return { locked: false, remainingMs: 0 }; + } + + async recordFailure(): Promise { + const settings = await this.getSettings(); + const failures = (settings.failureCount ?? 0) + 1; + let lockoutUntil = 0; + + for (const [threshold, minutes] of BiometricService.LOCKOUT_TIERS) { + if (failures >= threshold) { + lockoutUntil = Date.now() + minutes * 60 * 1000; + } + } + + await this.saveSettings({ failureCount: failures, lockoutUntil }); + } + + async resetFailures(): Promise { + await this.saveSettings({ failureCount: 0, lockoutUntil: 0 }); + } + + // ── Hardening: Authenticated with lockout enforcement ───────────────────── + + async authenticateHardened(reason?: string): Promise { + const lockout = await this.checkLockout(); + if (lockout.locked) { + const mins = Math.ceil(lockout.remainingMs / 60000); + return { success: false, error: `Too many failures. Try again in ${mins} minute(s).` }; + } + + const result = await this.authenticate(reason); + if (result.success) { + await this.resetFailures(); + } else if (!result.cancelled) { + await this.recordFailure(); + } + return result; + } + + // ── PIN management ──────────────────────────────────────────────────────── + + async setPinHash(pinHash: string): Promise { + await this.saveSettings({ pinHash }); + } + + async verifyPin(inputHash: string): Promise { + const settings = await this.getSettings(); + return settings.pinHash === inputHash; + } + + // ── Enrollment change detection ─────────────────────────────────────────── + + async setEnrollmentHash(hash: string): Promise { + await this.saveSettings({ enrollmentHash: hash }); + } + + async hasEnrollmentChanged(): Promise { + const settings = await this.getSettings(); + if (!settings.enrollmentHash) return false; + // In production, compare against current biometric enrollment via native module + // Placeholder: always returns false (no native enrollment hash API in Expo) + return false; + } + + // ── Key binding ─────────────────────────────────────────────────────────── + + async markKeyBound(): Promise { + await this.saveSettings({ keyBound: true }); + } + + async isKeyBound(): Promise { + const settings = await this.getSettings(); + return settings.keyBound ?? false; + } } export const biometricService = new BiometricService(); diff --git a/src/services/auth/deviceAttestationService.ts b/src/services/auth/deviceAttestationService.ts new file mode 100644 index 00000000..d2a3a166 --- /dev/null +++ b/src/services/auth/deviceAttestationService.ts @@ -0,0 +1,97 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Platform } from 'react-native'; + +const DEVICE_INTEGRITY_KEY = '@subtrackr/device_integrity'; + +export interface DeviceIntegrityResult { + isIntact: boolean; + isRooted: boolean; + isEmulator: boolean; + attestedAt: number; +} + +class DeviceAttestationService { + async checkIntegrity(): Promise { + const isRooted = await this.detectRootOrJailbreak(); + const isEmulator = this.detectEmulator(); + + const result: DeviceIntegrityResult = { + isIntact: !isRooted && !isEmulator, + isRooted, + isEmulator, + attestedAt: Date.now(), + }; + + await AsyncStorage.setItem(DEVICE_INTEGRITY_KEY, JSON.stringify(result)); + return result; + } + + async getCachedIntegrity(): Promise { + try { + const raw = await AsyncStorage.getItem(DEVICE_INTEGRITY_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } + } + + private async detectRootOrJailbreak(): Promise { + if (Platform.OS === 'ios') { + return this.detectJailbreak(); + } + if (Platform.OS === 'android') { + return this.detectRoot(); + } + return false; + } + + private detectJailbreak(): boolean { + // Check for common jailbreak indicators + // In production, use a native module for file system checks + try { + const indicators = [ + '/Applications/Cydia.app', + '/Library/MobileSubstrate/MobileSubstrate.dylib', + '/bin/bash', + '/usr/sbin/sshd', + '/etc/apt', + '/private/var/lib/apt/', + ]; + // React Native can't directly check file existence without native module + // This is a placeholder — in production use react-native-device-info or jail-monkey + return false; + } catch { + return false; + } + } + + private detectRoot(): boolean { + // Check for common root indicators on Android + // In production, use SafetyNet/Play Integrity API + try { + const indicators = [ + '/system/app/Superuser.apk', + '/sbin/su', + '/system/bin/su', + '/system/xbin/su', + '/data/local/xbin/su', + '/data/local/bin/su', + '/system/sd/xbin/su', + ]; + return false; + } catch { + return false; + } + } + + private detectEmulator(): boolean { + if (Platform.OS === 'android') { + const brand = (Platform as any).constants?.Brand?.toLowerCase() || ''; + const model = (Platform as any).constants?.Model?.toLowerCase() || ''; + return brand === 'google' && model.includes('sdk'); + } + return false; + } +} + +export const deviceAttestationService = new DeviceAttestationService(); diff --git a/src/types/webhook.ts b/src/types/webhook.ts index a09431ed..8cadfe4b 100644 --- a/src/types/webhook.ts +++ b/src/types/webhook.ts @@ -1,19 +1,54 @@ import { BillingCycle } from './subscription'; export type WebhookEventType = + // Subscription lifecycle | 'subscription.created' | 'subscription.updated' - | 'subscription.renewed' | 'subscription.cancelled' - | 'subscription.payment_failed' - | 'subscription.upgraded' | 'subscription.paused' | 'subscription.resumed' + | 'subscription.expired' + | 'subscription.renewed' + | 'subscription.upgraded' + | 'subscription.downgraded' + | 'subscription.transfer_requested' + | 'subscription.transfer_completed' + | 'subscription.grace_period_started' + | 'subscription.grace_period_ended' + // Payment + | 'payment.succeeded' + | 'payment.failed' + | 'payment.refunded' + | 'payment.disputed' + | 'payment.chargeback' + | 'payment.method_updated' + | 'payment.retry_scheduled' + // Invoice + | 'invoice.created' + | 'invoice.finalized' + | 'invoice.paid' + | 'invoice.voided' + | 'invoice.overdue' + // Trial + | 'trial.started' + | 'trial.ending_soon' + | 'trial.ended' + | 'trial.converted' + // Usage + | 'usage.threshold_reached' + | 'usage.limit_exceeded' + | 'usage.recorded' + // Plan + | 'plan.created' + | 'plan.updated' + | 'plan.archived' + | 'plan.price_changed' + // Deprecated (kept for backward compatibility) + | 'subscription.payment_failed' | 'subscription.charged' | 'subscription.refund_requested' | 'subscription.refund_approved' | 'subscription.refund_rejected' - | 'subscription.transfer_requested' | 'subscription.transfer_accepted'; export interface WebhookRetryPolicy {