From 7507a0689f72d31407db8685f8c69debeac848fa Mon Sep 17 00:00:00 2001 From: DevSolex Date: Tue, 26 May 2026 23:11:44 +0100 Subject: [PATCH 1/2] feat: implement webhook event dispatch system (#15) - Add WebhookService with dispatchEvent, HMAC-SHA256 signing, retry with exponential backoff - Add WebhookController with CRUD endpoints for merchant webhook configs - Add WebhookRepository for config and delivery attempt persistence - Define WebhookEventType enum: payment.created, payment.detected, payment.confirmed, payment.failed - Wire WebhookService into PaymentsService and TransactionsService to fire events on all status transitions - Add unit tests for WebhookService and WebhookController Closes #15 --- apps/api/src/app.module.ts | 4 + apps/api/src/payments/payments.module.ts | 2 + .../api/src/payments/payments.service.spec.ts | 199 +++++++++++++++++ apps/api/src/payments/payments.service.ts | 63 +++++- .../transactions/transactions.controller.ts | 9 +- .../src/transactions/transactions.module.ts | 2 + .../src/transactions/transactions.service.ts | 73 +++++- .../src/webhooks/dto/create-webhook.dto.ts | 15 ++ .../src/webhooks/dto/update-webhook.dto.ts | 17 ++ .../interfaces/webhook-config.interface.ts | 24 ++ .../interfaces/webhook-event.interface.ts | 27 +++ .../src/webhooks/webhook.controller.spec.ts | 173 +++++++++++++++ apps/api/src/webhooks/webhook.controller.ts | 111 +++++++++ apps/api/src/webhooks/webhook.module.ts | 11 + apps/api/src/webhooks/webhook.repository.ts | 78 +++++++ apps/api/src/webhooks/webhook.service.spec.ts | 210 ++++++++++++++++++ apps/api/src/webhooks/webhook.service.ts | 161 ++++++++++++++ 17 files changed, 1172 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/payments/payments.service.spec.ts create mode 100644 apps/api/src/webhooks/dto/create-webhook.dto.ts create mode 100644 apps/api/src/webhooks/dto/update-webhook.dto.ts create mode 100644 apps/api/src/webhooks/interfaces/webhook-config.interface.ts create mode 100644 apps/api/src/webhooks/interfaces/webhook-event.interface.ts create mode 100644 apps/api/src/webhooks/webhook.controller.spec.ts create mode 100644 apps/api/src/webhooks/webhook.controller.ts create mode 100644 apps/api/src/webhooks/webhook.module.ts create mode 100644 apps/api/src/webhooks/webhook.repository.ts create mode 100644 apps/api/src/webhooks/webhook.service.spec.ts create mode 100644 apps/api/src/webhooks/webhook.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 195cb6d..fc11c6f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +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 { WebhookModule } from './webhooks/webhook.module'; +import { PaymentsModule } from './payments/payments.module'; @Module({ imports: [ @@ -20,6 +22,8 @@ import { WorkerModule } from './modules/worker/worker.module'; AuthModule, TransactionsModule, WorkerModule, + WebhookModule, + PaymentsModule, 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 c4167a1..577b1a2 100644 --- a/apps/api/src/payments/payments.module.ts +++ b/apps/api/src/payments/payments.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { PaymentsController } from './payments.controller.js'; import { PaymentsService } from './payments.service.js'; +import { WebhookModule } from '../webhooks/webhook.module'; @Module({ + imports: [WebhookModule], controllers: [PaymentsController], providers: [PaymentsService], }) 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..5e0c12f --- /dev/null +++ b/apps/api/src/payments/payments.service.spec.ts @@ -0,0 +1,199 @@ +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'; + +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: 'USDC' as any }, + '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: 'USDC' as any, 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: 'USDC' as any }, 'merchant-1'); + + const [, , data] = (webhookSvc.dispatchEvent as jest.Mock).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: 'USDC' as any }, + '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: 'XLM' as any }, + '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: 'USDC' as any }, + 'merchant-1', + ); + jest.clearAllMocks(); + service.markConfirmed(payment.id); + + const [, , data] = (webhookSvc.dispatchEvent as jest.Mock).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: 'USDC' as any }, + '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: 'USDC' as any }, + '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: 'USDC' as any }, + merchantId, + ); + + const calls = (webhookSvc.dispatchEvent as jest.Mock).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 as jest.Mock).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 dcb1e74..1bfa0bf 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 PaymentIntent { id: string; @@ -7,21 +11,72 @@ export interface PaymentIntent { amount: number; currency: string; metadata?: Record; - status: 'pending'; + status: PaymentStatus; createdAt: string; + updatedAt: string; } @Injectable() export class PaymentsService { + private readonly payments: PaymentIntent[] = []; + + constructor(private readonly webhookService: WebhookService) {} + createPaymentIntent(dto: CreatePaymentIntentDto, merchantId: string): PaymentIntent { - return { + const now = new Date().toISOString(); + const payment: PaymentIntent = { id: crypto.randomUUID(), merchantId, amount: dto.amount, currency: dto.currency, metadata: dto.metadata, status: 'pending', - createdAt: new Date().toISOString(), + createdAt: now, + updatedAt: now, }; + + this.payments.push(payment); + + 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): PaymentIntent { + return this.updateStatus(id, 'detected', WebhookEventType.PAYMENT_DETECTED); + } + + markConfirmed(id: string): PaymentIntent { + return this.updateStatus(id, 'confirmed', WebhookEventType.PAYMENT_CONFIRMED); + } + + markFailed(id: string): PaymentIntent { + return this.updateStatus(id, 'failed', WebhookEventType.PAYMENT_FAILED); + } + + private updateStatus(id: string, status: PaymentStatus, event: WebhookEventType): PaymentIntent { + const payment = this.payments.find((p) => p.id === id); + 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 new file mode 100644 index 0000000..ed320b3 --- /dev/null +++ b/apps/api/src/webhooks/interfaces/webhook-event.interface.ts @@ -0,0 +1,27 @@ +export enum WebhookEventType { + PAYMENT_CREATED = 'payment.created', + PAYMENT_DETECTED = 'payment.detected', + PAYMENT_CONFIRMED = 'payment.confirmed', + PAYMENT_FAILED = 'payment.failed', +} + +export interface WebhookEventPayload { + event: WebhookEventType; + data: Record; + timestamp: string; +} + +export interface WebhookDeliveryAttempt { + id: string; + webhook_id: string; + event_type: WebhookEventType; + payload: WebhookEventPayload; + url: string; + 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..d2f5ecd --- /dev/null +++ b/apps/api/src/webhooks/webhook.controller.spec.ts @@ -0,0 +1,173 @@ +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, CreateWebhookConfigDto } from './interfaces/webhook-config.interface'; +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: CreateWebhookConfigDto = { + url: 'https://example.com/hook', + events: [WebhookEventType.PAYMENT_CREATED], + }; + + const result = controller.create(dto as any, 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 any, 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 any, 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 any, 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..5b25209 --- /dev/null +++ b/apps/api/src/webhooks/webhook.repository.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@nestjs/common'; +import { randomBytes } from 'crypto'; +import type { WebhookConfig, CreateWebhookConfigDto, UpdateWebhookConfigDto } from './interfaces/webhook-config.interface'; +import type { WebhookDeliveryAttempt } 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: string): WebhookConfig[] { + return this.configs.filter( + (config) => + config.merchant_id === merchantId && + config.enabled && + config.events.includes(eventType as any), + ); + } + + 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..72c0f4a --- /dev/null +++ b/apps/api/src/webhooks/webhook.service.spec.ts @@ -0,0 +1,210 @@ +import { Test, TestingModule } from '@nestjs/testing'; +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 { createHmac } = require('crypto') as typeof import('crypto'); + 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; + } +} From 2cd03af6e1752cb515e85943ea0b399dfab79db8 Mon Sep 17 00:00:00 2001 From: DevSolex Date: Fri, 29 May 2026 08:41:58 +0100 Subject: [PATCH 2/2] fix: resolve no-explicit-any and no-require-imports lint errors --- .../api/src/payments/payments.service.spec.ts | 32 +++--- .../src/webhooks/webhook.controller.spec.ts | 29 ++++-- apps/api/src/webhooks/webhook.repository.ts | 17 ++-- apps/api/src/webhooks/webhook.service.spec.ts | 97 +++++++++++++++---- 4 files changed, 125 insertions(+), 50 deletions(-) diff --git a/apps/api/src/payments/payments.service.spec.ts b/apps/api/src/payments/payments.service.spec.ts index 5e0c12f..3ce8cb4 100644 --- a/apps/api/src/payments/payments.service.spec.ts +++ b/apps/api/src/payments/payments.service.spec.ts @@ -3,6 +3,7 @@ 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 => ({ @@ -15,10 +16,7 @@ describe('PaymentsService – webhook event dispatch', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ - PaymentsService, - { provide: WebhookService, useFactory: mockWebhookService }, - ], + providers: [PaymentsService, { provide: WebhookService, useFactory: mockWebhookService }], }).compile(); service = module.get(PaymentsService); @@ -32,7 +30,7 @@ describe('PaymentsService – webhook event dispatch', () => { describe('createPaymentIntent', () => { it('returns a payment with status "pending"', () => { const payment = service.createPaymentIntent( - { amount: 50, currency: 'USDC' as any }, + { amount: 50, currency: Currency.USDC }, 'merchant-1', ); @@ -43,7 +41,7 @@ describe('PaymentsService – webhook event dispatch', () => { it('dispatches payment.created event with standardised payload', () => { const payment = service.createPaymentIntent( - { amount: 100, currency: 'USDC' as any, metadata: { ref: 'ord-9' } }, + { amount: 100, currency: Currency.USDC, metadata: { ref: 'ord-9' } }, 'merchant-1', ); @@ -61,9 +59,9 @@ describe('PaymentsService – webhook event dispatch', () => { }); it('payload contains created_at timestamp', () => { - service.createPaymentIntent({ amount: 1, currency: 'USDC' as any }, 'merchant-1'); + service.createPaymentIntent({ amount: 1, currency: Currency.USDC }, 'merchant-1'); - const [, , data] = (webhookSvc.dispatchEvent as jest.Mock).mock.calls[0] as [ + const [, , data] = webhookSvc.dispatchEvent.mock.calls[0] as [ string, WebhookEventType, Record, @@ -77,7 +75,7 @@ describe('PaymentsService – webhook event dispatch', () => { describe('markDetected', () => { it('updates status to "detected" and dispatches payment.detected', () => { const payment = service.createPaymentIntent( - { amount: 10, currency: 'USDC' as any }, + { amount: 10, currency: Currency.USDC }, 'merchant-2', ); jest.clearAllMocks(); @@ -102,7 +100,7 @@ describe('PaymentsService – webhook event dispatch', () => { describe('markConfirmed', () => { it('updates status to "confirmed" and dispatches payment.confirmed', () => { const payment = service.createPaymentIntent( - { amount: 25, currency: 'XLM' as any }, + { amount: 25, currency: Currency.XLM }, 'merchant-3', ); jest.clearAllMocks(); @@ -119,13 +117,13 @@ describe('PaymentsService – webhook event dispatch', () => { it('payload contains updated_at timestamp', () => { const payment = service.createPaymentIntent( - { amount: 5, currency: 'USDC' as any }, + { amount: 5, currency: Currency.USDC }, 'merchant-1', ); jest.clearAllMocks(); service.markConfirmed(payment.id); - const [, , data] = (webhookSvc.dispatchEvent as jest.Mock).mock.calls[0] as [ + const [, , data] = webhookSvc.dispatchEvent.mock.calls[0] as [ string, WebhookEventType, Record, @@ -143,7 +141,7 @@ describe('PaymentsService – webhook event dispatch', () => { describe('markFailed', () => { it('updates status to "failed" and dispatches payment.failed', () => { const payment = service.createPaymentIntent( - { amount: 75, currency: 'USDC' as any }, + { amount: 75, currency: Currency.USDC }, 'merchant-4', ); jest.clearAllMocks(); @@ -168,7 +166,7 @@ describe('PaymentsService – webhook event dispatch', () => { describe('dispatch payload shape', () => { it('dispatches exactly once per lifecycle transition', () => { const payment = service.createPaymentIntent( - { amount: 10, currency: 'USDC' as any }, + { amount: 10, currency: Currency.USDC }, 'merchant-1', ); jest.clearAllMocks(); @@ -180,11 +178,11 @@ describe('PaymentsService – webhook event dispatch', () => { it('merchant id is forwarded correctly in every dispatch', () => { const merchantId = 'merchant-xyz'; const payment = service.createPaymentIntent( - { amount: 10, currency: 'USDC' as any }, + { amount: 10, currency: Currency.USDC }, merchantId, ); - const calls = (webhookSvc.dispatchEvent as jest.Mock).mock.calls as [string, ...unknown[]][]; + const calls = webhookSvc.dispatchEvent.mock.calls as [string, ...unknown[]][]; expect(calls[0][0]).toBe(merchantId); jest.clearAllMocks(); @@ -192,7 +190,7 @@ describe('PaymentsService – webhook event dispatch', () => { service.markConfirmed(payment.id); service.markFailed(payment.id); - const allCalls = (webhookSvc.dispatchEvent as jest.Mock).mock.calls as [string, ...unknown[]][]; + const allCalls = webhookSvc.dispatchEvent.mock.calls as [string, ...unknown[]][]; allCalls.forEach(([mid]) => expect(mid).toBe(merchantId)); }); }); diff --git a/apps/api/src/webhooks/webhook.controller.spec.ts b/apps/api/src/webhooks/webhook.controller.spec.ts index d2f5ecd..9c3746f 100644 --- a/apps/api/src/webhooks/webhook.controller.spec.ts +++ b/apps/api/src/webhooks/webhook.controller.spec.ts @@ -3,13 +3,15 @@ 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, CreateWebhookConfigDto } from './interfaces/webhook-config.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 merchant = (): MerchantUser => ({ merchant_id: MERCHANT_ID }) as MerchantUser; const makeConfig = (overrides: Partial = {}): WebhookConfig => ({ id: 'wh-abc', @@ -57,12 +59,12 @@ describe('WebhookController', () => { const config = makeConfig(); svc.createWebhook.mockReturnValue(config); - const dto: CreateWebhookConfigDto = { + const dto: CreateWebhookDto = { url: 'https://example.com/hook', events: [WebhookEventType.PAYMENT_CREATED], }; - const result = controller.create(dto as any, merchant()); + const result = controller.create(dto, merchant()); expect(svc.createWebhook).toHaveBeenCalledWith(MERCHANT_ID, dto); expect(result).toEqual(config); }); @@ -110,19 +112,30 @@ describe('WebhookController', () => { svc.getWebhook.mockReturnValue(makeConfig()); svc.updateWebhook.mockReturnValue(updated); - const result = controller.update('wh-abc', { url: 'https://new.com', enabled: false } as any, merchant()); - expect(svc.updateWebhook).toHaveBeenCalledWith('wh-abc', { url: 'https://new.com', enabled: false }); + 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 any, merchant())).toThrow(NotFoundException); + 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 any, merchant())).toThrow(ForbiddenException); + expect(() => controller.update('wh-abc', {} as UpdateWebhookDto, merchant())).toThrow( + ForbiddenException, + ); }); }); diff --git a/apps/api/src/webhooks/webhook.repository.ts b/apps/api/src/webhooks/webhook.repository.ts index 5b25209..b8fa171 100644 --- a/apps/api/src/webhooks/webhook.repository.ts +++ b/apps/api/src/webhooks/webhook.repository.ts @@ -1,7 +1,14 @@ import { Injectable } from '@nestjs/common'; import { randomBytes } from 'crypto'; -import type { WebhookConfig, CreateWebhookConfigDto, UpdateWebhookConfigDto } from './interfaces/webhook-config.interface'; -import type { WebhookDeliveryAttempt } from './interfaces/webhook-event.interface'; +import type { + WebhookConfig, + CreateWebhookConfigDto, + UpdateWebhookConfigDto, +} from './interfaces/webhook-config.interface'; +import type { + WebhookDeliveryAttempt, + WebhookEventType, +} from './interfaces/webhook-event.interface'; @Injectable() export class WebhookRepository { @@ -52,12 +59,10 @@ export class WebhookRepository { return true; } - findActiveConfigsByEvent(merchantId: string, eventType: string): WebhookConfig[] { + findActiveConfigsByEvent(merchantId: string, eventType: WebhookEventType): WebhookConfig[] { return this.configs.filter( (config) => - config.merchant_id === merchantId && - config.enabled && - config.events.includes(eventType as any), + config.merchant_id === merchantId && config.enabled && config.events.includes(eventType), ); } diff --git a/apps/api/src/webhooks/webhook.service.spec.ts b/apps/api/src/webhooks/webhook.service.spec.ts index 72c0f4a..8a06f23 100644 --- a/apps/api/src/webhooks/webhook.service.spec.ts +++ b/apps/api/src/webhooks/webhook.service.spec.ts @@ -1,4 +1,5 @@ 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'; @@ -48,8 +49,14 @@ describe('WebhookService', () => { }); 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] }); + 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); }); @@ -100,7 +107,11 @@ describe('WebhookService', () => { 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 }; + 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(); @@ -111,11 +122,20 @@ describe('WebhookService', () => { 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); + 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' }); + 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 }; + 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'); }); @@ -124,11 +144,20 @@ describe('WebhookService', () => { 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); + 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' }); + 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 }; + 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'); }); @@ -137,11 +166,20 @@ describe('WebhookService', () => { 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); + 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' }); + 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 }; + 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'); }); @@ -159,11 +197,19 @@ describe('WebhookService', () => { 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); + 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' }); + 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; + 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); }); @@ -173,7 +219,12 @@ describe('WebhookService', () => { 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); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => '', + } as Response); await service.dispatchEvent('merchant-1', WebhookEventType.PAYMENT_CREATED, {}); @@ -185,7 +236,12 @@ describe('WebhookService', () => { 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); + 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, {}); @@ -195,9 +251,12 @@ describe('WebhookService', () => { 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 payload = JSON.stringify({ + event: 'payment.created', + data: {}, + timestamp: '2024-01-01T00:00:00.000Z', + }); const secret = 'my-secret'; - const { createHmac } = require('crypto') as typeof import('crypto'); const sig = createHmac('sha256', secret).update(payload).digest('hex'); expect(service.verifySignature(payload, sig, secret)).toBe(true);