diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6eed883..fc11c6f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,9 +11,8 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { ThrottlerRedisGuard } from './rate-limiter/guards/throttler-redis.guard'; import { TransactionsModule } from './transactions/transactions.module'; import { WorkerModule } from './modules/worker/worker.module'; -import { PaymentsModule } from './payments/payments.module.js'; -import { PrismaModule } from './prisma/prisma.module.js'; -import { WebhooksModule } from './webhooks/webhooks.module.js'; +import { WebhookModule } from './webhooks/webhook.module'; +import { PaymentsModule } from './payments/payments.module'; @Module({ imports: [ @@ -23,9 +22,8 @@ import { WebhooksModule } from './webhooks/webhooks.module.js'; AuthModule, TransactionsModule, WorkerModule, + WebhookModule, PaymentsModule, - PrismaModule, - WebhooksModule, ThrottlerModule.forRoot({ throttlers: [ { name: 'short', ttl: 60000, limit: 100 }, diff --git a/apps/api/src/payments/payments.module.ts b/apps/api/src/payments/payments.module.ts index e5231fc..954748b 100644 --- a/apps/api/src/payments/payments.module.ts +++ b/apps/api/src/payments/payments.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { PaymentsController } from './payments.controller.js'; import { PaymentsService } from './payments.service.js'; +import { WebhookModule } from '../webhooks/webhook.module'; import { DepositAddressService } from './deposit-address.service.js'; @Module({ + imports: [WebhookModule], controllers: [PaymentsController], providers: [PaymentsService, DepositAddressService], }) diff --git a/apps/api/src/payments/payments.service.spec.ts b/apps/api/src/payments/payments.service.spec.ts new file mode 100644 index 0000000..3ce8cb4 --- /dev/null +++ b/apps/api/src/payments/payments.service.spec.ts @@ -0,0 +1,197 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { PaymentsService } from './payments.service'; +import { WebhookService } from '../webhooks/webhook.service'; +import { WebhookEventType } from '../webhooks/interfaces/webhook-event.interface'; +import { Currency } from './enums/currency.enum'; + +const mockWebhookService = (): jest.Mocked => + ({ + dispatchEvent: jest.fn().mockResolvedValue(undefined), + }) as unknown as jest.Mocked; + +describe('PaymentsService – webhook event dispatch', () => { + let service: PaymentsService; + let webhookSvc: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PaymentsService, { provide: WebhookService, useFactory: mockWebhookService }], + }).compile(); + + service = module.get(PaymentsService); + webhookSvc = module.get(WebhookService); + }); + + afterEach(() => jest.clearAllMocks()); + + // ─── payment.created ────────────────────────────────────────────────────── + + describe('createPaymentIntent', () => { + it('returns a payment with status "pending"', () => { + const payment = service.createPaymentIntent( + { amount: 50, currency: Currency.USDC }, + 'merchant-1', + ); + + expect(payment.id).toBeDefined(); + expect(payment.status).toBe('pending'); + expect(payment.merchantId).toBe('merchant-1'); + }); + + it('dispatches payment.created event with standardised payload', () => { + const payment = service.createPaymentIntent( + { amount: 100, currency: Currency.USDC, metadata: { ref: 'ord-9' } }, + 'merchant-1', + ); + + expect(webhookSvc.dispatchEvent).toHaveBeenCalledTimes(1); + expect(webhookSvc.dispatchEvent).toHaveBeenCalledWith( + 'merchant-1', + WebhookEventType.PAYMENT_CREATED, + expect.objectContaining({ + payment_id: payment.id, + amount: 100, + currency: 'USDC', + status: 'pending', + }), + ); + }); + + it('payload contains created_at timestamp', () => { + service.createPaymentIntent({ amount: 1, currency: Currency.USDC }, 'merchant-1'); + + const [, , data] = webhookSvc.dispatchEvent.mock.calls[0] as [ + string, + WebhookEventType, + Record, + ]; + expect(typeof data['created_at']).toBe('string'); + }); + }); + + // ─── payment.detected ───────────────────────────────────────────────────── + + describe('markDetected', () => { + it('updates status to "detected" and dispatches payment.detected', () => { + const payment = service.createPaymentIntent( + { amount: 10, currency: Currency.USDC }, + 'merchant-2', + ); + jest.clearAllMocks(); + + const updated = service.markDetected(payment.id); + expect(updated.status).toBe('detected'); + + expect(webhookSvc.dispatchEvent).toHaveBeenCalledWith( + 'merchant-2', + WebhookEventType.PAYMENT_DETECTED, + expect.objectContaining({ payment_id: payment.id, status: 'detected' }), + ); + }); + + it('throws NotFoundException for an unknown payment id', () => { + expect(() => service.markDetected('no-such-id')).toThrow(NotFoundException); + }); + }); + + // ─── payment.confirmed ──────────────────────────────────────────────────── + + describe('markConfirmed', () => { + it('updates status to "confirmed" and dispatches payment.confirmed', () => { + const payment = service.createPaymentIntent( + { amount: 25, currency: Currency.XLM }, + 'merchant-3', + ); + jest.clearAllMocks(); + + const updated = service.markConfirmed(payment.id); + expect(updated.status).toBe('confirmed'); + + expect(webhookSvc.dispatchEvent).toHaveBeenCalledWith( + 'merchant-3', + WebhookEventType.PAYMENT_CONFIRMED, + expect.objectContaining({ payment_id: payment.id, status: 'confirmed' }), + ); + }); + + it('payload contains updated_at timestamp', () => { + const payment = service.createPaymentIntent( + { amount: 5, currency: Currency.USDC }, + 'merchant-1', + ); + jest.clearAllMocks(); + service.markConfirmed(payment.id); + + const [, , data] = webhookSvc.dispatchEvent.mock.calls[0] as [ + string, + WebhookEventType, + Record, + ]; + expect(typeof data['updated_at']).toBe('string'); + }); + + it('throws NotFoundException for an unknown payment id', () => { + expect(() => service.markConfirmed('no-such-id')).toThrow(NotFoundException); + }); + }); + + // ─── payment.failed ─────────────────────────────────────────────────────── + + describe('markFailed', () => { + it('updates status to "failed" and dispatches payment.failed', () => { + const payment = service.createPaymentIntent( + { amount: 75, currency: Currency.USDC }, + 'merchant-4', + ); + jest.clearAllMocks(); + + const updated = service.markFailed(payment.id); + expect(updated.status).toBe('failed'); + + expect(webhookSvc.dispatchEvent).toHaveBeenCalledWith( + 'merchant-4', + WebhookEventType.PAYMENT_FAILED, + expect.objectContaining({ payment_id: payment.id, status: 'failed' }), + ); + }); + + it('throws NotFoundException for an unknown payment id', () => { + expect(() => service.markFailed('no-such-id')).toThrow(NotFoundException); + }); + }); + + // ─── payload shape ──────────────────────────────────────────────────────── + + describe('dispatch payload shape', () => { + it('dispatches exactly once per lifecycle transition', () => { + const payment = service.createPaymentIntent( + { amount: 10, currency: Currency.USDC }, + 'merchant-1', + ); + jest.clearAllMocks(); + + service.markDetected(payment.id); + expect(webhookSvc.dispatchEvent).toHaveBeenCalledTimes(1); + }); + + it('merchant id is forwarded correctly in every dispatch', () => { + const merchantId = 'merchant-xyz'; + const payment = service.createPaymentIntent( + { amount: 10, currency: Currency.USDC }, + merchantId, + ); + + const calls = webhookSvc.dispatchEvent.mock.calls as [string, ...unknown[]][]; + expect(calls[0][0]).toBe(merchantId); + + jest.clearAllMocks(); + service.markDetected(payment.id); + service.markConfirmed(payment.id); + service.markFailed(payment.id); + + const allCalls = webhookSvc.dispatchEvent.mock.calls as [string, ...unknown[]][]; + allCalls.forEach(([mid]) => expect(mid).toBe(merchantId)); + }); + }); +}); diff --git a/apps/api/src/payments/payments.service.ts b/apps/api/src/payments/payments.service.ts index 99b06ec..a125ad8 100644 --- a/apps/api/src/payments/payments.service.ts +++ b/apps/api/src/payments/payments.service.ts @@ -1,5 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { type CreatePaymentIntentDto } from './dto/create-payment-intent.dto.js'; +import { WebhookService } from '../webhooks/webhook.service'; +import { WebhookEventType } from '../webhooks/interfaces/webhook-event.interface'; + +export type PaymentStatus = 'pending' | 'detected' | 'confirmed' | 'failed'; export interface StoredIntent { paymentId: string; @@ -9,8 +13,9 @@ export interface StoredIntent { currency: string; reference?: string; metadata?: Record; - status: 'pending'; + status: PaymentStatus; createdAt: string; + updatedAt: string; expiresAt: string; } @@ -23,21 +28,27 @@ export interface CreatePaymentIntentResponse { expires_at: string; } +// Alias used by tests and other services +export type PaymentIntent = StoredIntent & { id: string }; + @Injectable() export class PaymentsService { - private readonly intents: Map = new Map(); + private readonly payments: StoredIntent[] = []; + + constructor(private readonly webhookService: WebhookService) {} createPaymentIntent( dto: CreatePaymentIntentDto, merchantId: string, - ): CreatePaymentIntentResponse { - const paymentId = `pay_${crypto.randomUUID().split('-').join('').slice(0, 16)}`; - const paymentReference = `PAY-${Date.now()}-${crypto.randomUUID().split('-').join('').slice(0, 8).toUpperCase()}`; + ): StoredIntent & { id: string } { const now = new Date(); const expiresAt = new Date(now.getTime() + 60 * 60 * 1000); + const id = crypto.randomUUID(); + const paymentReference = `PAY-${Date.now()}-${crypto.randomUUID().split('-').join('').slice(0, 8).toUpperCase()}`; - const intent: StoredIntent = { - paymentId, + const payment: StoredIntent & { id: string } = { + id, + paymentId: id, paymentReference, merchantId, amount: dto.amount, @@ -46,22 +57,62 @@ export class PaymentsService { metadata: dto.metadata, status: 'pending', createdAt: now.toISOString(), + updatedAt: now.toISOString(), expiresAt: expiresAt.toISOString(), }; - this.intents.set(paymentId, intent); + this.payments.push(payment); - return { - payment_id: paymentId, - payment_reference: paymentReference, - checkout_url: `https://checkout.stellarpay.io/pay/${paymentReference}`, - status: 'pending', - created_at: now.toISOString(), - expires_at: expiresAt.toISOString(), - }; + void this.webhookService.dispatchEvent(merchantId, WebhookEventType.PAYMENT_CREATED, { + payment_id: payment.id, + amount: payment.amount, + currency: payment.currency, + status: payment.status, + metadata: payment.metadata, + created_at: payment.createdAt, + }); + + return payment; + } + + markDetected(id: string): StoredIntent & { id: string } { + return this.updateStatus(id, 'detected', WebhookEventType.PAYMENT_DETECTED); + } + + markConfirmed(id: string): StoredIntent & { id: string } { + return this.updateStatus(id, 'confirmed', WebhookEventType.PAYMENT_CONFIRMED); + } + + markFailed(id: string): StoredIntent & { id: string } { + return this.updateStatus(id, 'failed', WebhookEventType.PAYMENT_FAILED); } findOne(paymentId: string): StoredIntent | undefined { - return this.intents.get(paymentId); + return this.payments.find((p) => p.paymentId === paymentId); + } + + private updateStatus( + id: string, + status: PaymentStatus, + event: WebhookEventType, + ): StoredIntent & { id: string } { + const payment = this.payments.find((p) => p.paymentId === id) as + | (StoredIntent & { id: string }) + | undefined; + if (!payment) throw new NotFoundException(`Payment ${id} not found`); + + payment.status = status; + payment.updatedAt = new Date().toISOString(); + + void this.webhookService.dispatchEvent(payment.merchantId, event, { + payment_id: payment.id, + amount: payment.amount, + currency: payment.currency, + status: payment.status, + metadata: payment.metadata, + updated_at: payment.updatedAt, + }); + + return payment; } } diff --git a/apps/api/src/transactions/transactions.controller.ts b/apps/api/src/transactions/transactions.controller.ts index ad0afa5..09fa436 100644 --- a/apps/api/src/transactions/transactions.controller.ts +++ b/apps/api/src/transactions/transactions.controller.ts @@ -6,6 +6,8 @@ import { Param, Post, } from '@nestjs/common'; +import { CurrentMerchant } from '../auth/decorators/current-merchant.decorator'; +import { type MerchantUser } from '../auth/interfaces/merchant-user.interface'; import { TransactionNetwork } from './interfaces/transaction.interface'; import { TransactionsService } from './transactions.service'; @@ -23,8 +25,11 @@ export class TransactionsController { * Body: { hash: string, network: "STELLAR" | "BTC" | "ETH" } */ @Post() - register(@Body() dto: RegisterTransactionDto) { - return this.transactionsService.register(dto.hash, dto.network); + register( + @Body() dto: RegisterTransactionDto, + @CurrentMerchant() merchant: MerchantUser, + ) { + return this.transactionsService.register(dto.hash, dto.network, merchant.merchant_id); } /** List all tracked transactions. */ diff --git a/apps/api/src/transactions/transactions.module.ts b/apps/api/src/transactions/transactions.module.ts index d6b2726..1050dee 100644 --- a/apps/api/src/transactions/transactions.module.ts +++ b/apps/api/src/transactions/transactions.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { TransactionMonitorService } from './transaction-monitor.service'; import { TransactionsController } from './transactions.controller'; import { TransactionsService } from './transactions.service'; +import { WebhookModule } from '../webhooks/webhook.module'; @Module({ + imports: [WebhookModule], controllers: [TransactionsController], providers: [TransactionsService, TransactionMonitorService], exports: [TransactionsService], diff --git a/apps/api/src/transactions/transactions.service.ts b/apps/api/src/transactions/transactions.service.ts index d047b4b..39bcbd3 100644 --- a/apps/api/src/transactions/transactions.service.ts +++ b/apps/api/src/transactions/transactions.service.ts @@ -5,12 +5,17 @@ import { TransactionNetwork, TransactionStatus, } from './interfaces/transaction.interface'; +import { WebhookService } from '../webhooks/webhook.service'; +import { WebhookEventType } from '../webhooks/interfaces/webhook-event.interface'; @Injectable() export class TransactionsService { private readonly store = new Map(); + private readonly merchantIdMap = new Map(); // tx.id -> merchantId - register(hash: string, network: TransactionNetwork): Transaction { + constructor(private readonly webhookService: WebhookService) {} + + register(hash: string, network: TransactionNetwork, merchantId?: string): Transaction { const tx: Transaction = { id: crypto.randomUUID(), network, @@ -21,6 +26,11 @@ export class TransactionsService { created_at: new Date().toISOString(), }; this.store.set(tx.id, tx); + + if (merchantId) { + this.merchantIdMap.set(tx.id, merchantId); + } + return tx; } @@ -43,13 +53,74 @@ export class TransactionsService { const tx = this.store.get(id); if (!tx) return undefined; + const previousStatus = tx.status; tx.confirmations = confirmations; if (confirmations >= tx.required_confirmations) { tx.status = TransactionStatus.CONFIRMED; tx.confirmed_at ??= new Date().toISOString(); + + // Dispatch payment.confirmed webhook event + const merchantId = this.merchantIdMap.get(id); + if (merchantId) { + void this.webhookService.dispatchEvent( + merchantId, + WebhookEventType.PAYMENT_CONFIRMED, + { + transaction_id: tx.id, + network: tx.network, + hash: tx.hash, + status: tx.status, + confirmations: tx.confirmations, + confirmed_at: tx.confirmed_at, + }, + ); + } } else if (confirmations > 0 && tx.status === TransactionStatus.PENDING) { tx.status = TransactionStatus.CONFIRMING; + + // Dispatch payment.detected webhook event (first confirmation) + if (previousStatus === TransactionStatus.PENDING) { + const merchantId = this.merchantIdMap.get(id); + if (merchantId) { + void this.webhookService.dispatchEvent( + merchantId, + WebhookEventType.PAYMENT_DETECTED, + { + transaction_id: tx.id, + network: tx.network, + hash: tx.hash, + status: tx.status, + confirmations: tx.confirmations, + }, + ); + } + } + } + + return tx; + } + + markFailed(id: string, reason: string): Transaction | undefined { + const tx = this.store.get(id); + if (!tx) return undefined; + + tx.status = TransactionStatus.FAILED; + + // Dispatch payment.failed webhook event + const merchantId = this.merchantIdMap.get(id); + if (merchantId) { + void this.webhookService.dispatchEvent( + merchantId, + WebhookEventType.PAYMENT_FAILED, + { + transaction_id: tx.id, + network: tx.network, + hash: tx.hash, + status: tx.status, + reason, + }, + ); } return tx; diff --git a/apps/api/src/webhooks/dto/create-webhook.dto.ts b/apps/api/src/webhooks/dto/create-webhook.dto.ts new file mode 100644 index 0000000..d880cba --- /dev/null +++ b/apps/api/src/webhooks/dto/create-webhook.dto.ts @@ -0,0 +1,15 @@ +import { IsArray, IsBoolean, IsEnum, IsOptional, IsUrl } from 'class-validator'; +import { WebhookEventType } from '../interfaces/webhook-event.interface'; + +export class CreateWebhookDto { + @IsUrl({ require_tld: false }) + url!: string; + + @IsArray() + @IsEnum(WebhookEventType, { each: true }) + events!: WebhookEventType[]; + + @IsOptional() + @IsBoolean() + enabled?: boolean; +} diff --git a/apps/api/src/webhooks/dto/update-webhook.dto.ts b/apps/api/src/webhooks/dto/update-webhook.dto.ts new file mode 100644 index 0000000..cf98369 --- /dev/null +++ b/apps/api/src/webhooks/dto/update-webhook.dto.ts @@ -0,0 +1,17 @@ +import { IsArray, IsBoolean, IsEnum, IsOptional, IsUrl } from 'class-validator'; +import { WebhookEventType } from '../interfaces/webhook-event.interface'; + +export class UpdateWebhookDto { + @IsOptional() + @IsUrl({ require_tld: false }) + url?: string; + + @IsOptional() + @IsArray() + @IsEnum(WebhookEventType, { each: true }) + events?: WebhookEventType[]; + + @IsOptional() + @IsBoolean() + enabled?: boolean; +} diff --git a/apps/api/src/webhooks/interfaces/webhook-config.interface.ts b/apps/api/src/webhooks/interfaces/webhook-config.interface.ts new file mode 100644 index 0000000..62910e7 --- /dev/null +++ b/apps/api/src/webhooks/interfaces/webhook-config.interface.ts @@ -0,0 +1,24 @@ +import { WebhookEventType } from './webhook-event.interface'; + +export interface WebhookConfig { + id: string; + merchant_id: string; + url: string; + secret: string; + events: WebhookEventType[]; + enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateWebhookConfigDto { + url: string; + events: WebhookEventType[]; + enabled?: boolean; +} + +export interface UpdateWebhookConfigDto { + url?: string; + events?: WebhookEventType[]; + enabled?: boolean; +} diff --git a/apps/api/src/webhooks/interfaces/webhook-event.interface.ts b/apps/api/src/webhooks/interfaces/webhook-event.interface.ts index 43ad7e1..58807f6 100644 --- a/apps/api/src/webhooks/interfaces/webhook-event.interface.ts +++ b/apps/api/src/webhooks/interfaces/webhook-event.interface.ts @@ -1,27 +1,17 @@ -export interface WebhookEvent { - webhookId: string; - merchantId: string; - url: string; - secret: string; - type: string; - payload: Record; - createdAt: string; +export enum WebhookEventType { + PAYMENT_CREATED = 'payment.created', + PAYMENT_DETECTED = 'payment.detected', + PAYMENT_CONFIRMED = 'payment.confirmed', + PAYMENT_FAILED = 'payment.failed', } -export interface WebhookDelivery { - webhookId: string; - eventId: string; - merchantId: string; - url: string; - type: string; - attempt: number; - status: 'success' | 'failed'; - statusCode?: number; - error?: string; - deliveredAt: string; +export interface WebhookEventPayload { + event: WebhookEventType; + data: Record; + timestamp: string; } -export interface WebhookEndpointRecord { +export interface WebhookDeliveryAttempt { id: string; merchantId: string; url: string; @@ -35,9 +25,11 @@ export interface WebhookJobData { eventId: string; merchantId: string; url: string; - secret: string; - type: string; - payload: Record; - attempt: number; - maxAttempts: number; + status: 'pending' | 'success' | 'failed'; + response_code?: number; + response_body?: string; + error_message?: string; + attempt_number: number; + created_at: string; + delivered_at?: string; } diff --git a/apps/api/src/webhooks/webhook.controller.spec.ts b/apps/api/src/webhooks/webhook.controller.spec.ts new file mode 100644 index 0000000..9c3746f --- /dev/null +++ b/apps/api/src/webhooks/webhook.controller.spec.ts @@ -0,0 +1,186 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { WebhookController } from './webhook.controller'; +import { WebhookService } from './webhook.service'; +import { WebhookEventType } from './interfaces/webhook-event.interface'; +import type { WebhookConfig } from './interfaces/webhook-config.interface'; +import type { CreateWebhookDto } from './dto/create-webhook.dto'; +import type { UpdateWebhookDto } from './dto/update-webhook.dto'; +import type { MerchantUser } from '../auth/interfaces/merchant-user.interface'; + +const MERCHANT_ID = 'merchant-test-1'; +const OTHER_MERCHANT_ID = 'merchant-other'; + +const merchant = (): MerchantUser => ({ merchant_id: MERCHANT_ID }) as MerchantUser; + +const makeConfig = (overrides: Partial = {}): WebhookConfig => ({ + id: 'wh-abc', + merchant_id: MERCHANT_ID, + url: 'https://example.com/hook', + secret: 'secret-xyz', + events: [WebhookEventType.PAYMENT_CREATED, WebhookEventType.PAYMENT_CONFIRMED], + enabled: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, +}); + +const mockService = (): jest.Mocked => + ({ + createWebhook: jest.fn(), + listWebhooks: jest.fn(), + getWebhook: jest.fn(), + updateWebhook: jest.fn(), + deleteWebhook: jest.fn(), + getDeliveryAttempts: jest.fn(), + dispatchEvent: jest.fn(), + }) as unknown as jest.Mocked; + +describe('WebhookController', () => { + let controller: WebhookController; + let svc: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [WebhookController], + providers: [{ provide: WebhookService, useFactory: mockService }], + }).compile(); + + controller = module.get(WebhookController); + svc = module.get(WebhookService); + }); + + afterEach(() => jest.clearAllMocks()); + + // ─── POST /webhooks ──────────────────────────────────────────────────────── + + describe('create', () => { + it('delegates to WebhookService.createWebhook and returns config', () => { + const config = makeConfig(); + svc.createWebhook.mockReturnValue(config); + + const dto: CreateWebhookDto = { + url: 'https://example.com/hook', + events: [WebhookEventType.PAYMENT_CREATED], + }; + + const result = controller.create(dto, merchant()); + expect(svc.createWebhook).toHaveBeenCalledWith(MERCHANT_ID, dto); + expect(result).toEqual(config); + }); + }); + + // ─── GET /webhooks ───────────────────────────────────────────────────────── + + describe('list', () => { + it('returns all webhooks for the authenticated merchant', () => { + const configs = [makeConfig(), makeConfig({ id: 'wh-2' })]; + svc.listWebhooks.mockReturnValue(configs); + + const result = controller.list(merchant()); + expect(svc.listWebhooks).toHaveBeenCalledWith(MERCHANT_ID); + expect(result).toEqual(configs); + }); + }); + + // ─── GET /webhooks/:id ───────────────────────────────────────────────────── + + describe('get', () => { + it('returns webhook when it belongs to the merchant', () => { + const config = makeConfig(); + svc.getWebhook.mockReturnValue(config); + + expect(controller.get('wh-abc', merchant())).toEqual(config); + }); + + it('throws NotFoundException when webhook does not exist', () => { + svc.getWebhook.mockReturnValue(undefined); + expect(() => controller.get('missing', merchant())).toThrow(NotFoundException); + }); + + it('throws ForbiddenException when webhook belongs to another merchant', () => { + svc.getWebhook.mockReturnValue(makeConfig({ merchant_id: OTHER_MERCHANT_ID })); + expect(() => controller.get('wh-abc', merchant())).toThrow(ForbiddenException); + }); + }); + + // ─── PATCH /webhooks/:id ─────────────────────────────────────────────────── + + describe('update', () => { + it('updates and returns the modified webhook', () => { + const updated = makeConfig({ url: 'https://new.com', enabled: false }); + svc.getWebhook.mockReturnValue(makeConfig()); + svc.updateWebhook.mockReturnValue(updated); + + const result = controller.update( + 'wh-abc', + { url: 'https://new.com', enabled: false } as UpdateWebhookDto, + merchant(), + ); + expect(svc.updateWebhook).toHaveBeenCalledWith('wh-abc', { + url: 'https://new.com', + enabled: false, + }); + expect(result).toEqual(updated); + }); + + it('throws NotFoundException when webhook does not exist', () => { + svc.getWebhook.mockReturnValue(undefined); + expect(() => controller.update('missing', {} as UpdateWebhookDto, merchant())).toThrow( + NotFoundException, + ); + }); + + it('throws ForbiddenException when webhook belongs to another merchant', () => { + svc.getWebhook.mockReturnValue(makeConfig({ merchant_id: OTHER_MERCHANT_ID })); + expect(() => controller.update('wh-abc', {} as UpdateWebhookDto, merchant())).toThrow( + ForbiddenException, + ); + }); + }); + + // ─── DELETE /webhooks/:id ────────────────────────────────────────────────── + + describe('delete', () => { + it('deletes the webhook without returning content', () => { + svc.getWebhook.mockReturnValue(makeConfig()); + svc.deleteWebhook.mockReturnValue(true); + + expect(() => controller.delete('wh-abc', merchant())).not.toThrow(); + expect(svc.deleteWebhook).toHaveBeenCalledWith('wh-abc'); + }); + + it('throws NotFoundException when webhook does not exist', () => { + svc.getWebhook.mockReturnValue(undefined); + expect(() => controller.delete('missing', merchant())).toThrow(NotFoundException); + }); + + it('throws ForbiddenException when webhook belongs to another merchant', () => { + svc.getWebhook.mockReturnValue(makeConfig({ merchant_id: OTHER_MERCHANT_ID })); + expect(() => controller.delete('wh-abc', merchant())).toThrow(ForbiddenException); + }); + }); + + // ─── GET /webhooks/:id/deliveries ───────────────────────────────────────── + + describe('getDeliveries', () => { + it('returns delivery attempts for a webhook belonging to the merchant', () => { + svc.getWebhook.mockReturnValue(makeConfig()); + svc.getDeliveryAttempts.mockReturnValue([]); + + const result = controller.getDeliveries('wh-abc', merchant()); + expect(svc.getDeliveryAttempts).toHaveBeenCalledWith('wh-abc'); + expect(result).toEqual([]); + }); + + it('throws NotFoundException when webhook does not exist', () => { + svc.getWebhook.mockReturnValue(undefined); + expect(() => controller.getDeliveries('missing', merchant())).toThrow(NotFoundException); + }); + + it('throws ForbiddenException when webhook belongs to another merchant', () => { + svc.getWebhook.mockReturnValue(makeConfig({ merchant_id: OTHER_MERCHANT_ID })); + expect(() => controller.getDeliveries('wh-abc', merchant())).toThrow(ForbiddenException); + }); + }); +}); diff --git a/apps/api/src/webhooks/webhook.controller.ts b/apps/api/src/webhooks/webhook.controller.ts new file mode 100644 index 0000000..cb85b86 --- /dev/null +++ b/apps/api/src/webhooks/webhook.controller.ts @@ -0,0 +1,111 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Patch, + Post, + ForbiddenException, +} from '@nestjs/common'; +import { CurrentMerchant } from '../auth/decorators/current-merchant.decorator'; +import { type MerchantUser } from '../auth/interfaces/merchant-user.interface'; +import { CreateWebhookDto } from './dto/create-webhook.dto'; +import { UpdateWebhookDto } from './dto/update-webhook.dto'; +import { WebhookService } from './webhook.service'; +import type { WebhookConfig } from './interfaces/webhook-config.interface'; +import type { WebhookDeliveryAttempt } from './interfaces/webhook-event.interface'; + +@Controller('webhooks') +export class WebhookController { + constructor(private readonly webhookService: WebhookService) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + create( + @Body() dto: CreateWebhookDto, + @CurrentMerchant() merchant: MerchantUser, + ): WebhookConfig { + return this.webhookService.createWebhook(merchant.merchant_id, dto); + } + + @Get() + list(@CurrentMerchant() merchant: MerchantUser): WebhookConfig[] { + return this.webhookService.listWebhooks(merchant.merchant_id); + } + + @Get(':id') + get( + @Param('id') id: string, + @CurrentMerchant() merchant: MerchantUser, + ): WebhookConfig { + const webhook = this.webhookService.getWebhook(id); + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + if (webhook.merchant_id !== merchant.merchant_id) { + throw new ForbiddenException('Access denied'); + } + return webhook; + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateWebhookDto, + @CurrentMerchant() merchant: MerchantUser, + ): WebhookConfig { + const webhook = this.webhookService.getWebhook(id); + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + if (webhook.merchant_id !== merchant.merchant_id) { + throw new ForbiddenException('Access denied'); + } + + const updated = this.webhookService.updateWebhook(id, dto); + if (!updated) { + throw new NotFoundException('Webhook not found'); + } + return updated; + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('id') id: string, + @CurrentMerchant() merchant: MerchantUser, + ): void { + const webhook = this.webhookService.getWebhook(id); + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + if (webhook.merchant_id !== merchant.merchant_id) { + throw new ForbiddenException('Access denied'); + } + + const deleted = this.webhookService.deleteWebhook(id); + if (!deleted) { + throw new NotFoundException('Webhook not found'); + } + } + + @Get(':id/deliveries') + getDeliveries( + @Param('id') id: string, + @CurrentMerchant() merchant: MerchantUser, + ): WebhookDeliveryAttempt[] { + const webhook = this.webhookService.getWebhook(id); + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + if (webhook.merchant_id !== merchant.merchant_id) { + throw new ForbiddenException('Access denied'); + } + + return this.webhookService.getDeliveryAttempts(id); + } +} diff --git a/apps/api/src/webhooks/webhook.module.ts b/apps/api/src/webhooks/webhook.module.ts new file mode 100644 index 0000000..e2bdd36 --- /dev/null +++ b/apps/api/src/webhooks/webhook.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { WebhookController } from './webhook.controller'; +import { WebhookService } from './webhook.service'; +import { WebhookRepository } from './webhook.repository'; + +@Module({ + controllers: [WebhookController], + providers: [WebhookService, WebhookRepository], + exports: [WebhookService], +}) +export class WebhookModule {} diff --git a/apps/api/src/webhooks/webhook.repository.ts b/apps/api/src/webhooks/webhook.repository.ts new file mode 100644 index 0000000..b8fa171 --- /dev/null +++ b/apps/api/src/webhooks/webhook.repository.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { randomBytes } from 'crypto'; +import type { + WebhookConfig, + CreateWebhookConfigDto, + UpdateWebhookConfigDto, +} from './interfaces/webhook-config.interface'; +import type { + WebhookDeliveryAttempt, + WebhookEventType, +} from './interfaces/webhook-event.interface'; + +@Injectable() +export class WebhookRepository { + private readonly configs: WebhookConfig[] = []; + private readonly deliveryAttempts: WebhookDeliveryAttempt[] = []; + + createConfig(merchantId: string, dto: CreateWebhookConfigDto): WebhookConfig { + const config: WebhookConfig = { + id: crypto.randomUUID(), + merchant_id: merchantId, + url: dto.url, + secret: this.generateSecret(), + events: dto.events, + enabled: dto.enabled ?? true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + this.configs.push(config); + return config; + } + + findByMerchant(merchantId: string): WebhookConfig[] { + return this.configs.filter((config) => config.merchant_id === merchantId); + } + + findById(id: string): WebhookConfig | undefined { + return this.configs.find((config) => config.id === id); + } + + updateConfig(id: string, dto: UpdateWebhookConfigDto): WebhookConfig | undefined { + const config = this.configs.find((c) => c.id === id); + if (!config) return undefined; + + if (dto.url !== undefined) config.url = dto.url; + if (dto.events !== undefined) config.events = dto.events; + if (dto.enabled !== undefined) config.enabled = dto.enabled; + config.updated_at = new Date().toISOString(); + + return config; + } + + deleteConfig(id: string): boolean { + const index = this.configs.findIndex((c) => c.id === id); + if (index === -1) return false; + + this.configs.splice(index, 1); + return true; + } + + findActiveConfigsByEvent(merchantId: string, eventType: WebhookEventType): WebhookConfig[] { + return this.configs.filter( + (config) => + config.merchant_id === merchantId && config.enabled && config.events.includes(eventType), + ); + } + + saveDeliveryAttempt(attempt: WebhookDeliveryAttempt): void { + this.deliveryAttempts.push(attempt); + } + + findDeliveryAttempts(webhookId: string, limit = 50): WebhookDeliveryAttempt[] { + return this.deliveryAttempts + .filter((attempt) => attempt.webhook_id === webhookId) + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .slice(0, limit); + } + + private generateSecret(): string { + return randomBytes(32).toString('hex'); + } +} diff --git a/apps/api/src/webhooks/webhook.service.spec.ts b/apps/api/src/webhooks/webhook.service.spec.ts new file mode 100644 index 0000000..8a06f23 --- /dev/null +++ b/apps/api/src/webhooks/webhook.service.spec.ts @@ -0,0 +1,269 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createHmac } from 'crypto'; +import { WebhookService } from './webhook.service'; +import { WebhookRepository } from './webhook.repository'; +import { WebhookEventType } from './interfaces/webhook-event.interface'; +import type { WebhookConfig } from './interfaces/webhook-config.interface'; + +const mockConfig = (overrides: Partial = {}): WebhookConfig => ({ + id: 'wh-1', + merchant_id: 'merchant-1', + url: 'https://example.com/webhook', + secret: 'test-secret', + events: [ + WebhookEventType.PAYMENT_CREATED, + WebhookEventType.PAYMENT_DETECTED, + WebhookEventType.PAYMENT_CONFIRMED, + WebhookEventType.PAYMENT_FAILED, + ], + enabled: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, +}); + +describe('WebhookService', () => { + let service: WebhookService; + let repo: WebhookRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [WebhookService, WebhookRepository], + }).compile(); + + service = module.get(WebhookService); + repo = module.get(WebhookRepository); + }); + + describe('CRUD', () => { + it('creates and retrieves a webhook config', () => { + const config = service.createWebhook('merchant-1', { + url: 'https://example.com/hook', + events: [WebhookEventType.PAYMENT_CREATED], + }); + + expect(config.id).toBeDefined(); + expect(config.secret).toBeDefined(); + expect(config.enabled).toBe(true); + expect(service.getWebhook(config.id)).toEqual(config); + }); + + it('lists webhooks for a merchant', () => { + service.createWebhook('merchant-1', { + url: 'https://a.com', + events: [WebhookEventType.PAYMENT_CREATED], + }); + service.createWebhook('merchant-2', { + url: 'https://b.com', + events: [WebhookEventType.PAYMENT_FAILED], + }); + + expect(service.listWebhooks('merchant-1')).toHaveLength(1); + }); + + it('updates a webhook config', () => { + const config = service.createWebhook('merchant-1', { + url: 'https://old.com', + events: [WebhookEventType.PAYMENT_CREATED], + }); + + const updated = service.updateWebhook(config.id, { url: 'https://new.com', enabled: false }); + expect(updated?.url).toBe('https://new.com'); + expect(updated?.enabled).toBe(false); + }); + + it('deletes a webhook config', () => { + const config = service.createWebhook('merchant-1', { + url: 'https://example.com', + events: [WebhookEventType.PAYMENT_CREATED], + }); + + expect(service.deleteWebhook(config.id)).toBe(true); + expect(service.getWebhook(config.id)).toBeUndefined(); + }); + }); + + describe('dispatchEvent', () => { + it('dispatches to matching active webhook configs', async () => { + const config = mockConfig(); + jest.spyOn(repo, 'findActiveConfigsByEvent').mockReturnValue([config]); + jest.spyOn(repo, 'saveDeliveryAttempt').mockImplementation(() => {}); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => 'ok', + } as Response); + + await service.dispatchEvent('merchant-1', WebhookEventType.PAYMENT_CREATED, { + payment_id: 'pay-1', + amount: 100, + currency: 'USDC', + status: 'pending', + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, options] = (global.fetch as jest.Mock).mock.calls[0] as [string, RequestInit]; + expect(url).toBe(config.url); + + const body = JSON.parse(options.body as string) as { + event: string; + data: unknown; + timestamp: string; + }; + expect(body.event).toBe(WebhookEventType.PAYMENT_CREATED); + expect(body.data).toBeDefined(); + expect(body.timestamp).toBeDefined(); + }); + + it('dispatches payment.detected event', async () => { + const config = mockConfig({ events: [WebhookEventType.PAYMENT_DETECTED] }); + jest.spyOn(repo, 'findActiveConfigsByEvent').mockReturnValue([config]); + jest.spyOn(repo, 'saveDeliveryAttempt').mockImplementation(() => {}); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => '', + } as Response); + + await service.dispatchEvent('merchant-1', WebhookEventType.PAYMENT_DETECTED, { + payment_id: 'pay-1', + }); + + const body = JSON.parse( + ((global.fetch as jest.Mock).mock.calls[0] as [string, RequestInit])[1].body as string, + ) as { event: string }; + expect(body.event).toBe('payment.detected'); + }); + + it('dispatches payment.confirmed event', async () => { + const config = mockConfig({ events: [WebhookEventType.PAYMENT_CONFIRMED] }); + jest.spyOn(repo, 'findActiveConfigsByEvent').mockReturnValue([config]); + jest.spyOn(repo, 'saveDeliveryAttempt').mockImplementation(() => {}); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => '', + } as Response); + + await service.dispatchEvent('merchant-1', WebhookEventType.PAYMENT_CONFIRMED, { + payment_id: 'pay-1', + }); + + const body = JSON.parse( + ((global.fetch as jest.Mock).mock.calls[0] as [string, RequestInit])[1].body as string, + ) as { event: string }; + expect(body.event).toBe('payment.confirmed'); + }); + + it('dispatches payment.failed event', async () => { + const config = mockConfig({ events: [WebhookEventType.PAYMENT_FAILED] }); + jest.spyOn(repo, 'findActiveConfigsByEvent').mockReturnValue([config]); + jest.spyOn(repo, 'saveDeliveryAttempt').mockImplementation(() => {}); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => '', + } as Response); + + await service.dispatchEvent('merchant-1', WebhookEventType.PAYMENT_FAILED, { + payment_id: 'pay-1', + }); + + const body = JSON.parse( + ((global.fetch as jest.Mock).mock.calls[0] as [string, RequestInit])[1].body as string, + ) as { event: string }; + expect(body.event).toBe('payment.failed'); + }); + + it('skips dispatch when no active configs match', async () => { + jest.spyOn(repo, 'findActiveConfigsByEvent').mockReturnValue([]); + global.fetch = jest.fn(); + + await service.dispatchEvent('merchant-1', WebhookEventType.PAYMENT_CREATED, {}); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('includes HMAC signature header', async () => { + const config = mockConfig(); + jest.spyOn(repo, 'findActiveConfigsByEvent').mockReturnValue([config]); + jest.spyOn(repo, 'saveDeliveryAttempt').mockImplementation(() => {}); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => '', + } as Response); + + await service.dispatchEvent('merchant-1', WebhookEventType.PAYMENT_CREATED, { + payment_id: 'pay-1', + }); + + const headers = ((global.fetch as jest.Mock).mock.calls[0] as [string, RequestInit])[1] + .headers as Record; + expect(headers['X-Webhook-Signature']).toBeDefined(); + expect(headers['X-Webhook-Event']).toBe(WebhookEventType.PAYMENT_CREATED); + }); + + it('saves delivery attempt on success', async () => { + const config = mockConfig(); + jest.spyOn(repo, 'findActiveConfigsByEvent').mockReturnValue([config]); + const saveSpy = jest.spyOn(repo, 'saveDeliveryAttempt').mockImplementation(() => {}); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => '', + } as Response); + + await service.dispatchEvent('merchant-1', WebhookEventType.PAYMENT_CREATED, {}); + + expect(saveSpy).toHaveBeenCalledWith(expect.objectContaining({ status: 'success' })); + }); + + it('saves failed delivery attempt on HTTP error', async () => { + const config = mockConfig(); + jest.spyOn(repo, 'findActiveConfigsByEvent').mockReturnValue([config]); + const saveSpy = jest.spyOn(repo, 'saveDeliveryAttempt').mockImplementation(() => {}); + + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => '', + } as Response); + + await service.dispatchEvent('merchant-1', WebhookEventType.PAYMENT_CREATED, {}); + + expect(saveSpy).toHaveBeenCalledWith(expect.objectContaining({ status: 'failed' })); + }); + }); + + describe('verifySignature', () => { + it('returns true for a valid signature', () => { + const payload = JSON.stringify({ + event: 'payment.created', + data: {}, + timestamp: '2024-01-01T00:00:00.000Z', + }); + const secret = 'my-secret'; + const sig = createHmac('sha256', secret).update(payload).digest('hex'); + + expect(service.verifySignature(payload, sig, secret)).toBe(true); + }); + + it('returns false for an invalid signature', () => { + expect(service.verifySignature('payload', 'bad-sig', 'secret')).toBe(false); + }); + }); +}); diff --git a/apps/api/src/webhooks/webhook.service.ts b/apps/api/src/webhooks/webhook.service.ts new file mode 100644 index 0000000..cdae1f8 --- /dev/null +++ b/apps/api/src/webhooks/webhook.service.ts @@ -0,0 +1,161 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { createHmac } from 'crypto'; +import { WebhookRepository } from './webhook.repository'; +import type { WebhookEventPayload, WebhookEventType, WebhookDeliveryAttempt } from './interfaces/webhook-event.interface'; +import type { CreateWebhookConfigDto, UpdateWebhookConfigDto, WebhookConfig } from './interfaces/webhook-config.interface'; + +@Injectable() +export class WebhookService { + private readonly logger = new Logger(WebhookService.name); + private readonly MAX_RETRIES = 3; + private readonly TIMEOUT_MS = 10000; // 10 seconds + + constructor(private readonly webhookRepo: WebhookRepository) {} + + createWebhook(merchantId: string, dto: CreateWebhookConfigDto): WebhookConfig { + return this.webhookRepo.createConfig(merchantId, dto); + } + + listWebhooks(merchantId: string): WebhookConfig[] { + return this.webhookRepo.findByMerchant(merchantId); + } + + getWebhook(id: string): WebhookConfig | undefined { + return this.webhookRepo.findById(id); + } + + updateWebhook(id: string, dto: UpdateWebhookConfigDto): WebhookConfig | undefined { + return this.webhookRepo.updateConfig(id, dto); + } + + deleteWebhook(id: string): boolean { + return this.webhookRepo.deleteConfig(id); + } + + getDeliveryAttempts(webhookId: string): WebhookDeliveryAttempt[] { + return this.webhookRepo.findDeliveryAttempts(webhookId); + } + + async dispatchEvent( + merchantId: string, + eventType: WebhookEventType, + data: Record, + ): Promise { + const payload: WebhookEventPayload = { + event: eventType, + data, + timestamp: new Date().toISOString(), + }; + + const configs = this.webhookRepo.findActiveConfigsByEvent(merchantId, eventType); + + if (configs.length === 0) { + this.logger.debug(`No active webhooks for merchant ${merchantId} and event ${eventType}`); + return; + } + + this.logger.log( + `Dispatching ${eventType} event to ${configs.length} webhook(s) for merchant ${merchantId}`, + ); + + await Promise.allSettled( + configs.map((config) => this.deliverWebhook(config, payload)), + ); + } + + private async deliverWebhook( + config: WebhookConfig, + payload: WebhookEventPayload, + attemptNumber = 1, + ): Promise { + const attempt: WebhookDeliveryAttempt = { + id: crypto.randomUUID(), + webhook_id: config.id, + event_type: payload.event, + payload, + url: config.url, + status: 'pending', + attempt_number: attemptNumber, + created_at: new Date().toISOString(), + }; + + try { + const signature = this.generateSignature(payload, config.secret); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT_MS); + + const response = await fetch(config.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': signature, + 'X-Webhook-Event': payload.event, + 'User-Agent': 'StellarPay-Webhooks/1.0', + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + attempt.response_code = response.status; + attempt.response_body = await response.text().catch(() => ''); + + if (response.ok) { + attempt.status = 'success'; + attempt.delivered_at = new Date().toISOString(); + this.logger.log( + `Webhook delivered successfully to ${config.url} (attempt ${attemptNumber})`, + ); + } else { + attempt.status = 'failed'; + attempt.error_message = `HTTP ${response.status}: ${response.statusText}`; + this.logger.warn( + `Webhook delivery failed to ${config.url}: ${attempt.error_message} (attempt ${attemptNumber})`, + ); + + if (attemptNumber < this.MAX_RETRIES) { + await this.scheduleRetry(config, payload, attemptNumber + 1); + } + } + } catch (error) { + attempt.status = 'failed'; + attempt.error_message = error instanceof Error ? error.message : String(error); + this.logger.error( + `Webhook delivery error to ${config.url}: ${attempt.error_message} (attempt ${attemptNumber})`, + ); + + if (attemptNumber < this.MAX_RETRIES) { + await this.scheduleRetry(config, payload, attemptNumber + 1); + } + } finally { + this.webhookRepo.saveDeliveryAttempt(attempt); + } + } + + private async scheduleRetry( + config: WebhookConfig, + payload: WebhookEventPayload, + attemptNumber: number, + ): Promise { + // Exponential backoff: 2^(attempt-1) seconds + const delayMs = Math.pow(2, attemptNumber - 1) * 1000; + this.logger.log( + `Scheduling retry ${attemptNumber} for ${config.url} in ${delayMs}ms`, + ); + + setTimeout(() => { + void this.deliverWebhook(config, payload, attemptNumber); + }, delayMs); + } + + private generateSignature(payload: WebhookEventPayload, secret: string): string { + const payloadString = JSON.stringify(payload); + return createHmac('sha256', secret).update(payloadString).digest('hex'); + } + + verifySignature(payload: string, signature: string, secret: string): boolean { + const expectedSignature = createHmac('sha256', secret).update(payload).digest('hex'); + return signature === expectedSignature; + } +}