From db7d84d4a1e11dd23c96da04f32bc7753ad52f21 Mon Sep 17 00:00:00 2001 From: shaaibu7 Date: Fri, 24 Apr 2026 07:17:02 +0100 Subject: [PATCH] feat: implement subscription webhooks --- backend/services/__tests__/webhook.test.ts | 147 +++++++ backend/services/index.ts | 13 + backend/services/webhook.ts | 444 ++++++++++++++++++++ contracts/subscription/Cargo.toml | 2 + contracts/subscription/src/events.rs | 59 +++ contracts/subscription/src/lib.rs | 181 ++++++++- contracts/subscription/src/webhook.rs | 340 ++++++++++++++++ contracts/types/src/lib.rs | 131 +++++- src/navigation/AppNavigator.tsx | 6 + src/navigation/types.ts | 1 + src/screens/SettingsScreen.tsx | 11 + src/screens/WebhookSettingsScreen.tsx | 445 +++++++++++++++++++++ src/store/index.ts | 1 + src/store/webhookStore.ts | 256 ++++++++++++ src/types/webhook.ts | 120 ++++++ src/utils/webhook.ts | 70 ++++ 16 files changed, 2212 insertions(+), 15 deletions(-) create mode 100644 backend/services/__tests__/webhook.test.ts create mode 100644 backend/services/webhook.ts create mode 100644 contracts/subscription/src/events.rs create mode 100644 contracts/subscription/src/webhook.rs create mode 100644 src/screens/WebhookSettingsScreen.tsx create mode 100644 src/store/webhookStore.ts create mode 100644 src/types/webhook.ts create mode 100644 src/utils/webhook.ts diff --git a/backend/services/__tests__/webhook.test.ts b/backend/services/__tests__/webhook.test.ts new file mode 100644 index 0000000..483ae08 --- /dev/null +++ b/backend/services/__tests__/webhook.test.ts @@ -0,0 +1,147 @@ +import { + WebhookDeliveryService, + buildWebhookPayload, + signWebhookPayload, + verifyWebhookSignature, +} from '../webhook'; +import type { + WebhookEventInput, + WebhookPlanSnapshot, + WebhookSubscriptionSnapshot, +} from '../../../src/types/webhook'; + +const makeSubscription = (overrides: Partial = {}): WebhookSubscriptionSnapshot => ({ + id: 'sub_1', + planId: 'plan_1', + subscriberId: 'user_1', + status: 'active', + startedAt: 1_700_000_000, + lastChargedAt: 1_700_000_000, + nextChargeAt: 1_700_086_400, + totalPaid: 500, + totalGasSpent: 10, + chargeCount: 1, + pausedAt: 0, + pauseDuration: 0, + refundRequestedAmount: 0, + ...overrides, +}); + +const makePlan = (overrides: Partial = {}): WebhookPlanSnapshot => ({ + id: 'plan_1', + merchantId: 'merchant_1', + name: 'Pro', + price: 500, + token: 'USDC', + interval: 'monthly', + active: true, + subscriberCount: 1, + createdAt: 1_700_000_000, + ...overrides, +}); + +const makeInput = (overrides: Partial = {}): WebhookEventInput => ({ + webhookId: 'whk_1', + merchantId: 'merchant_1', + eventType: 'subscription.charged', + subscription: makeSubscription(), + plan: makePlan(), + previousStatus: 'active', + currentStatus: 'active', + occurredAt: 1_700_000_100, + ...overrides, +}); + +describe('WebhookDeliveryService', () => { + it('signs and verifies webhook payloads', () => { + const payload = buildWebhookPayload(makeInput()); + const signature = signWebhookPayload(payload, 'secret'); + + expect(verifyWebhookSignature(signature, payload, 'secret')).toBe(true); + expect(verifyWebhookSignature(signature, payload, 'different-secret')).toBe(false); + }); + + it('delivers with exponential backoff until success', async () => { + const fetchImpl = jest + .fn() + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce({ ok: true, status: 200 }); + const sleepImpl = jest.fn().mockResolvedValue(undefined); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch, sleepImpl }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['subscription.charged'], + secretKey: 'secret', + retryPolicy: { + maxRetries: 3, + initialDelayMs: 10, + maxDelayMs: 20, + backoffFactor: 2, + }, + }); + + const result = await service.deliverEvent(makeInput({ webhookId: webhook.id })); + + expect(result?.delivery.status).toBe('delivered'); + expect(result?.delivery.attempts).toBe(2); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(sleepImpl).toHaveBeenCalledWith(10); + }); + + it('fails fast for payloads over 1MB', async () => { + const fetchImpl = jest.fn(); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['subscription.charged'], + secretKey: 'secret', + }); + + const giantSubscription = makeSubscription({ + totalPaid: 500, + status: 'active', + // Inflate the payload by using a large subscriber identifier. + subscriberId: 'x'.repeat(1_050_000), + }); + + const result = await service.deliverEvent( + makeInput({ webhookId: webhook.id, subscription: giantSubscription }) + ); + + expect(result?.delivery.status).toBe('failed'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('supports manual retry after a failed delivery', async () => { + const fetchImpl = jest + .fn() + .mockRejectedValueOnce(new Error('down')) + .mockResolvedValueOnce({ ok: true, status: 200 }); + const sleepImpl = jest.fn().mockResolvedValue(undefined); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch, sleepImpl }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['subscription.charged'], + secretKey: 'secret', + retryPolicy: { + maxRetries: 0, + initialDelayMs: 10, + maxDelayMs: 10, + backoffFactor: 2, + }, + }); + + const first = await service.deliverEvent(makeInput({ webhookId: webhook.id })); + expect(first?.delivery.status).toBe('failed'); + + const retry = await service.retryWebhookDelivery(first!.delivery.id); + expect(retry.delivery.status).toBe('delivered'); + expect(retry.delivery.attempts).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/backend/services/index.ts b/backend/services/index.ts index 5c8dc3b..ad6532c 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -6,3 +6,16 @@ export type { ExportFormat, RetentionPolicy, } from './auditTypes'; +export { + WebhookDeliveryService, + webhookDeliveryService, + buildWebhookPayload, + signWebhookPayload, + verifyWebhookSignature, + isWebhookEventAllowed, +} from './webhook'; +export type { + RegisterWebhookInput, + WebhookDeliveryResult, + WebhookEventInput, +} from './webhook'; diff --git a/backend/services/webhook.ts b/backend/services/webhook.ts new file mode 100644 index 0000000..ac3d221 --- /dev/null +++ b/backend/services/webhook.ts @@ -0,0 +1,444 @@ +import crypto from 'crypto'; +import type { + WebhookAnalytics, + WebhookConfig, + WebhookDelivery, + WebhookDeliveryStatus, + WebhookEventPayload, + WebhookEventType, + WebhookRetryPolicy, + WebhookPlanSnapshot, + WebhookSubscriptionSnapshot, +} from '../../src/types/webhook'; + +type FetchLike = typeof fetch; + +export interface WebhookEventInput { + webhookId: string; + merchantId: string; + eventType: WebhookEventType; + subscription: WebhookSubscriptionSnapshot; + plan: WebhookPlanSnapshot; + previousStatus: string; + currentStatus: string; + occurredAt?: number; +} + +export interface RegisterWebhookInput { + merchantId: string; + url: string; + events: WebhookEventType[]; + secretKey: string; + retryPolicy?: Partial; + isPaused?: boolean; +} + +export interface WebhookDeliveryResult { + delivery: WebhookDelivery; + response?: Response; +} + +const MAX_PAYLOAD_BYTES = 1_048_576; +const DEFAULT_RETRY_POLICY: WebhookRetryPolicy = { + maxRetries: 5, + initialDelayMs: 250, + maxDelayMs: 8_000, + backoffFactor: 2, +}; + +const now = (): number => Date.now(); + +const createId = (prefix: string): string => + `${prefix}_${now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + +const clampRetryPolicy = (retryPolicy?: Partial): WebhookRetryPolicy => ({ + maxRetries: retryPolicy?.maxRetries ?? DEFAULT_RETRY_POLICY.maxRetries, + initialDelayMs: retryPolicy?.initialDelayMs ?? DEFAULT_RETRY_POLICY.initialDelayMs, + maxDelayMs: retryPolicy?.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs, + backoffFactor: retryPolicy?.backoffFactor ?? DEFAULT_RETRY_POLICY.backoffFactor, +}); + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export const signWebhookPayload = (payload: WebhookEventPayload, secretKey: string): string => { + const body = JSON.stringify(payload); + return crypto.createHmac('sha256', secretKey).update(body).digest('hex'); +}; + +export const verifyWebhookSignature = ( + signature: string, + payload: WebhookEventPayload, + secretKey: string +): boolean => { + const expected = signWebhookPayload(payload, secretKey); + const actualBytes = Buffer.from(signature); + const expectedBytes = Buffer.from(expected); + if (actualBytes.length !== expectedBytes.length) return false; + return crypto.timingSafeEqual(actualBytes, expectedBytes); +}; + +export const buildWebhookPayload = (input: WebhookEventInput): WebhookEventPayload => { + const eventId = createId('evt'); + return { + id: eventId, + webhookId: input.webhookId, + eventType: input.eventType, + occurredAt: input.occurredAt ?? now(), + merchantId: input.merchantId, + subscription: input.subscription, + plan: input.plan, + previousStatus: input.previousStatus, + currentStatus: input.currentStatus, + payloadVersion: 1, + }; +}; + +export const isWebhookEventAllowed = ( + webhook: Pick, + eventType: WebhookEventType +): boolean => !webhook.isPaused && webhook.events.includes(eventType); + +export class WebhookDeliveryService { + private readonly fetchImpl: FetchLike; + private readonly sleepImpl: (ms: number) => Promise; + private readonly webhooks = new Map(); + private readonly deliveries = new Map(); + private readonly deliveredKeys = new Set(); + + constructor(options: { fetchImpl?: FetchLike; sleepImpl?: (ms: number) => Promise } = {}) { + this.fetchImpl = options.fetchImpl ?? fetch; + this.sleepImpl = options.sleepImpl ?? sleep; + } + + registerWebhook(input: RegisterWebhookInput): WebhookConfig { + const id = createId('whk'); + const createdAt = now(); + const config: WebhookConfig = { + id, + merchantId: input.merchantId, + url: input.url, + events: [...input.events], + secretKey: input.secretKey, + retryPolicy: clampRetryPolicy(input.retryPolicy), + isPaused: input.isPaused ?? false, + createdAt, + updatedAt: createdAt, + lastHealthCheckAt: undefined, + lastHealthStatus: undefined, + successCount: 0, + failureCount: 0, + }; + + this.webhooks.set(id, config); + return config; + } + + updateWebhook(id: string, input: Partial>): WebhookConfig { + const existing = this.webhooks.get(id); + if (!existing) throw new Error(`Webhook ${id} not found`); + + const next: WebhookConfig = { + ...existing, + url: input.url ?? existing.url, + events: input.events ? [...input.events] : existing.events, + secretKey: input.secretKey ?? existing.secretKey, + retryPolicy: clampRetryPolicy(input.retryPolicy ?? existing.retryPolicy), + isPaused: input.isPaused ?? existing.isPaused, + updatedAt: now(), + }; + + this.webhooks.set(id, next); + return next; + } + + deleteWebhook(id: string): void { + this.webhooks.delete(id); + } + + pauseWebhook(id: string): WebhookConfig { + return this.updateWebhook(id, { isPaused: true }); + } + + resumeWebhook(id: string): WebhookConfig { + return this.updateWebhook(id, { isPaused: false }); + } + + listWebhooks(merchantId: string): WebhookConfig[] { + return Array.from(this.webhooks.values()).filter((webhook) => webhook.merchantId === merchantId); + } + + getWebhook(id: string): WebhookConfig | undefined { + return this.webhooks.get(id); + } + + getWebhookDeliveries(webhookId: string, limit: number): WebhookDelivery[] { + return Array.from(this.deliveries.values()) + .filter((delivery) => delivery.webhookId === webhookId) + .slice(-Math.max(0, limit)); + } + + getDelivery(deliveryId: string): WebhookDelivery | undefined { + return this.deliveries.get(deliveryId); + } + + getAnalytics(webhookId: string): WebhookAnalytics { + const deliveries = this.getWebhookDeliveries(webhookId, Number.MAX_SAFE_INTEGER); + const totalDeliveries = deliveries.length; + const successfulDeliveries = deliveries.filter((delivery) => delivery.status === 'delivered').length; + const failedDeliveries = deliveries.filter((delivery) => delivery.status === 'failed').length; + const pendingDeliveries = deliveries.filter((delivery) => + ['pending', 'retrying', 'paused'].includes(delivery.status) + ).length; + const retryCount = deliveries.reduce((sum, delivery) => sum + Math.max(0, delivery.attempts - 1), 0); + const avgAttempts = totalDeliveries ? deliveries.reduce((sum, d) => sum + d.attempts, 0) / totalDeliveries : 0; + + return { + webhookId, + totalDeliveries, + successfulDeliveries, + failedDeliveries, + retryCount, + pendingDeliveries, + successRate: totalDeliveries ? successfulDeliveries / totalDeliveries : 0, + avgAttempts, + lastSuccessAt: deliveries + .filter((delivery) => delivery.status === 'delivered' && delivery.deliveredAt) + .map((delivery) => delivery.deliveredAt as number) + .sort((a, b) => b - a)[0], + lastFailureAt: deliveries + .filter((delivery) => delivery.status === 'failed' && delivery.updatedAt) + .map((delivery) => delivery.updatedAt) + .sort((a, b) => b - a)[0], + }; + } + + async checkWebhookHealth(id: string): Promise { + const webhook = this.webhooks.get(id); + if (!webhook) throw new Error(`Webhook ${id} not found`); + + const checkedAt = now(); + try { + const response = await this.fetchImpl(webhook.url, { method: 'HEAD' }); + const healthy = response.ok; + const next: WebhookConfig = { + ...webhook, + lastHealthCheckAt: checkedAt, + lastHealthStatus: healthy ? 'healthy' : 'unhealthy', + updatedAt: checkedAt, + }; + this.webhooks.set(id, next); + return next; + } catch { + const next: WebhookConfig = { + ...webhook, + lastHealthCheckAt: checkedAt, + lastHealthStatus: 'unhealthy', + updatedAt: checkedAt, + }; + this.webhooks.set(id, next); + return next; + } + } + + async deliverEvent(input: WebhookEventInput): Promise { + const webhook = this.webhooks.get(input.webhookId); + if (!webhook || webhook.merchantId !== input.merchantId) return null; + if (!isWebhookEventAllowed(webhook, input.eventType)) return null; + + const payload = buildWebhookPayload(input); + const signature = signWebhookPayload(payload, webhook.secretKey); + const idempotencyKey = `${payload.id}:${webhook.id}`; + if (this.deliveredKeys.has(idempotencyKey)) { + const skipped = { + delivery: { + id: createId('del'), + webhookId: webhook.id, + eventId: payload.id, + eventType: payload.eventType, + url: webhook.url, + payload, + status: 'skipped', + attempts: 0, + maxAttempts: webhook.retryPolicy.maxRetries, + createdAt: now(), + updatedAt: now(), + signature, + idempotencyKey, + }, + }; + this.deliveries.set(skipped.delivery.id, skipped.delivery); + return skipped; + } + + const delivery: WebhookDelivery = { + id: createId('del'), + webhookId: webhook.id, + eventId: payload.id, + eventType: payload.eventType, + url: webhook.url, + payload, + status: 'pending', + attempts: 0, + maxAttempts: webhook.retryPolicy.maxRetries, + createdAt: now(), + updatedAt: now(), + signature, + idempotencyKey, + }; + + this.deliveries.set(delivery.id, delivery); + const result = await this.sendWithRetry(webhook, delivery); + this.deliveries.set(delivery.id, result.delivery); + + if (result.delivery.status === 'delivered') { + this.deliveredKeys.add(idempotencyKey); + } + return result; + } + + async retryWebhookDelivery(deliveryId: string): Promise { + const existing = this.deliveries.get(deliveryId); + if (!existing) throw new Error(`Delivery ${deliveryId} not found`); + const webhook = this.webhooks.get(existing.webhookId); + if (!webhook) throw new Error(`Webhook ${existing.webhookId} not found`); + + const restarted: WebhookDelivery = { + ...existing, + attempts: 0, + status: 'retrying', + updatedAt: now(), + nextRetryAt: undefined, + errorMessage: undefined, + }; + + this.deliveries.set(deliveryId, restarted); + const result = await this.sendWithRetry(webhook, restarted); + this.deliveries.set(deliveryId, result.delivery); + + if (result.delivery.status === 'delivered') { + this.deliveredKeys.add(existing.idempotencyKey); + } + return result; + } + + private async sendWithRetry( + webhook: WebhookConfig, + delivery: WebhookDelivery + ): Promise { + const payloadBody = JSON.stringify(delivery.payload); + if (Buffer.byteLength(payloadBody, 'utf8') > MAX_PAYLOAD_BYTES) { + return this.finalizeDelivery(webhook, delivery, { + status: 'failed', + errorMessage: 'Payload exceeds 1MB limit', + }); + } + + const headers = { + 'Content-Type': 'application/json', + 'X-SubTrackr-Signature': delivery.signature, + 'X-SubTrackr-Event-Type': delivery.eventType, + 'X-SubTrackr-Event-Id': delivery.eventId, + 'X-SubTrackr-Idempotency-Key': delivery.idempotencyKey, + }; + + let attempt = delivery.attempts; + let lastError: string | undefined; + const maxAttempts = Math.max(1, webhook.retryPolicy.maxRetries + 1); + + while (attempt < maxAttempts) { + attempt += 1; + const attemptAt = now(); + const next: WebhookDelivery = { + ...delivery, + status: attempt === 1 ? 'pending' : 'retrying', + attempts: attempt, + lastAttemptAt: attemptAt, + updatedAt: attemptAt, + }; + this.deliveries.set(delivery.id, next); + + try { + const response = await this.fetchImpl(webhook.url, { + method: 'POST', + headers, + body: payloadBody, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return this.finalizeDelivery(webhook, next, { + status: 'delivered', + responseCode: response.status, + deliveredAt: now(), + }, response); + } catch (error) { + lastError = error instanceof Error ? error.message : 'Webhook delivery failed'; + const isLastAttempt = attempt >= maxAttempts; + const delay = this.computeDelay(webhook.retryPolicy, attempt); + + if (isLastAttempt) { + return this.finalizeDelivery(webhook, next, { + status: 'failed', + errorMessage: lastError, + responseCode: undefined, + }); + } + + const retried: WebhookDelivery = { + ...next, + status: 'retrying', + errorMessage: lastError, + nextRetryAt: now() + delay, + }; + this.deliveries.set(delivery.id, retried); + await this.sleepImpl(delay); + } + } + + return this.finalizeDelivery(webhook, delivery, { + status: 'failed', + errorMessage: lastError ?? 'Webhook delivery failed', + }); + } + + private finalizeDelivery( + webhook: WebhookConfig, + delivery: WebhookDelivery, + patch: Partial & { status: WebhookDeliveryStatus }, + response?: Response + ): WebhookDeliveryResult { + const next: WebhookDelivery = { + ...delivery, + ...patch, + updatedAt: now(), + }; + this.deliveries.set(delivery.id, next); + + const configPatch: Partial = { + updatedAt: next.updatedAt, + successCount: + next.status === 'delivered' ? webhook.successCount + 1 : webhook.successCount, + failureCount: + next.status === 'failed' ? webhook.failureCount + 1 : webhook.failureCount, + lastHealthStatus: + next.status === 'delivered' + ? 'healthy' + : next.status === 'failed' + ? 'degraded' + : webhook.lastHealthStatus, + }; + this.webhooks.set(webhook.id, { ...webhook, ...configPatch }); + + return { delivery: next, response }; + } + + private computeDelay(policy: WebhookRetryPolicy, attempt: number): number { + const factor = policy.backoffFactor ?? DEFAULT_RETRY_POLICY.backoffFactor ?? 2; + const rawDelay = Math.floor(policy.initialDelayMs * Math.pow(factor, Math.max(0, attempt - 1))); + return Math.min(rawDelay, policy.maxDelayMs); + } +} + +export const webhookDeliveryService = new WebhookDeliveryService(); diff --git a/contracts/subscription/Cargo.toml b/contracts/subscription/Cargo.toml index 6776348..d941db7 100644 --- a/contracts/subscription/Cargo.toml +++ b/contracts/subscription/Cargo.toml @@ -7,6 +7,8 @@ description = "SubTrackr subscription implementation contract (Soroban)" [lib] crate-type = ["cdylib", "rlib"] +name = "subtrackr_subscription" +path = "src/lib.rs" [dependencies] soroban-sdk = "21.0.0" diff --git a/contracts/subscription/src/events.rs b/contracts/subscription/src/events.rs new file mode 100644 index 0000000..e522ee4 --- /dev/null +++ b/contracts/subscription/src/events.rs @@ -0,0 +1,59 @@ +use soroban_sdk::{Address, Env}; +use subtrackr_types::{ + Plan, Subscription, SubscriptionStatus, WebhookEventPayload, WebhookEventType, + WebhookPlanSnapshot, WebhookSubscriptionSnapshot, +}; + +pub(crate) fn subscription_snapshot(sub: &Subscription) -> WebhookSubscriptionSnapshot { + WebhookSubscriptionSnapshot { + id: sub.id, + plan_id: sub.plan_id, + subscriber: sub.subscriber.clone(), + status: sub.status.clone(), + started_at: sub.started_at, + last_charged_at: sub.last_charged_at, + next_charge_at: sub.next_charge_at, + total_paid: sub.total_paid, + total_gas_spent: sub.total_gas_spent, + charge_count: sub.charge_count, + paused_at: sub.paused_at, + pause_duration: sub.pause_duration, + refund_requested_amount: sub.refund_requested_amount, + } +} + +pub(crate) fn plan_snapshot(plan: &Plan) -> WebhookPlanSnapshot { + WebhookPlanSnapshot { + id: plan.id, + merchant: plan.merchant.clone(), + name: plan.name.clone(), + price: plan.price, + token: plan.token.clone(), + interval: plan.interval.clone(), + active: plan.active, + subscriber_count: plan.subscriber_count, + created_at: plan.created_at, + } +} + +pub(crate) fn build_payload( + env: &Env, + webhook_id: u64, + event_type: WebhookEventType, + merchant: &Address, + subscription: &Subscription, + plan: &Plan, + previous_status: SubscriptionStatus, +) -> WebhookEventPayload { + WebhookEventPayload { + id: env.ledger().timestamp(), + webhook_id, + event_type, + merchant: merchant.clone(), + occurred_at: env.ledger().timestamp(), + subscription: subscription_snapshot(subscription), + plan: plan_snapshot(plan), + previous_status, + current_status: subscription.status.clone(), + } +} diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 5d4ba7d..df47381 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -1,14 +1,19 @@ #![no_std] use soroban_sdk::{token, Address, Env, IntoVal, String, TryFromVal, Val, Vec}; -use subtrackr_types::{Interval, Plan, StorageKey, Subscription, SubscriptionStatus}; +use subtrackr_types::{ + Interval, Plan, StorageKey, Subscription, SubscriptionStatus, WebhookEventType, +}; + +mod events; +mod webhook; /// Billing interval in seconds. const MAX_PAUSE_DURATION: u64 = 2_592_000; // 30 days -const STORAGE_VERSION: u32 = 2; +const STORAGE_VERSION: u32 = 3; -fn storage_instance_get>( +pub(crate) fn storage_instance_get>( env: &Env, storage: &Address, key: StorageKey, @@ -22,7 +27,12 @@ fn storage_instance_get>( val_opt.map(|val| V::try_from_val(env, &val).unwrap()) } -fn storage_instance_set>(env: &Env, storage: &Address, key: StorageKey, value: V) { +pub(crate) fn storage_instance_set>( + env: &Env, + storage: &Address, + key: StorageKey, + value: V, +) { let val: Val = value.into_val(env); let args: Vec = soroban_sdk::vec![env, key.into_val(env), val]; env.invoke_contract::<()>( @@ -32,7 +42,7 @@ fn storage_instance_set>(env: &Env, storage: &Address, key: ); } -fn storage_instance_remove(env: &Env, storage: &Address, key: StorageKey) { +pub(crate) fn storage_instance_remove(env: &Env, storage: &Address, key: StorageKey) { let args: Vec = soroban_sdk::vec![env, key.into_val(env)]; env.invoke_contract::<()>( storage, @@ -41,7 +51,7 @@ fn storage_instance_remove(env: &Env, storage: &Address, key: StorageKey) { ); } -fn storage_persistent_get>( +pub(crate) fn storage_persistent_get>( env: &Env, storage: &Address, key: StorageKey, @@ -55,7 +65,12 @@ fn storage_persistent_get>( val_opt.map(|val| V::try_from_val(env, &val).unwrap()) } -fn storage_persistent_set>(env: &Env, storage: &Address, key: StorageKey, value: V) { +pub(crate) fn storage_persistent_set>( + env: &Env, + storage: &Address, + key: StorageKey, + value: V, +) { let val: Val = value.into_val(env); let args: Vec = soroban_sdk::vec![env, key.into_val(env), val]; env.invoke_contract::<()>( @@ -65,7 +80,7 @@ fn storage_persistent_set>(env: &Env, storage: &Address, ke ); } -fn storage_persistent_remove(env: &Env, storage: &Address, key: StorageKey) { +pub(crate) fn storage_persistent_remove(env: &Env, storage: &Address, key: StorageKey) { let args: Vec = soroban_sdk::vec![env, key.into_val(env)]; env.invoke_contract::<()>( storage, @@ -74,11 +89,11 @@ fn storage_persistent_remove(env: &Env, storage: &Address, key: StorageKey) { ); } -fn get_admin(env: &Env, storage: &Address) -> Address { +pub(crate) fn get_admin(env: &Env, storage: &Address) -> Address { storage_instance_get(env, storage, StorageKey::Admin).expect("Admin not set") } -fn enforce_rate_limit(env: &Env, storage: &Address, caller: &Address, function_name: &str) { +pub(crate) fn enforce_rate_limit(env: &Env, storage: &Address, caller: &Address, function_name: &str) { let fname = String::from_str(env, function_name); let min_interval: Option = storage_instance_get(env, storage, StorageKey::RateLimit(fname.clone())); @@ -115,7 +130,7 @@ fn enforce_rate_limit(env: &Env, storage: &Address, caller: &Address, function_n ); } -fn check_and_resume_internal(env: &Env, sub: &mut Subscription) -> bool { +pub(crate) fn check_and_resume_internal(env: &Env, sub: &mut Subscription) -> bool { if sub.status == SubscriptionStatus::Paused { let now = env.ledger().timestamp(); if now >= sub.paused_at + sub.pause_duration { @@ -128,7 +143,7 @@ fn check_and_resume_internal(env: &Env, sub: &mut Subscription) -> bool { false } -fn set_user_plan_index( +pub(crate) fn set_user_plan_index( env: &Env, storage: &Address, subscriber: &Address, @@ -143,11 +158,16 @@ fn set_user_plan_index( ); } -fn remove_user_plan_index(env: &Env, storage: &Address, subscriber: &Address, plan_id: u64) { +pub(crate) fn remove_user_plan_index(env: &Env, storage: &Address, subscriber: &Address, plan_id: u64) { storage_persistent_remove(env, storage, StorageKey::UserPlanIndex(subscriber.clone(), plan_id)); } -fn get_user_plan_index(env: &Env, storage: &Address, subscriber: &Address, plan_id: u64) -> Option { +pub(crate) fn get_user_plan_index( + env: &Env, + storage: &Address, + subscriber: &Address, + plan_id: u64, +) -> Option { storage_persistent_get(env, storage, StorageKey::UserPlanIndex(subscriber.clone(), plan_id)) } @@ -186,6 +206,7 @@ impl SubTrackrSubscription { /// Migrate storage from `from_version` to this implementation's `STORAGE_VERSION`. /// /// For v1 -> v2: build `UserPlanIndex` for all active/non-cancelled subscriptions. + /// For v2 -> v3: webhook data is additive and needs no backfill. pub fn migrate(env: Env, proxy: Address, storage: Address, from_version: u32) { proxy.require_auth(); if from_version == STORAGE_VERSION { @@ -193,6 +214,10 @@ impl SubTrackrSubscription { } assert!(from_version < STORAGE_VERSION, "Unsupported migration path"); + if from_version == 2 { + return; + } + if from_version == 1 { let sub_count: u64 = storage_instance_get(&env, &storage, StorageKey::SubscriptionCount) .unwrap_or(0); @@ -332,6 +357,7 @@ impl SubTrackrSubscription { let mut plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(plan_id)) .expect("Plan not found"); + let previous_status = SubscriptionStatus::Cancelled; assert!(plan.active, "Plan is not active"); assert!( plan.merchant != subscriber, @@ -392,6 +418,21 @@ impl SubTrackrSubscription { plan.subscriber_count += 1; storage_persistent_set(&env, &storage, StorageKey::Plan(plan_id), plan); + let subscription: Subscription = + storage_persistent_get(&env, &storage, StorageKey::Subscription(sub_count)) + .expect("Subscription not found"); + let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(plan_id)) + .expect("Plan not found"); + webhook::emit_subscription_event( + &env, + &storage, + &plan.merchant, + WebhookEventType::SubscriptionCreated, + &subscription, + &plan, + previous_status, + ); + sub_count } @@ -405,6 +446,7 @@ impl SubTrackrSubscription { let mut sub: Subscription = storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id)) .expect("Subscription not found"); + let previous_status = sub.status.clone(); assert!(sub.subscriber == subscriber, "Only subscriber can cancel"); assert!( @@ -424,6 +466,18 @@ impl SubTrackrSubscription { plan.subscriber_count -= 1; } storage_persistent_set(&env, &storage, StorageKey::Plan(sub.plan_id), plan); + + let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id)) + .expect("Plan not found"); + webhook::emit_subscription_event( + &env, + &storage, + &plan.merchant, + WebhookEventType::SubscriptionCancelled, + &sub, + &plan, + previous_status, + ); } pub fn pause_subscription( @@ -464,6 +518,7 @@ impl SubTrackrSubscription { let mut sub: Subscription = storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id)) .expect("Subscription not found"); + let previous_status = sub.status.clone(); assert!(sub.subscriber == subscriber, "Only subscriber can pause"); assert!( @@ -485,6 +540,18 @@ impl SubTrackrSubscription { (String::from_str(&env, "subscription_paused"), subscriber), (subscription_id, sub.paused_at, duration), ); + + let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id)) + .expect("Plan not found"); + webhook::emit_subscription_event( + &env, + &storage, + &plan.merchant, + WebhookEventType::SubscriptionPaused, + &sub, + &plan, + previous_status, + ); } pub fn resume_subscription( @@ -503,6 +570,7 @@ impl SubTrackrSubscription { let mut sub: Subscription = storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id)) .expect("Subscription not found"); + let previous_status = sub.status.clone(); assert!(sub.subscriber == subscriber, "Only subscriber can resume"); assert!( @@ -526,6 +594,16 @@ impl SubTrackrSubscription { (String::from_str(&env, "subscription_resumed"), subscriber), subscription_id, ); + + webhook::emit_subscription_event( + &env, + &storage, + &plan.merchant, + WebhookEventType::SubscriptionResumed, + &sub, + &plan, + previous_status, + ); } // ── Payment Processing ── @@ -535,6 +613,7 @@ impl SubTrackrSubscription { let mut sub: Subscription = storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id)) .expect("Subscription not found"); + let previous_status = sub.status.clone(); if sub.subscriber != get_admin(&env, &storage) { enforce_rate_limit(&env, &storage, &sub.subscriber, "charge_subscription"); @@ -576,6 +655,16 @@ impl SubTrackrSubscription { (String::from_str(&env, "subscription_charged"), subscription_id), (sub.subscriber.clone(), plan.price, 100_000u64, now), ); + + webhook::emit_subscription_event( + &env, + &storage, + &plan.merchant, + WebhookEventType::SubscriptionCharged, + &sub, + &plan, + previous_status, + ); } pub fn request_refund( @@ -589,6 +678,7 @@ impl SubTrackrSubscription { let mut sub: Subscription = storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id)) .expect("Subscription not found"); + let previous_status = sub.status.clone(); if sub.subscriber != get_admin(&env, &storage) { enforce_rate_limit(&env, &storage, &sub.subscriber, "request_refund"); @@ -609,6 +699,18 @@ impl SubTrackrSubscription { (String::from_str(&env, "refund_requested"), subscription_id), (sub.subscriber.clone(), amount), ); + + let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id)) + .expect("Plan not found"); + webhook::emit_subscription_event( + &env, + &storage, + &plan.merchant, + WebhookEventType::RefundRequested, + &sub, + &plan, + previous_status, + ); } pub fn approve_refund(env: Env, proxy: Address, storage: Address, subscription_id: u64) { @@ -619,6 +721,7 @@ impl SubTrackrSubscription { let admin = get_admin(&env, &storage); admin.require_auth(); + let previous_status = sub.status.clone(); let amount = sub.refund_requested_amount; assert!(amount > 0, "No pending refund request"); @@ -635,6 +738,18 @@ impl SubTrackrSubscription { (String::from_str(&env, "refund_approved"), subscription_id), (sub.subscriber.clone(), amount), ); + + let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id)) + .expect("Plan not found"); + webhook::emit_subscription_event( + &env, + &storage, + &plan.merchant, + WebhookEventType::RefundApproved, + &sub, + &plan, + previous_status, + ); } pub fn reject_refund(env: Env, proxy: Address, storage: Address, subscription_id: u64) { @@ -645,6 +760,7 @@ impl SubTrackrSubscription { let admin = get_admin(&env, &storage); admin.require_auth(); + let previous_status = sub.status.clone(); assert!(sub.refund_requested_amount > 0, "No pending refund request"); sub.refund_requested_amount = 0; @@ -655,6 +771,18 @@ impl SubTrackrSubscription { (String::from_str(&env, "refund_rejected"), subscription_id), sub.subscriber.clone(), ); + + let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id)) + .expect("Plan not found"); + webhook::emit_subscription_event( + &env, + &storage, + &plan.merchant, + WebhookEventType::RefundRejected, + &sub, + &plan, + previous_status, + ); } // ── Subscription Transfer ── @@ -693,6 +821,18 @@ impl SubTrackrSubscription { (String::from_str(&env, "transfer_requested"), subscription_id), (sub.subscriber.clone(), recipient), ); + + let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id)) + .expect("Plan not found"); + webhook::emit_subscription_event( + &env, + &storage, + &plan.merchant, + WebhookEventType::TransferRequested, + &sub, + &plan, + sub.status.clone(), + ); } pub fn accept_transfer( @@ -711,6 +851,7 @@ impl SubTrackrSubscription { let mut sub: Subscription = storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id)) .expect("Subscription not found"); + let previous_status = sub.status.clone(); let pending_recipient: Address = storage_instance_get(&env, &storage, StorageKey::PendingTransfer(subscription_id)) @@ -767,6 +908,18 @@ impl SubTrackrSubscription { (String::from_str(&env, "transfer_accepted"), subscription_id), (old, recipient), ); + + let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id)) + .expect("Plan not found"); + webhook::emit_subscription_event( + &env, + &storage, + &plan.merchant, + WebhookEventType::TransferAccepted, + &sub, + &plan, + previous_status, + ); } // ── Queries ── diff --git a/contracts/subscription/src/webhook.rs b/contracts/subscription/src/webhook.rs new file mode 100644 index 0000000..caf8dd4 --- /dev/null +++ b/contracts/subscription/src/webhook.rs @@ -0,0 +1,340 @@ +use soroban_sdk::{Address, Env, String, Vec}; +use subtrackr_types::{ + StorageKey, Subscription, SubscriptionStatus, WebhookConfig, WebhookDelivery, + WebhookDeliveryStatus, WebhookEventType, WebhookRetryPolicy, +}; + +use crate::{ + events::build_payload, storage_instance_get, storage_instance_set, storage_persistent_get, + storage_persistent_remove, storage_persistent_set, +}; + +fn webhook_ids_for_merchant(env: &Env, storage: &Address, merchant: &Address) -> Vec { + storage_persistent_get(env, storage, StorageKey::MerchantWebhooks(merchant.clone())) + .unwrap_or(Vec::new(env)) +} + +fn set_webhook_ids_for_merchant(env: &Env, storage: &Address, merchant: &Address, ids: Vec) { + storage_persistent_set(env, storage, StorageKey::MerchantWebhooks(merchant.clone()), ids); +} + +fn deliveries_for_webhook(env: &Env, storage: &Address, webhook_id: u64) -> Vec { + storage_persistent_get(env, storage, StorageKey::WebhookDeliveriesByWebhook(webhook_id)) + .unwrap_or(Vec::new(env)) +} + +fn set_deliveries_for_webhook(env: &Env, storage: &Address, webhook_id: u64, ids: Vec) { + storage_persistent_set( + env, + storage, + StorageKey::WebhookDeliveriesByWebhook(webhook_id), + ids, + ); +} + +fn webhook_supports_event(config: &WebhookConfig, event_type: &WebhookEventType) -> bool { + if config.is_paused { + return false; + } + for configured in config.events.iter() { + if configured == *event_type { + return true; + } + } + false +} + +fn next_webhook_id(env: &Env, storage: &Address) -> u64 { + let mut count: u64 = storage_instance_get(env, storage, StorageKey::WebhookCount).unwrap_or(0); + count += 1; + storage_instance_set(env, storage, StorageKey::WebhookCount, count); + count +} + +fn next_delivery_id(env: &Env, storage: &Address) -> u64 { + let mut count: u64 = + storage_instance_get(env, storage, StorageKey::WebhookDeliveryCount).unwrap_or(0); + count += 1; + storage_instance_set(env, storage, StorageKey::WebhookDeliveryCount, count); + count +} + +fn compute_delay(policy: &WebhookRetryPolicy, attempt: u32) -> u64 { + let mut delay = policy.initial_delay_secs; + let mut i = 1u32; + while i < attempt { + delay = delay.saturating_mul(policy.backoff_factor as u64); + i += 1; + } + if delay > policy.max_delay_secs { + policy.max_delay_secs + } else { + delay + } +} + +pub(crate) fn emit_subscription_event( + env: &Env, + storage: &Address, + merchant: &Address, + event_type: WebhookEventType, + subscription: &Subscription, + plan: &subtrackr_types::Plan, + previous_status: SubscriptionStatus, +) { + let webhook_ids = webhook_ids_for_merchant(env, storage, merchant); + let mut i = 0u32; + while i < webhook_ids.len() { + let webhook_id = webhook_ids.get_unchecked(i); + if let Some(config) = storage_persistent_get::( + env, + storage, + StorageKey::Webhook(webhook_id), + ) { + if !webhook_supports_event(&config, &event_type) { + i += 1; + continue; + } + + let delivery_id = next_delivery_id(env, storage); + let payload = build_payload( + env, + webhook_id, + event_type.clone(), + merchant, + subscription, + plan, + previous_status.clone(), + ); + let delivery = WebhookDelivery { + id: delivery_id, + webhook_id, + event_id: payload.id, + event_type, + payload, + status: if config.is_paused { + WebhookDeliveryStatus::Paused + } else { + WebhookDeliveryStatus::Pending + }, + attempts: 0, + max_attempts: config.retry_policy.max_retries, + next_retry_at: 0, + last_attempt_at: 0, + delivered_at: 0, + response_code: 0, + error_message: String::from_str(env, ""), + signature: String::from_str(env, ""), + created_at: env.ledger().timestamp(), + updated_at: env.ledger().timestamp(), + }; + storage_persistent_set(env, storage, StorageKey::WebhookDelivery(delivery_id), delivery); + + let mut deliveries = deliveries_for_webhook(env, storage, webhook_id); + deliveries.push_back(delivery_id); + set_deliveries_for_webhook(env, storage, webhook_id, deliveries); + } + i += 1; + } +} + +#[soroban_sdk::contractimpl] +impl super::SubTrackrSubscription { + pub fn register_webhook( + env: Env, + proxy: Address, + storage: Address, + mut config: WebhookConfig, + ) -> u64 { + proxy.require_auth(); + config.merchant.require_auth(); + + let id = next_webhook_id(&env, &storage); + config.id = id; + config.created_at = env.ledger().timestamp(); + config.updated_at = config.created_at; + config.health_check_at = 0; + config.healthy = true; + config.success_count = 0; + config.failure_count = 0; + + storage_persistent_set(&env, &storage, StorageKey::Webhook(id), config.clone()); + + let mut ids = webhook_ids_for_merchant(&env, &storage, &config.merchant); + ids.push_back(id); + set_webhook_ids_for_merchant(&env, &storage, &config.merchant, ids); + id + } + + pub fn update_webhook( + env: Env, + proxy: Address, + storage: Address, + id: u64, + mut config: WebhookConfig, + ) { + proxy.require_auth(); + config.merchant.require_auth(); + + let current: WebhookConfig = + storage_persistent_get(&env, &storage, StorageKey::Webhook(id)) + .expect("Webhook not found"); + + assert!(current.merchant == config.merchant, "Webhook merchant mismatch"); + config.id = id; + config.created_at = current.created_at; + config.updated_at = env.ledger().timestamp(); + config.success_count = current.success_count; + config.failure_count = current.failure_count; + config.health_check_at = current.health_check_at; + config.healthy = current.healthy; + + storage_persistent_set(&env, &storage, StorageKey::Webhook(id), config); + } + + pub fn delete_webhook(env: Env, proxy: Address, storage: Address, id: u64) { + proxy.require_auth(); + let config: WebhookConfig = storage_persistent_get(&env, &storage, StorageKey::Webhook(id)) + .expect("Webhook not found"); + config.merchant.require_auth(); + + let ids = webhook_ids_for_merchant(&env, &storage, &config.merchant); + let mut next_ids = Vec::new(&env); + for existing in ids.iter() { + if existing != id { + next_ids.push_back(existing); + } + } + set_webhook_ids_for_merchant(&env, &storage, &config.merchant, next_ids); + storage_persistent_remove( + &env, + &storage, + StorageKey::WebhookDeliveriesByWebhook(id), + ); + storage_persistent_remove(&env, &storage, StorageKey::Webhook(id)); + } + + pub fn pause_webhook(env: Env, proxy: Address, storage: Address, id: u64) { + proxy.require_auth(); + let mut config: WebhookConfig = + storage_persistent_get(&env, &storage, StorageKey::Webhook(id)) + .expect("Webhook not found"); + config.merchant.require_auth(); + config.is_paused = true; + config.updated_at = env.ledger().timestamp(); + storage_persistent_set(&env, &storage, StorageKey::Webhook(id), config); + } + + pub fn resume_webhook(env: Env, proxy: Address, storage: Address, id: u64) { + proxy.require_auth(); + let mut config: WebhookConfig = + storage_persistent_get(&env, &storage, StorageKey::Webhook(id)) + .expect("Webhook not found"); + config.merchant.require_auth(); + config.is_paused = false; + config.updated_at = env.ledger().timestamp(); + storage_persistent_set(&env, &storage, StorageKey::Webhook(id), config); + } + + pub fn list_webhooks(env: Env, proxy: Address, storage: Address, merchant: Address) -> Vec { + proxy.require_auth(); + let ids = webhook_ids_for_merchant(&env, &storage, &merchant); + let mut items = Vec::new(&env); + for webhook_id in ids.iter() { + if let Some(config) = + storage_persistent_get::(&env, &storage, StorageKey::Webhook(webhook_id)) + { + items.push_back(config); + } + } + items + } + + pub fn get_webhook_deliveries( + env: Env, + proxy: Address, + storage: Address, + webhook_id: u64, + limit: u32, + ) -> Vec { + proxy.require_auth(); + let ids = deliveries_for_webhook(&env, &storage, webhook_id); + let mut items = Vec::new(&env); + let mut i = 0u32; + while i < ids.len() && i < limit { + let delivery_id = ids.get_unchecked(i); + if let Some(delivery) = storage_persistent_get::( + &env, + &storage, + StorageKey::WebhookDelivery(delivery_id), + ) { + items.push_back(delivery); + } + i += 1; + } + items + } + + pub fn retry_webhook_delivery(env: Env, proxy: Address, storage: Address, delivery_id: u64) { + proxy.require_auth(); + let mut delivery: WebhookDelivery = + storage_persistent_get(&env, &storage, StorageKey::WebhookDelivery(delivery_id)) + .expect("Webhook delivery not found"); + + let config: WebhookConfig = storage_persistent_get( + &env, + &storage, + StorageKey::Webhook(delivery.webhook_id), + ) + .expect("Webhook not found"); + config.merchant.require_auth(); + + if delivery.attempts > config.retry_policy.max_retries { + delivery.status = WebhookDeliveryStatus::Failed; + } else { + delivery.attempts += 1; + delivery.status = WebhookDeliveryStatus::Retrying; + delivery.last_attempt_at = env.ledger().timestamp(); + delivery.next_retry_at = env.ledger().timestamp() + + compute_delay(&config.retry_policy, delivery.attempts); + } + delivery.updated_at = env.ledger().timestamp(); + storage_persistent_set(&env, &storage, StorageKey::WebhookDelivery(delivery_id), delivery); + } + + pub fn get_webhook_health( + env: Env, + proxy: Address, + storage: Address, + webhook_id: u64, + ) -> WebhookConfig { + proxy.require_auth(); + let mut config: WebhookConfig = + storage_persistent_get(&env, &storage, StorageKey::Webhook(webhook_id)) + .expect("Webhook not found"); + config.merchant.require_auth(); + + let deliveries = deliveries_for_webhook(&env, &storage, webhook_id); + let mut failures = 0u64; + let mut successes = 0u64; + for delivery_id in deliveries.iter() { + if let Some(delivery) = storage_persistent_get::( + &env, + &storage, + StorageKey::WebhookDelivery(delivery_id), + ) { + match delivery.status { + WebhookDeliveryStatus::Delivered => successes += 1, + WebhookDeliveryStatus::Failed => failures += 1, + _ => {} + } + } + } + + config.healthy = failures <= successes; + config.health_check_at = env.ledger().timestamp(); + config.updated_at = config.health_check_at; + storage_persistent_set(&env, &storage, StorageKey::Webhook(webhook_id), config.clone()); + config + } +} diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index f93c984..581e10d 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contracttype, Address, String}; +use soroban_sdk::{contracttype, Address, String, Vec}; /// Billing interval in seconds. #[contracttype] @@ -96,6 +96,127 @@ pub struct UpgradeEvent { pub executed_at: Timestamp, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum WebhookEventType { + SubscriptionCreated, + SubscriptionUpdated, + SubscriptionCancelled, + SubscriptionPaused, + SubscriptionResumed, + SubscriptionCharged, + RefundRequested, + RefundApproved, + RefundRejected, + TransferRequested, + TransferAccepted, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum WebhookDeliveryStatus { + Pending, + Retrying, + Delivered, + Failed, + Paused, + Skipped, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct WebhookRetryPolicy { + pub max_retries: u32, + pub initial_delay_secs: u64, + pub max_delay_secs: u64, + pub backoff_factor: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct WebhookSubscriptionSnapshot { + pub id: u64, + pub plan_id: u64, + pub subscriber: Address, + pub status: SubscriptionStatus, + pub started_at: u64, + pub last_charged_at: u64, + pub next_charge_at: u64, + pub total_paid: i128, + pub total_gas_spent: u64, + pub charge_count: u32, + pub paused_at: u64, + pub pause_duration: u64, + pub refund_requested_amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct WebhookPlanSnapshot { + pub id: u64, + pub merchant: Address, + pub name: String, + pub price: i128, + pub token: Address, + pub interval: Interval, + pub active: bool, + pub subscriber_count: u32, + pub created_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct WebhookConfig { + pub id: u64, + pub merchant: Address, + pub url: String, + pub events: Vec, + pub secret_key: String, + pub retry_policy: WebhookRetryPolicy, + pub is_paused: bool, + pub created_at: u64, + pub updated_at: u64, + pub health_check_at: u64, + pub healthy: bool, + pub success_count: u64, + pub failure_count: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct WebhookEventPayload { + pub id: u64, + pub webhook_id: u64, + pub event_type: WebhookEventType, + pub merchant: Address, + pub occurred_at: u64, + pub subscription: WebhookSubscriptionSnapshot, + pub plan: WebhookPlanSnapshot, + pub previous_status: SubscriptionStatus, + pub current_status: SubscriptionStatus, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct WebhookDelivery { + pub id: u64, + pub webhook_id: u64, + pub event_id: u64, + pub event_type: WebhookEventType, + pub payload: WebhookEventPayload, + pub status: WebhookDeliveryStatus, + pub attempts: u32, + pub max_attempts: u32, + pub next_retry_at: u64, + pub last_attempt_at: u64, + pub delivered_at: u64, + pub response_code: i32, + pub error_message: String, + pub signature: String, + pub created_at: u64, + pub updated_at: u64, +} + /// Storage keys for the proxy contract state. /// /// IMPORTANT: Never reorder existing variants. Append new variants only. @@ -132,6 +253,14 @@ pub enum StorageKey { /// Index: (subscriber, plan_id) -> subscription_id (active/non-cancelled) UserPlanIndex(Address, u64), + // ── Added in storage version 3 ── + WebhookCount, + Webhook(u64), + MerchantWebhooks(Address), + WebhookDeliveryCount, + WebhookDelivery(u64), + WebhookDeliveriesByWebhook(u64), + /// Proxy pointer to the state storage contract. ProxyStorage, } diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 9fd1ca0..2a75f0c 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -21,6 +21,7 @@ import AdminDashboardScreen from '../screens/AdminDashboardScreen'; import { SegmentManagementScreen } from '../screens/SegmentManagementScreen'; import { SegmentDetailScreen } from '../screens/SegmentDetailScreen'; import { GamificationScreen } from '../screens/GamificationScreen'; +import WebhookSettingsScreen from '../screens/WebhookSettingsScreen'; import { colors } from '../utils/constants'; import { RootStackParamList, TabParamList } from './types'; @@ -116,6 +117,11 @@ const SettingsStack = () => ( component={ErrorDashboardScreen} options={{ title: 'Error Dashboard', headerShown: true }} /> + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index acc7ba2..325ae7f 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -18,6 +18,7 @@ export type RootStackParamList = { SegmentManagement: undefined; SegmentDetail: { segmentId: string }; Gamification: undefined; + WebhookSettings: undefined; }; export type TabParamList = { diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 17c763b..80dd489 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -218,6 +218,17 @@ const SettingsScreen: React.FC = () => { > + navigation.navigate('WebhookSettings')} + accessibilityRole="button" + accessibilityLabel="Webhook settings" + accessibilityHint="Opens subscription webhook management"> + Webhooks + + → + + navigation.navigate('AdminDashboard')} diff --git a/src/screens/WebhookSettingsScreen.tsx b/src/screens/WebhookSettingsScreen.tsx new file mode 100644 index 0000000..2ac776e --- /dev/null +++ b/src/screens/WebhookSettingsScreen.tsx @@ -0,0 +1,445 @@ +import React, { useEffect, useState } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + SafeAreaView, + TextInput, + TouchableOpacity, + Alert, +} from 'react-native'; +import { Card } from '../components/common/Card'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { + defaultRetryPolicy, + useWebhookStore, + webhookEventTypes, + webhookStatusLabels, +} from '../store/webhookStore'; +import { WebhookConfig, WebhookEventType } from '../types/webhook'; + +const emptyWebhookForm = { + merchantId: '', + url: '', + secretKey: '', +}; + +const WebhookSettingsScreen: React.FC = () => { + const { + webhooks, + deliveries, + analytics, + registerWebhook, + updateWebhook, + deleteWebhook, + pauseWebhook, + resumeWebhook, + retryDelivery, + refreshAnalytics, + } = useWebhookStore(); + + const [form, setForm] = useState(emptyWebhookForm); + const [selectedEvents, setSelectedEvents] = useState([ + 'subscription.created', + 'subscription.updated', + 'subscription.cancelled', + ]); + const [editingId, setEditingId] = useState(null); + + useEffect(() => { + refreshAnalytics(); + }, [deliveries.length, refreshAnalytics]); + + const resetForm = () => { + setForm(emptyWebhookForm); + setSelectedEvents(['subscription.created', 'subscription.updated', 'subscription.cancelled']); + setEditingId(null); + }; + + const handleSubmit = async () => { + if (!form.merchantId || !form.url || !form.secretKey) { + Alert.alert('Missing fields', 'Merchant, endpoint URL, and secret key are required.'); + return; + } + + if (selectedEvents.length === 0) { + Alert.alert('Select events', 'Choose at least one lifecycle event.'); + return; + } + + if (editingId) { + await updateWebhook(editingId, { + merchantId: form.merchantId, + url: form.url, + secretKey: form.secretKey, + events: selectedEvents, + retryPolicy: defaultRetryPolicy, + } as Partial); + resetForm(); + return; + } + + await registerWebhook({ + merchantId: form.merchantId, + url: form.url, + secretKey: form.secretKey, + events: selectedEvents, + retryPolicy: defaultRetryPolicy, + isPaused: false, + }); + resetForm(); + }; + + const toggleEvent = (eventType: WebhookEventType) => { + setSelectedEvents((current) => + current.includes(eventType) + ? current.filter((item) => item !== eventType) + : [...current, eventType] + ); + }; + + const startEdit = (webhook: WebhookConfig) => { + setEditingId(webhook.id); + setForm({ + merchantId: webhook.merchantId, + url: webhook.url, + secretKey: webhook.secretKey, + }); + setSelectedEvents(webhook.events); + }; + + const onDelete = (id: string) => { + Alert.alert('Delete webhook', 'Remove this webhook configuration?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress: () => deleteWebhook(id) }, + ]); + }; + + return ( + + + + Webhooks + Manage subscription lifecycle notifications + + + + Webhook configuration + setForm((state) => ({ ...state, merchantId }))} + placeholder="Merchant ID" + placeholderTextColor={colors.textSecondary} + style={styles.input} + /> + setForm((state) => ({ ...state, url }))} + placeholder="https://example.com/webhooks/subscriptions" + placeholderTextColor={colors.textSecondary} + autoCapitalize="none" + keyboardType="url" + style={styles.input} + /> + setForm((state) => ({ ...state, secretKey }))} + placeholder="Signing secret" + placeholderTextColor={colors.textSecondary} + secureTextEntry + style={styles.input} + /> + + Events + + {webhookEventTypes.map((eventType) => { + const active = selectedEvents.includes(eventType); + return ( + toggleEvent(eventType)}> + + {eventType} + + + ); + })} + + + + + {editingId ? 'Update webhook' : 'Register webhook'} + + + {editingId ? ( + + Cancel edit + + ) : null} + + + {webhooks.map((webhook) => { + const webhookAnalytics = analytics[webhook.id]; + const latestDeliveries = deliveries + .filter((delivery) => delivery.webhookId === webhook.id) + .slice(-3) + .reverse(); + + return ( + + + + {webhook.url} + Merchant: {webhook.merchantId} + + + {webhook.isPaused ? 'Paused' : 'Active'} + + + + + Events: {webhook.events.join(', ')} + + + + + {webhookAnalytics ? Math.round(webhookAnalytics.successRate * 100) : 0}% + + success rate + + + + (webhook.isPaused ? resumeWebhook(webhook.id) : pauseWebhook(webhook.id))}> + + {webhook.isPaused ? 'Resume' : 'Pause'} + + + startEdit(webhook)}> + Edit + + onDelete(webhook.id)}> + Delete + + + + Recent deliveries + {latestDeliveries.length === 0 ? ( + No deliveries yet. + ) : ( + latestDeliveries.map((delivery) => ( + + + {delivery.eventType} + + {webhookStatusLabels[delivery.status]} · attempts {delivery.attempts}/{delivery.maxAttempts} + + + {delivery.status === 'failed' ? ( + retryDelivery(delivery.id)}> + Retry + + ) : null} + + )) + )} + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + padding: spacing.lg, + gap: spacing.lg, + }, + header: { + marginBottom: spacing.sm, + }, + title: { + ...typography.h1, + color: colors.text, + }, + subtitle: { + color: colors.textSecondary, + marginTop: spacing.xs, + }, + section: { + gap: spacing.md, + }, + sectionTitle: { + ...typography.h3, + color: colors.text, + }, + subsectionTitle: { + color: colors.text, + fontWeight: '600', + marginTop: spacing.sm, + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + color: colors.text, + backgroundColor: colors.surface, + }, + eventGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + eventChip: { + borderWidth: 1, + borderColor: colors.border, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: borderRadius.round, + }, + eventChipActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + eventChipText: { + color: colors.textSecondary, + fontSize: 12, + }, + eventChipTextActive: { + color: colors.text, + }, + primaryButton: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + alignItems: 'center', + }, + primaryButtonText: { + color: colors.text, + fontWeight: '700', + }, + secondaryButton: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + alignItems: 'center', + }, + secondaryButtonText: { + color: colors.textSecondary, + }, + rowBetween: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: spacing.sm, + }, + rowLeft: { + flex: 1, + gap: 4, + }, + webhookTitle: { + color: colors.text, + fontWeight: '700', + }, + webhookMeta: { + color: colors.textSecondary, + fontSize: 12, + }, + statusBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.round, + backgroundColor: colors.success, + }, + statusBadgePaused: { + backgroundColor: colors.warning, + }, + statusText: { + color: colors.text, + fontSize: 12, + fontWeight: '700', + }, + analyticsRow: { + flexDirection: 'row', + alignItems: 'baseline', + gap: spacing.xs, + }, + analyticsValue: { + color: colors.text, + fontSize: 28, + fontWeight: '800', + }, + analyticsLabel: { + color: colors.textSecondary, + }, + actionRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + actionButton: { + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + }, + actionButtonDanger: { + borderWidth: 1, + borderColor: colors.error, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + }, + actionButtonText: { + color: colors.text, + fontWeight: '600', + }, + deliveryRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + paddingVertical: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + deliveryInfo: { + flex: 1, + gap: 2, + }, + deliveryEvent: { + color: colors.text, + fontWeight: '600', + }, + deliveryMeta: { + color: colors.textSecondary, + fontSize: 12, + }, + retryButton: { + backgroundColor: colors.primary, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: borderRadius.md, + }, + retryButtonText: { + color: colors.text, + fontWeight: '700', + }, + emptyText: { + color: colors.textSecondary, + }, +}); + +export default WebhookSettingsScreen; diff --git a/src/store/index.ts b/src/store/index.ts index c0f2289..69cddf4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -3,3 +3,4 @@ export { useTransactionQueueStore } from './transactionQueueStore'; export { useWalletStore } from './walletStore'; export { useNetworkStore } from './networkStore'; export { useCommunityStore } from './communityStore'; +export { useWebhookStore } from './webhookStore'; diff --git a/src/store/webhookStore.ts b/src/store/webhookStore.ts new file mode 100644 index 0000000..08217d8 --- /dev/null +++ b/src/store/webhookStore.ts @@ -0,0 +1,256 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + WebhookAnalytics, + WebhookConfig, + WebhookDelivery, + WebhookDeliveryStatus, + WebhookEventType, + WebhookRetryPolicy, +} from '../types/webhook'; + +const STORAGE_KEY = 'subtrackr-webhooks'; +const DEFAULT_RETRY_POLICY: WebhookRetryPolicy = { + maxRetries: 5, + initialDelayMs: 250, + maxDelayMs: 8_000, + backoffFactor: 2, +}; + +const now = (): number => Date.now(); + +const createId = (prefix: string): string => + `${prefix}_${now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + +const calculateAnalytics = (webhookId: string, deliveries: WebhookDelivery[]): WebhookAnalytics => { + const scoped = deliveries.filter((delivery) => delivery.webhookId === webhookId); + const totalDeliveries = scoped.length; + const successfulDeliveries = scoped.filter((delivery) => delivery.status === 'delivered').length; + const failedDeliveries = scoped.filter((delivery) => delivery.status === 'failed').length; + const pendingDeliveries = scoped.filter((delivery) => + ['pending', 'retrying', 'paused'].includes(delivery.status) + ).length; + const retryCount = scoped.reduce((sum, delivery) => sum + Math.max(0, delivery.attempts - 1), 0); + const avgAttempts = totalDeliveries ? scoped.reduce((sum, delivery) => sum + delivery.attempts, 0) / totalDeliveries : 0; + + return { + webhookId, + totalDeliveries, + successfulDeliveries, + failedDeliveries, + retryCount, + pendingDeliveries, + successRate: totalDeliveries ? successfulDeliveries / totalDeliveries : 0, + avgAttempts, + lastSuccessAt: scoped + .filter((delivery) => delivery.status === 'delivered' && delivery.deliveredAt) + .map((delivery) => delivery.deliveredAt as number) + .sort((a, b) => b - a)[0], + lastFailureAt: scoped + .filter((delivery) => delivery.status === 'failed' && delivery.updatedAt) + .map((delivery) => delivery.updatedAt) + .sort((a, b) => b - a)[0], + }; +}; + +interface WebhookState { + webhooks: WebhookConfig[]; + deliveries: WebhookDelivery[]; + analytics: Record; + isLoading: boolean; + error: string | null; + + registerWebhook: (input: Omit) => Promise; + updateWebhook: (id: string, patch: Partial) => Promise; + deleteWebhook: (id: string) => Promise; + pauseWebhook: (id: string) => Promise; + resumeWebhook: (id: string) => Promise; + recordDelivery: (delivery: Omit) => Promise; + retryDelivery: (deliveryId: string) => Promise; + getWebhookDeliveries: (webhookId: string, limit?: number) => WebhookDelivery[]; + getAnalytics: (webhookId: string) => WebhookAnalytics; + refreshAnalytics: (webhookId?: string) => void; + setWebhookState: (webhooks: WebhookConfig[]) => void; +} + +export const useWebhookStore = create()( + persist( + (set, get) => ({ + webhooks: [], + deliveries: [], + analytics: {}, + isLoading: false, + error: null, + + registerWebhook: async (input) => { + const webhook: WebhookConfig = { + ...input, + id: createId('whk'), + createdAt: now(), + updatedAt: now(), + successCount: 0, + failureCount: 0, + }; + set((state) => ({ + webhooks: [...state.webhooks, webhook], + analytics: { + ...state.analytics, + [webhook.id]: calculateAnalytics(webhook.id, state.deliveries), + }, + })); + return webhook; + }, + + updateWebhook: async (id, patch) => { + const current = get().webhooks.find((webhook) => webhook.id === id); + if (!current) throw new Error(`Webhook ${id} not found`); + + const next: WebhookConfig = { + ...current, + ...patch, + id, + updatedAt: now(), + }; + + set((state) => ({ + webhooks: state.webhooks.map((webhook) => (webhook.id === id ? next : webhook)), + analytics: { + ...state.analytics, + [id]: calculateAnalytics(id, state.deliveries), + }, + })); + return next; + }, + + deleteWebhook: async (id) => { + set((state) => ({ + webhooks: state.webhooks.filter((webhook) => webhook.id !== id), + deliveries: state.deliveries.filter((delivery) => delivery.webhookId !== id), + analytics: Object.fromEntries( + Object.entries(state.analytics).filter(([webhookId]) => webhookId !== id) + ), + })); + }, + + pauseWebhook: async (id) => get().updateWebhook(id, { isPaused: true }), + + resumeWebhook: async (id) => get().updateWebhook(id, { isPaused: false }), + + recordDelivery: async (delivery) => { + const record: WebhookDelivery = { + ...delivery, + id: createId('del'), + createdAt: now(), + updatedAt: now(), + }; + + set((state) => { + const nextDeliveries = [...state.deliveries, record]; + return { + deliveries: nextDeliveries, + analytics: { + ...state.analytics, + [record.webhookId]: calculateAnalytics(record.webhookId, nextDeliveries), + }, + }; + }); + return record; + }, + + retryDelivery: async (deliveryId) => { + const current = get().deliveries.find((delivery) => delivery.id === deliveryId); + if (!current) throw new Error(`Delivery ${deliveryId} not found`); + + const next: WebhookDelivery = { + ...current, + status: 'retrying', + attempts: current.attempts + 1, + lastAttemptAt: now(), + nextRetryAt: now(), + updatedAt: now(), + }; + + set((state) => { + const nextDeliveries = state.deliveries.map((delivery) => + delivery.id === deliveryId ? next : delivery + ); + return { + deliveries: nextDeliveries, + analytics: { + ...state.analytics, + [next.webhookId]: calculateAnalytics(next.webhookId, nextDeliveries), + }, + }; + }); + return next; + }, + + getWebhookDeliveries: (webhookId, limit = 25) => + get() + .deliveries.filter((delivery) => delivery.webhookId === webhookId) + .slice(-Math.max(0, limit)), + + getAnalytics: (webhookId) => { + const analytics = calculateAnalytics(webhookId, get().deliveries); + set((state) => ({ + analytics: { + ...state.analytics, + [webhookId]: analytics, + }, + })); + return analytics; + }, + + refreshAnalytics: (webhookId) => { + if (webhookId) { + get().getAnalytics(webhookId); + return; + } + + const nextAnalytics: Record = {}; + for (const webhook of get().webhooks) { + nextAnalytics[webhook.id] = calculateAnalytics(webhook.id, get().deliveries); + } + set({ analytics: nextAnalytics }); + }, + + setWebhookState: (webhooks) => { + set({ webhooks }); + }, + }), + { + name: STORAGE_KEY, + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + webhooks: state.webhooks, + deliveries: state.deliveries, + analytics: state.analytics, + }), + } + ) +); + +export const webhookEventTypes: WebhookEventType[] = [ + 'subscription.created', + 'subscription.updated', + 'subscription.cancelled', + 'subscription.paused', + 'subscription.resumed', + 'subscription.charged', + 'subscription.refund_requested', + 'subscription.refund_approved', + 'subscription.refund_rejected', + 'subscription.transfer_requested', + 'subscription.transfer_accepted', +]; + +export const defaultRetryPolicy = DEFAULT_RETRY_POLICY; +export const webhookStatusLabels: Record = { + pending: 'Pending', + retrying: 'Retrying', + delivered: 'Delivered', + failed: 'Failed', + paused: 'Paused', + skipped: 'Skipped', +}; diff --git a/src/types/webhook.ts b/src/types/webhook.ts new file mode 100644 index 0000000..cdc30ef --- /dev/null +++ b/src/types/webhook.ts @@ -0,0 +1,120 @@ +export type WebhookEventType = + | 'subscription.created' + | 'subscription.updated' + | 'subscription.cancelled' + | 'subscription.paused' + | 'subscription.resumed' + | 'subscription.charged' + | 'subscription.refund_requested' + | 'subscription.refund_approved' + | 'subscription.refund_rejected' + | 'subscription.transfer_requested' + | 'subscription.transfer_accepted'; + +export interface WebhookRetryPolicy { + maxRetries: number; + initialDelayMs: number; + maxDelayMs: number; + backoffFactor?: number; +} + +export interface WebhookConfig { + id: string; + merchantId: string; + url: string; + events: WebhookEventType[]; + secretKey: string; + retryPolicy: WebhookRetryPolicy; + isPaused: boolean; + createdAt: number; + updatedAt: number; + lastHealthCheckAt?: number; + lastHealthStatus?: 'healthy' | 'degraded' | 'unhealthy'; + successCount: number; + failureCount: number; +} + +export interface WebhookSubscriptionSnapshot { + id: string; + planId: string; + subscriberId: string; + status: string; + startedAt: number; + lastChargedAt: number; + nextChargeAt: number; + totalPaid: number; + totalGasSpent: number; + chargeCount: number; + pausedAt: number; + pauseDuration: number; + refundRequestedAmount: number; +} + +export interface WebhookPlanSnapshot { + id: string; + merchantId: string; + name: string; + price: number; + token: string; + interval: BillingCycle; + active: boolean; + subscriberCount: number; + createdAt: number; +} + +export interface WebhookEventPayload { + id: string; + webhookId: string; + eventType: WebhookEventType; + occurredAt: number; + merchantId: string; + subscription: WebhookSubscriptionSnapshot; + plan: WebhookPlanSnapshot; + previousStatus: string; + currentStatus: string; + payloadVersion: number; +} + +export type WebhookDeliveryStatus = + | 'pending' + | 'retrying' + | 'delivered' + | 'failed' + | 'paused' + | 'skipped'; + +export interface WebhookDelivery { + id: string; + webhookId: string; + eventId: string; + eventType: WebhookEventType; + url: string; + payload: WebhookEventPayload; + status: WebhookDeliveryStatus; + attempts: number; + maxAttempts: number; + createdAt: number; + updatedAt: number; + lastAttemptAt?: number; + deliveredAt?: number; + nextRetryAt?: number; + responseCode?: number; + errorMessage?: string; + signature: string; + idempotencyKey: string; + latencyMs?: number; +} + +export interface WebhookAnalytics { + webhookId: string; + totalDeliveries: number; + successfulDeliveries: number; + failedDeliveries: number; + retryCount: number; + pendingDeliveries: number; + successRate: number; + avgAttempts: number; + lastSuccessAt?: number; + lastFailureAt?: number; +} +import { BillingCycle } from './subscription'; diff --git a/src/utils/webhook.ts b/src/utils/webhook.ts new file mode 100644 index 0000000..cb8526f --- /dev/null +++ b/src/utils/webhook.ts @@ -0,0 +1,70 @@ +import { BillingCycle, Subscription } from '../types/subscription'; +import { + WebhookEventPayload, + WebhookEventType, + WebhookPlanSnapshot, + WebhookSubscriptionSnapshot, +} from '../types/webhook'; + +export interface WebhookPlanLike { + id: string; + merchantId: string; + name: string; + price: number; + token: string; + interval: BillingCycle; + active: boolean; + subscriberCount: number; + createdAt: number; +} + +export const toWebhookSubscriptionSnapshot = (subscription: Subscription): WebhookSubscriptionSnapshot => ({ + id: subscription.id, + planId: subscription.id, + subscriberId: subscription.id, + status: subscription.isActive ? 'active' : 'inactive', + startedAt: subscription.createdAt.getTime(), + lastChargedAt: subscription.updatedAt.getTime(), + nextChargeAt: subscription.nextBillingDate.getTime(), + totalPaid: subscription.price, + totalGasSpent: subscription.totalGasSpent ?? 0, + chargeCount: subscription.chargeCount ?? 0, + pausedAt: 0, + pauseDuration: 0, + refundRequestedAmount: 0, +}); + +export const toWebhookPlanSnapshot = (plan: WebhookPlanLike): WebhookPlanSnapshot => ({ + id: plan.id, + merchantId: plan.merchantId, + name: plan.name, + price: plan.price, + token: plan.token, + interval: plan.interval, + active: plan.active, + subscriberCount: plan.subscriberCount, + createdAt: plan.createdAt, +}); + +export const buildWebhookEventPayload = (input: { + id: string; + webhookId: string; + merchantId: string; + eventType: WebhookEventType; + subscription: Subscription; + plan: WebhookPlanLike; + previousStatus: string; + currentStatus: string; + occurredAt?: number; +}): WebhookEventPayload => ({ + id: input.id, + webhookId: input.webhookId, + eventType: input.eventType, + occurredAt: input.occurredAt ?? Date.now(), + merchantId: input.merchantId, + subscription: toWebhookSubscriptionSnapshot(input.subscription), + plan: toWebhookPlanSnapshot(input.plan), + previousStatus: input.previousStatus, + currentStatus: input.currentStatus, + payloadVersion: 1, +});