From e5e47f0f5331b184236beec8f11b573287d1e4d1 Mon Sep 17 00:00:00 2001 From: David-patrick-chuks Date: Wed, 25 Mar 2026 04:36:26 +0100 Subject: [PATCH 1/2] feat(api): implement webhook event dispatch --- apps/api/.env.example | 3 + apps/api/src/app.module.ts | 2 + .../interfaces/dispatch-webhook.interface.ts | 17 +++ .../interfaces/webhook-event.interface.ts | 25 ++++ apps/api/src/webhooks/webhooks.controller.ts | 20 ++++ apps/api/src/webhooks/webhooks.module.ts | 10 ++ .../api/src/webhooks/webhooks.service.spec.ts | 81 +++++++++++++ apps/api/src/webhooks/webhooks.service.ts | 108 ++++++++++++++++++ 8 files changed, 266 insertions(+) create mode 100644 apps/api/src/webhooks/interfaces/dispatch-webhook.interface.ts create mode 100644 apps/api/src/webhooks/interfaces/webhook-event.interface.ts create mode 100644 apps/api/src/webhooks/webhooks.controller.ts create mode 100644 apps/api/src/webhooks/webhooks.module.ts create mode 100644 apps/api/src/webhooks/webhooks.service.spec.ts create mode 100644 apps/api/src/webhooks/webhooks.service.ts 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; + } +} From 0c4a21bedac4e2b733de0d38cc5b3b52823c2c5a Mon Sep 17 00:00:00 2001 From: David-patrick-chuks Date: Mon, 27 Apr 2026 12:41:55 +0100 Subject: [PATCH 2/2] fix(frontend): resolve lint failures Made-with: Cursor --- apps/frontend/src/app/checkout/page.tsx | 4 +- apps/frontend/src/app/dashboard/page.tsx | 150 +++++++++--------- .../src/app/dashboard/subscriptions/page.tsx | 60 +++++-- .../src/app/dashboard/treasury/page.tsx | 82 +++++----- .../src/app/dashboard/webhooks/page.tsx | 62 ++++++-- 5 files changed, 208 insertions(+), 150 deletions(-) diff --git a/apps/frontend/src/app/checkout/page.tsx b/apps/frontend/src/app/checkout/page.tsx index 98dbd5b..95cb110 100644 --- a/apps/frontend/src/app/checkout/page.tsx +++ b/apps/frontend/src/app/checkout/page.tsx @@ -511,7 +511,7 @@ export default function PaymentCheckout() { onClick={handleBankPayment} className="w-full bg-white hover:bg-zinc-100 text-black h-14 rounded-xl mt-6 text-base transition-all duration-200 shadow-lg shadow-white/10" > - I've Completed the Transfer + I've Completed the Transfer

@@ -611,7 +611,7 @@ export default function PaymentCheckout() { onClick={handleCryptoDetection} className="w-full bg-white hover:bg-zinc-100 text-black h-14 rounded-xl mt-6 text-base transition-all duration-200 shadow-lg shadow-white/10" > - I've Sent the Payment + I've Sent the Payment

diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx index 68a7042..6ce48d5 100644 --- a/apps/frontend/src/app/dashboard/page.tsx +++ b/apps/frontend/src/app/dashboard/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { motion } from "motion/react"; +import { motion } from 'motion/react'; import { TrendingUp, TrendingDown, @@ -10,90 +10,90 @@ import { Clock, CheckCircle2, AlertCircle, -} from "lucide-react"; +} from 'lucide-react'; const stats = [ { - label: "Total Volume (30d)", - value: "$12,847,392.45", - change: "+18.2%", - trend: "up", + label: 'Total Volume (30d)', + value: '$12,847,392.45', + change: '+18.2%', + trend: 'up', icon: DollarSign, }, { - label: "Settlement Balance", - value: "$2,103,482.12", - change: "+5.4%", - trend: "up", + label: 'Settlement Balance', + value: '$2,103,482.12', + change: '+5.4%', + trend: 'up', icon: Activity, }, { - label: "Pending Settlements", - value: "47", - change: "-12.3%", - trend: "down", + label: 'Pending Settlements', + value: '47', + change: '-12.3%', + trend: 'down', icon: Clock, }, { - label: "Reserve Ratio", - value: "127.3%", - change: "+2.1%", - trend: "up", + label: 'Reserve Ratio', + value: '127.3%', + change: '+2.1%', + trend: 'up', icon: CheckCircle2, }, ]; const assets = [ - { symbol: "sUSDC", balance: "1,245,382.45", usd: "1,245,382.45", change: "+2.3%" }, - { symbol: "sBTC", balance: "12.4583", usd: "625,847.92", change: "+5.1%" }, - { symbol: "sETH", balance: "145.2341", usd: "232,251.75", change: "-1.2%" }, + { symbol: 'sUSDC', balance: '1,245,382.45', usd: '1,245,382.45', change: '+2.3%' }, + { symbol: 'sBTC', balance: '12.4583', usd: '625,847.92', change: '+5.1%' }, + { symbol: 'sETH', balance: '145.2341', usd: '232,251.75', change: '-1.2%' }, ]; const transactions = [ { - id: "pay_9k2j3n4k5j6h", - type: "Payment", - asset: "sUSDC", - amount: "+12,450.00", - status: "completed", - time: "2m ago", - hash: "0x7a8f9b2c...4e5d6f1a", + id: 'pay_9k2j3n4k5j6h', + type: 'Payment', + asset: 'sUSDC', + amount: '+12,450.00', + status: 'completed', + time: '2m ago', + hash: '0x7a8f9b2c...4e5d6f1a', }, { - id: "pay_8h1j2k3l4m5n", - type: "Redemption", - asset: "sBTC", - amount: "-0.2341", - status: "completed", - time: "5m ago", - hash: "0x3c4d5e6f...7a8b9c0d", + id: 'pay_8h1j2k3l4m5n', + type: 'Redemption', + asset: 'sBTC', + amount: '-0.2341', + status: 'completed', + time: '5m ago', + hash: '0x3c4d5e6f...7a8b9c0d', }, { - id: "pay_7g8h9i0j1k2l", - type: "Payment", - asset: "sETH", - amount: "+5.4321", - status: "pending", - time: "8m ago", - hash: "0x1a2b3c4d...5e6f7g8h", + id: 'pay_7g8h9i0j1k2l', + type: 'Payment', + asset: 'sETH', + amount: '+5.4321', + status: 'pending', + time: '8m ago', + hash: '0x1a2b3c4d...5e6f7g8h', }, { - id: "pay_6f7g8h9i0j1k", - type: "Settlement", - asset: "sUSDC", - amount: "-8,230.50", - status: "completed", - time: "12m ago", - hash: "0x9h8g7f6e...5d4c3b2a", + id: 'pay_6f7g8h9i0j1k', + type: 'Settlement', + asset: 'sUSDC', + amount: '-8,230.50', + status: 'completed', + time: '12m ago', + hash: '0x9h8g7f6e...5d4c3b2a', }, { - id: "pay_5e6f7g8h9i0j", - type: "Payment", - asset: "sBTC", - amount: "+0.1234", - status: "completed", - time: "15m ago", - hash: "0x2b3c4d5e...6f7g8h9i", + id: 'pay_5e6f7g8h9i0j', + type: 'Payment', + asset: 'sBTC', + amount: '+0.1234', + status: 'completed', + time: '15m ago', + hash: '0x2b3c4d5e...6f7g8h9i', }, ]; @@ -109,9 +109,7 @@ export default function OverviewPage() { > Overview -

- Real-time metrics and settlement status -

+

Real-time metrics and settlement status

{/* Stats Grid */} @@ -136,17 +134,17 @@ export default function OverviewPage() { repeatDelay: 2, }} /> - +
- {stat.trend === "up" ? ( + {stat.trend === 'up' ? ( ) : ( @@ -154,7 +152,7 @@ export default function OverviewPage() { {stat.change}
- +
{stat.value}
{stat.label}
@@ -200,7 +198,9 @@ export default function OverviewPage() {
${asset.usd}
-
+
{asset.change}
@@ -211,7 +211,7 @@ export default function OverviewPage() {
@@ -228,7 +228,7 @@ export default function OverviewPage() { transition={{ delay: 0.3 }} >

Reserve Health

- +
{/* Background circle */} @@ -249,9 +249,9 @@ export default function OverviewPage() { stroke="rgba(255,255,255,0.3)" strokeWidth="12" strokeLinecap="round" - initial={{ strokeDasharray: "0 440" }} - animate={{ strokeDasharray: "350 440" }} - transition={{ duration: 1.5, ease: "easeOut" }} + initial={{ strokeDasharray: '0 440' }} + animate={{ strokeDasharray: '350 440' }} + transition={{ duration: 1.5, ease: 'easeOut' }} />
@@ -336,18 +336,20 @@ export default function OverviewPage() { {tx.asset} - + {tx.amount} - {tx.status === "completed" ? ( + {tx.status === 'completed' ? ( ) : ( diff --git a/apps/frontend/src/app/dashboard/subscriptions/page.tsx b/apps/frontend/src/app/dashboard/subscriptions/page.tsx index cf1fb17..6c658f7 100644 --- a/apps/frontend/src/app/dashboard/subscriptions/page.tsx +++ b/apps/frontend/src/app/dashboard/subscriptions/page.tsx @@ -1,12 +1,36 @@ 'use client'; -import { motion } from "motion/react"; -import { Plus, RefreshCw, CheckCircle2, AlertCircle } from "lucide-react"; +import { motion } from 'motion/react'; +import { Plus, CheckCircle2, AlertCircle } from 'lucide-react'; const subscriptions = [ - { id: "sub_1a2b3c4d", customer: "Acme Corp", plan: "Enterprise", amount: "999.00", interval: "Monthly", status: "active", nextBilling: "2026-04-03" }, - { id: "sub_5e6f7g8h", customer: "TechStart Inc", plan: "Pro", amount: "299.00", interval: "Monthly", status: "active", nextBilling: "2026-04-15" }, - { id: "sub_9i0j1k2l", customer: "BuildCo", plan: "Starter", amount: "99.00", interval: "Monthly", status: "past_due", nextBilling: "2026-03-01" }, + { + id: 'sub_1a2b3c4d', + customer: 'Acme Corp', + plan: 'Enterprise', + amount: '999.00', + interval: 'Monthly', + status: 'active', + nextBilling: '2026-04-03', + }, + { + id: 'sub_5e6f7g8h', + customer: 'TechStart Inc', + plan: 'Pro', + amount: '299.00', + interval: 'Monthly', + status: 'active', + nextBilling: '2026-04-15', + }, + { + id: 'sub_9i0j1k2l', + customer: 'BuildCo', + plan: 'Starter', + amount: '99.00', + interval: 'Monthly', + status: 'past_due', + nextBilling: '2026-03-01', + }, ]; export default function SubscriptionsPage() { @@ -25,9 +49,9 @@ export default function SubscriptionsPage() {
{[ - { label: "Active Subscriptions", value: "2" }, - { label: "Monthly Recurring Revenue", value: "$1,298.00" }, - { label: "Past Due", value: "1" }, + { label: 'Active Subscriptions', value: '2' }, + { label: 'Monthly Recurring Revenue', value: '$1,298.00' }, + { label: 'Past Due', value: '1' }, ].map((stat, index) => ( - +