diff --git a/apps/api/.env.example b/apps/api/.env.example index f10135c..d9856ba 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -17,3 +17,6 @@ DATABASE_URL=postgresql://user:password@localhost:5432/stellar_pay # Redis (for when implemented) REDIS_URL=redis://localhost:6379 + +# Webhooks +WEBHOOK_TIMEOUT_MS=5000 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 72888f2..8edc528 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,7 @@ import { AppService } from './app.service'; import { HealthModule } from './health/health.module'; import { TreasuryModule } from './treasury/treasury.module'; import { AuthModule } from './auth/auth.module'; +import { WebhooksModule } from './webhooks/webhooks.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { ThrottlerRedisGuard } from './rate-limiter/guards/throttler-redis.guard'; @@ -14,6 +15,7 @@ import { ThrottlerRedisGuard } from './rate-limiter/guards/throttler-redis.guard HealthModule, TreasuryModule, AuthModule, + WebhooksModule, ThrottlerModule.forRoot({ throttlers: [ { name: 'short', ttl: 60000, limit: 100 }, diff --git a/apps/api/src/webhooks/interfaces/dispatch-webhook.interface.ts b/apps/api/src/webhooks/interfaces/dispatch-webhook.interface.ts new file mode 100644 index 0000000..088f79f --- /dev/null +++ b/apps/api/src/webhooks/interfaces/dispatch-webhook.interface.ts @@ -0,0 +1,17 @@ +import { + PaymentWebhookEventType, + WebhookDeliveryResult, + WebhookEndpoint, + WebhookEventPayload, +} from './webhook-event.interface'; + +export interface DispatchWebhookRequest { + event: PaymentWebhookEventType; + data: TData; + endpoints: WebhookEndpoint[]; +} + +export interface DispatchWebhookResponse { + payload: WebhookEventPayload; + deliveries: WebhookDeliveryResult[]; +} 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..b5d97fb --- /dev/null +++ b/apps/api/src/webhooks/interfaces/webhook-event.interface.ts @@ -0,0 +1,25 @@ +export type PaymentWebhookEventType = + | 'payment.created' + | 'payment.detected' + | 'payment.confirmed' + | 'payment.failed'; + +export interface WebhookEndpoint { + url: string; + secret?: string; +} + +export interface WebhookEventPayload { + event: PaymentWebhookEventType; + data: TData; + timestamp: string; +} + +export interface WebhookDeliveryResult { + endpoint: string; + event: PaymentWebhookEventType; + timestamp: string; + ok: boolean; + status: number; + responseBody: string; +} diff --git a/apps/api/src/webhooks/webhooks.controller.ts b/apps/api/src/webhooks/webhooks.controller.ts new file mode 100644 index 0000000..84fbc1b --- /dev/null +++ b/apps/api/src/webhooks/webhooks.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { Public } from '../auth/decorators/public.decorator'; +import type { + DispatchWebhookRequest, + DispatchWebhookResponse, +} from './interfaces/dispatch-webhook.interface'; +import { WebhooksService } from './webhooks.service'; + +@Public() +@Controller('webhooks') +export class WebhooksController { + constructor(private readonly webhooksService: WebhooksService) {} + + @Post('dispatch') + async dispatch( + @Body() body: DispatchWebhookRequest, + ): Promise> { + return this.webhooksService.dispatchToEndpoints(body.endpoints, body.event, body.data); + } +} diff --git a/apps/api/src/webhooks/webhooks.module.ts b/apps/api/src/webhooks/webhooks.module.ts new file mode 100644 index 0000000..7b00455 --- /dev/null +++ b/apps/api/src/webhooks/webhooks.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { WebhooksController } from './webhooks.controller'; +import { WebhooksService } from './webhooks.service'; + +@Module({ + controllers: [WebhooksController], + providers: [WebhooksService], + exports: [WebhooksService], +}) +export class WebhooksModule {} diff --git a/apps/api/src/webhooks/webhooks.service.spec.ts b/apps/api/src/webhooks/webhooks.service.spec.ts new file mode 100644 index 0000000..4a12f5d --- /dev/null +++ b/apps/api/src/webhooks/webhooks.service.spec.ts @@ -0,0 +1,81 @@ +import { BadRequestException } from '@nestjs/common'; +import { WebhooksService } from './webhooks.service'; + +describe('WebhooksService', () => { + let service: WebhooksService; + + beforeEach(() => { + service = new WebhooksService(); + process.env.WEBHOOK_TIMEOUT_MS = '5000'; + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete process.env.WEBHOOK_TIMEOUT_MS; + }); + + it('creates a standardized payload', () => { + const payload = service.createPayload( + 'payment.confirmed', + { paymentId: 'pay_123', amount: '100.00' }, + new Date('2026-03-25T10:00:00.000Z'), + ); + + expect(payload).toEqual({ + event: 'payment.confirmed', + data: { paymentId: 'pay_123', amount: '100.00' }, + timestamp: '2026-03-25T10:00:00.000Z', + }); + }); + + it('signs webhook payloads when a secret is provided', () => { + const signature = service.createSignature('{"hello":"world"}', 'top-secret'); + + expect(signature).toHaveLength(64); + expect(signature).toMatch(/^[a-f0-9]+$/); + }); + + it('dispatches a webhook event to a merchant endpoint', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 202, + text: jest.fn().mockResolvedValue('accepted'), + }); + + global.fetch = fetchMock as typeof fetch; + + const delivery = await service.dispatchEvent( + { url: 'https://merchant.example/webhooks', secret: 'shared-secret' }, + 'payment.detected', + { paymentId: 'pay_456', merchantId: 'm_123' }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://merchant.example/webhooks', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'content-type': 'application/json', + 'x-stellar-pay-event': 'payment.detected', + 'x-stellar-pay-signature': expect.any(String), + 'x-stellar-pay-timestamp': expect.any(String), + }), + }), + ); + expect(delivery).toEqual({ + endpoint: 'https://merchant.example/webhooks', + event: 'payment.detected', + timestamp: expect.any(String), + ok: true, + status: 202, + responseBody: 'accepted', + }); + }); + + it('rejects empty endpoint lists', async () => { + await expect( + service.dispatchToEndpoints([], 'payment.created', { paymentId: 'pay_789' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/api/src/webhooks/webhooks.service.ts b/apps/api/src/webhooks/webhooks.service.ts new file mode 100644 index 0000000..dd7bd2e --- /dev/null +++ b/apps/api/src/webhooks/webhooks.service.ts @@ -0,0 +1,108 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { createHmac } from 'crypto'; +import { + WebhookDeliveryResult, + WebhookEndpoint, + WebhookEventPayload, + PaymentWebhookEventType, +} from './interfaces/webhook-event.interface'; + +const PAYMENT_WEBHOOK_EVENTS: PaymentWebhookEventType[] = [ + 'payment.created', + 'payment.detected', + 'payment.confirmed', + 'payment.failed', +]; + +@Injectable() +export class WebhooksService { + createPayload( + event: PaymentWebhookEventType, + data: TData, + date = new Date(), + ): WebhookEventPayload { + this.ensureSupportedEvent(event); + + return { + event, + data, + timestamp: date.toISOString(), + }; + } + + createSignature(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('hex'); + } + + async dispatchEvent( + endpoint: WebhookEndpoint, + event: PaymentWebhookEventType, + data: TData, + payload = this.createPayload(event, data), + ): Promise { + if (!endpoint.url) { + throw new BadRequestException('Webhook endpoint url is required'); + } + + const body = JSON.stringify(payload); + const headers: Record = { + 'content-type': 'application/json', + 'user-agent': 'stellar-pay-webhooks/1.0', + 'x-stellar-pay-event': payload.event, + 'x-stellar-pay-timestamp': payload.timestamp, + }; + + if (endpoint.secret) { + headers['x-stellar-pay-signature'] = this.createSignature(body, endpoint.secret); + } + + const response = await fetch(endpoint.url, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(this.getTimeoutMs()), + }); + + return { + endpoint: endpoint.url, + event: payload.event, + timestamp: payload.timestamp, + ok: response.ok, + status: response.status, + responseBody: await response.text(), + }; + } + + async dispatchToEndpoints( + endpoints: WebhookEndpoint[], + event: PaymentWebhookEventType, + data: TData, + ): Promise<{ payload: WebhookEventPayload; deliveries: WebhookDeliveryResult[] }> { + if (!Array.isArray(endpoints) || endpoints.length === 0) { + throw new BadRequestException('At least one webhook endpoint is required'); + } + + const payload = this.createPayload(event, data); + const deliveries = await Promise.all( + endpoints.map((endpoint) => this.dispatchEvent(endpoint, event, data, payload)), + ); + + return { + payload, + deliveries, + }; + } + + private ensureSupportedEvent(event: PaymentWebhookEventType) { + if (!PAYMENT_WEBHOOK_EVENTS.includes(event)) { + throw new BadRequestException(`Unsupported webhook event: ${event}`); + } + } + + private getTimeoutMs(): number { + const rawTimeout = process.env.WEBHOOK_TIMEOUT_MS; + const timeout = Number.parseInt(rawTimeout ?? '5000', 10); + + return Number.isNaN(timeout) || timeout <= 0 ? 5000 : timeout; + } +}