diff --git a/src/config/app.config.ts b/src/config/app.config.ts index 7863d95..e1ccb53 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -1,7 +1,45 @@ import { registerAs } from '@nestjs/config'; +const API_PREFIX = process.env.API_PREFIX || 'api'; + +function resolveCallbackUrl(): string { + if (process.env.PAYMENT_CALLBACK_URL) return process.env.PAYMENT_CALLBACK_URL; + // Deliberately NOT falling back to APP_URL: that is the public *app* + // origin used in email links, not the API origin, and silently + // building an API path on top of it would be wrong the moment a + // frontend exists. RENDER_EXTERNAL_URL is injected by Render and is + // genuinely this service's own origin. + const origin = process.env.RENDER_EXTERNAL_URL; + if (!origin) return ''; + return `${origin.replace(/\/+$/, '')}/${API_PREFIX}/payments/callback`; +} + export default registerAs('app', () => ({ nodeEnv: process.env.NODE_ENV || 'development', port: parseInt(process.env.PORT ?? '3000', 10), - apiPrefix: process.env.API_PREFIX || 'api', + apiPrefix: API_PREFIX, + + /** + * Where the payment gateway redirects the buyer after checkout. + * + * CrowdPass has no web frontend — the buyer is inside the mobile app's + * in-app browser — so this points at the API's own + * `GET /api/payments/callback`, which settles the transaction and + * renders a result page. The mobile WebView watches for this path, + * closes the browser, and refreshes. + * + * Resolution order: + * 1. PAYMENT_CALLBACK_URL — explicit override (a real frontend page, + * or a deep link, once one exists) + * 2. RENDER_EXTERNAL_URL — injected automatically by Render, so a + * deploy needs no configuration at all + * + * There is deliberately NO localhost fallback. This key did not exist + * at all before, so `configService.get('app.paymentCallbackUrl')` + * always returned undefined and the hardcoded localhost default always + * won — in production too. Every fiat buyer was redirected to + * localhost:3000 after paying. `TicketsService` now refuses to boot in + * production when this resolves to nothing. + */ + paymentCallbackUrl: resolveCallbackUrl(), })); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index f8a6d27..27444a2 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -110,6 +110,13 @@ export const envValidationSchema = Joi.object({ APP_URL: Joi.string().uri().optional(), SUPPORT_EMAIL: Joi.string().email().optional(), + // Where the payment gateway redirects the buyer after checkout. + // Optional here rather than required because the fail-fast lives in + // TicketsService, which can report the far more useful "fiat checkout + // is misconfigured" instead of a generic Joi error — and because on + // Render it resolves automatically from RENDER_EXTERNAL_URL. + PAYMENT_CALLBACK_URL: Joi.string().uri().optional(), + // Twilio TWILIO_ACCOUNT_SID: Joi.string().required(), TWILIO_AUTH_TOKEN: Joi.string().required(), diff --git a/src/tickets/tickets.service.ts b/src/tickets/tickets.service.ts index a553b78..adcd712 100644 --- a/src/tickets/tickets.service.ts +++ b/src/tickets/tickets.service.ts @@ -44,11 +44,31 @@ export class TicketsService { private readonly wallets: WalletsService, configService: ConfigService, ) { - // Where the gateway redirects after checkout. For local dev this - // is fine as a relative-ish URL; staging/prod override via env. + // Where the gateway redirects the buyer after checkout. + // + // This previously read a config key that did not exist on the `app` + // namespace, so it always resolved to undefined and the hardcoded + // localhost fallback always won — in production too. Every fiat + // buyer was sent to localhost:3000 after paying, and the payment + // was never settled. Fail fast rather than shipping that again. + const isProduction = + configService.get('app.nodeEnv') === 'production'; + const callbackUrl = configService.get('app.paymentCallbackUrl'); + if (!callbackUrl) { + if (isProduction) { + throw new Error( + 'PAYMENT_CALLBACK_URL (or RENDER_EXTERNAL_URL) must be set in ' + + 'production — without it buyers are redirected to a dead URL ' + + 'after paying and the transaction is never settled.', + ); + } + this.logger.warn( + 'PAYMENT_CALLBACK_URL/RENDER_EXTERNAL_URL unset — falling back to ' + + 'localhost for the post-checkout redirect.', + ); + } this.checkoutCallbackUrl = - configService.get('app.paymentCallbackUrl') ?? - 'http://localhost:3000/api/payments/callback'; + callbackUrl || 'http://localhost:3000/api/payments/callback'; } async purchase(dto: PurchaseTicketDto, ctx: PurchaseContext = {}) { diff --git a/src/webhooks/settlement.controller.spec.ts b/src/webhooks/settlement.controller.spec.ts new file mode 100644 index 0000000..b27ce00 --- /dev/null +++ b/src/webhooks/settlement.controller.spec.ts @@ -0,0 +1,269 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { PaymentProvider, TransactionStatus } from '@prisma/client'; +import { SettlementController } from './settlement.controller'; + +function txn(overrides: Record = {}) { + return { + id: 't-1', + reference: 'HOSTIT_TXN_ABC123', + provider: PaymentProvider.PAYSTACK, + status: TransactionStatus.PENDING, + amount: 5000, + currency: 'NGN', + ...overrides, + }; +} + +/** Minimal express Response stand-in that captures what was sent. */ +function fakeRes() { + const sent = { + status: 0, + type: '', + body: '', + headers: {} as Record, + }; + const res = { + status(code: number) { + sent.status = code; + return this; + }, + type(t: string) { + sent.type = t; + return this; + }, + header(k: string, v: string) { + sent.headers[k] = v; + return this; + }, + send(body: string) { + sent.body = body; + return this; + }, + }; + return { res, sent }; +} + +function setup(opts: { transaction?: unknown; verify?: unknown } = {}) { + const findUnique = jest + .fn() + .mockResolvedValue( + opts.transaction === undefined ? txn() : opts.transaction, + ); + const verifyPayment = jest.fn().mockResolvedValue( + opts.verify ?? { + status: 'success', + amount: 5000, + reference: 'HOSTIT_TXN_ABC123', + providerReference: '123456', + channel: 'card', + paidAt: new Date('2026-08-01T10:00:00Z'), + }, + ); + const handleSuccess = jest.fn().mockResolvedValue(undefined); + const handleFailure = jest.fn().mockResolvedValue(undefined); + + const controller = new SettlementController( + { verifyPayment } as never, + { handleSuccess, handleFailure } as never, + { transaction: { findUnique } } as never, + ); + + return { + controller, + findUnique, + verifyPayment, + handleSuccess, + handleFailure, + }; +} + +describe('SettlementController.verify', () => { + it('settles a PENDING transaction the gateway reports as paid', async () => { + const { controller, handleSuccess } = setup(); + + const res = await controller.verify('HOSTIT_TXN_ABC123'); + + expect(handleSuccess).toHaveBeenCalledTimes(1); + expect(handleSuccess).toHaveBeenCalledWith({ + reference: 'HOSTIT_TXN_ABC123', + provider: PaymentProvider.PAYSTACK, + providerReference: '123456', + amount: 5000, + channel: 'card', + paidAt: new Date('2026-08-01T10:00:00Z'), + }); + expect(res).toEqual({ + reference: 'HOSTIT_TXN_ABC123', + status: TransactionStatus.SUCCESS, + settled: true, + }); + }); + + it('does not re-verify an already-settled transaction', async () => { + const { controller, verifyPayment, handleSuccess } = setup({ + transaction: txn({ status: TransactionStatus.SUCCESS }), + }); + + const res = await controller.verify('HOSTIT_TXN_ABC123'); + + expect(verifyPayment).not.toHaveBeenCalled(); + expect(handleSuccess).not.toHaveBeenCalled(); + expect(res.settled).toBe(true); + }); + + it('releases inventory when the gateway reports failure', async () => { + const { controller, handleFailure, handleSuccess } = setup({ + verify: { + status: 'failed', + amount: 0, + reference: 'x', + providerReference: 'y', + }, + }); + + const res = await controller.verify('HOSTIT_TXN_ABC123'); + + expect(handleFailure).toHaveBeenCalledTimes(1); + expect(handleSuccess).not.toHaveBeenCalled(); + expect(res.status).toBe(TransactionStatus.FAILED); + }); + + it('stays PENDING while the gateway is still processing', async () => { + const { controller, handleSuccess, handleFailure } = setup({ + verify: { + status: 'pending', + amount: 0, + reference: 'x', + providerReference: 'y', + }, + }); + + const res = await controller.verify('HOSTIT_TXN_ABC123'); + + expect(handleSuccess).not.toHaveBeenCalled(); + expect(handleFailure).not.toHaveBeenCalled(); + expect(res).toEqual({ + reference: 'HOSTIT_TXN_ABC123', + status: TransactionStatus.PENDING, + settled: false, + }); + }); + + it('refuses to mint on an underpayment', async () => { + const { controller, handleSuccess } = setup({ + verify: { + status: 'success', + amount: 100, // gateway says NGN 100, order was NGN 5000 + reference: 'HOSTIT_TXN_ABC123', + providerReference: '123456', + }, + }); + + const res = await controller.verify('HOSTIT_TXN_ABC123'); + + expect(handleSuccess).not.toHaveBeenCalled(); + expect(res.settled).toBe(false); + expect(res.error).toMatch(/does not match/i); + }); + + it('leaves crypto transactions to the Circle webhook', async () => { + const { controller, verifyPayment } = setup({ + transaction: txn({ provider: PaymentProvider.CRYPTO }), + }); + + const res = await controller.verify('HOSTIT_TXN_ABC123'); + + expect(verifyPayment).not.toHaveBeenCalled(); + expect(res.status).toBe(TransactionStatus.PENDING); + }); + + it('404s on an unknown reference', async () => { + const { controller } = setup({ transaction: null }); + + await expect(controller.verify('nope')).rejects.toThrow(NotFoundException); + }); + + it('rejects a blank reference before touching the database', async () => { + const { controller, findUnique } = setup(); + + await expect(controller.verify(' ')).rejects.toThrow(BadRequestException); + expect(findUnique).not.toHaveBeenCalled(); + }); +}); + +describe('SettlementController.callback', () => { + it('settles and reports success in a machine-readable form', async () => { + const { controller, handleSuccess } = setup(); + const { res, sent } = fakeRes(); + + await controller.callback(res as never, 'HOSTIT_TXN_ABC123'); + + expect(handleSuccess).toHaveBeenCalledTimes(1); + expect(sent.status).toBe(200); + expect(sent.type).toBe('text/html'); + expect(sent.body).toContain(''); + expect(sent.body).toContain(''); + expect(sent.body).toContain('data-reference="HOSTIT_TXN_ABC123"'); + }); + + it("accepts Paystack's trxref and Monnify's paymentReference", async () => { + const viaTrxref = setup(); + await viaTrxref.controller.callback( + fakeRes().res as never, + undefined, + 'HOSTIT_TXN_ABC123', + ); + expect(viaTrxref.handleSuccess).toHaveBeenCalledTimes(1); + + const viaMonnify = setup(); + await viaMonnify.controller.callback( + fakeRes().res as never, + undefined, + undefined, + 'HOSTIT_TXN_ABC123', + ); + expect(viaMonnify.handleSuccess).toHaveBeenCalledTimes(1); + }); + + it('renders a page instead of throwing when the reference is unknown', async () => { + const { controller } = setup({ transaction: null }); + const { res, sent } = fakeRes(); + + await controller.callback(res as never, 'nope'); + + // The buyer may already have been charged — a 500 here would be the + // worst possible outcome. + expect(sent.status).toBe(200); + expect(sent.body).toContain(''); + expect(sent.body).toContain('could not confirm'); + }); + + it('renders a page when no reference is supplied at all', async () => { + const { controller, findUnique } = setup(); + const { res, sent } = fakeRes(); + + await controller.callback(res as never); + + expect(findUnique).not.toHaveBeenCalled(); + expect(sent.body).toContain('No payment reference'); + }); + + it('escapes the reference so it cannot inject markup', async () => { + const { controller } = setup({ transaction: null }); + const { res, sent } = fakeRes(); + + await controller.callback(res as never, '">'); + + expect(sent.body).not.toContain(''); + expect(sent.body).toContain('<script>'); + }); + + it('never caches the result page', async () => { + const { controller } = setup(); + const { res, sent } = fakeRes(); + + await controller.callback(res as never, 'HOSTIT_TXN_ABC123'); + + expect(sent.headers['Cache-Control']).toBe('no-store'); + }); +}); diff --git a/src/webhooks/settlement.controller.ts b/src/webhooks/settlement.controller.ts new file mode 100644 index 0000000..7ec5848 --- /dev/null +++ b/src/webhooks/settlement.controller.ts @@ -0,0 +1,326 @@ +import { + BadRequestException, + Controller, + Get, + Logger, + NotFoundException, + Query, + Res, +} from '@nestjs/common'; +import { + ApiExcludeEndpoint, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { PaymentProvider, TransactionStatus } from '@prisma/client'; +import type { Response } from 'express'; +import { Public } from '../common/decorators/public.decorator'; +import { PaymentsService } from '../payments/payments.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { WebhooksService } from './webhooks.service'; + +/** Outcome of a settlement attempt, shared by both routes below. */ +export interface SettleOutcome { + reference: string; + status: TransactionStatus; + settled: boolean; + /** Set when settlement was refused rather than merely unfinished. */ + error?: string; +} + +/** + * Payment settlement surface. + * + * Why this exists: settlement used to depend entirely on the provider + * webhook. When that webhook never arrived — which is the current state, + * with zero fiat transactions ever reaching SUCCESS — transactions sat + * PENDING forever: money taken by the gateway, no ticket minted, nothing + * on the organizer dashboard, and no way to recover. Both routes here + * give the buyer's own return trip a chance to settle the transaction. + * + * Both funnel into the *same* `WebhooksService` methods the webhook uses + * rather than reimplementing settlement. Those are already idempotent — + * they no-op on any transaction that isn't PENDING — so a webhook and a + * callback racing each other is safe, and whichever lands first wins. + * + * Lives in WebhooksModule rather than PaymentsModule because + * `WebhooksService` needs `MintQueueService`, and + * PaymentsModule -> BlockchainModule -> TicketsModule -> PaymentsModule + * would be a dependency cycle. The route paths are still `/payments/*`. + */ +@ApiTags('Payments') +@Controller('payments') +export class SettlementController { + private readonly logger = new Logger(SettlementController.name); + + constructor( + private readonly payments: PaymentsService, + private readonly webhooks: WebhooksService, + private readonly prisma: PrismaService, + ) {} + + /** + * Where the payment gateway redirects the buyer after checkout. + * + * CrowdPass has no web frontend — the buyer is inside the mobile app's + * in-app browser. So this page has to live on the API: it is the + * stable https URL the gateway redirects to and the WebView watches + * for. On navigation to this path the app should close the browser and + * refresh its state. + * + * Settlement happens here, on the redirect itself, so the ticket is + * minted even if the app never calls `/payments/verify` and even if the + * provider webhook never arrives. The HTML is fully self-contained — + * no external assets — because it renders inside a WebView that may be + * on poor connectivity. + * + * Uses `@Res()` and writes the response directly so the global + * TransformInterceptor can't wrap the HTML in the JSON envelope. + * + * Providers disagree on the query parameter name, so accept all of + * them: Paystack sends `reference` and `trxref`, Monnify sends + * `paymentReference`. + */ + @Get('callback') + @Public() + @ApiExcludeEndpoint() + async callback( + @Res() res: Response, + @Query('reference') reference?: string, + @Query('trxref') trxref?: string, + @Query('paymentReference') paymentReference?: string, + ): Promise { + const ref = (reference || trxref || paymentReference || '').trim(); + + let outcome: SettleOutcome; + if (!ref) { + outcome = { + reference: '', + status: TransactionStatus.PENDING, + settled: false, + error: 'No payment reference was supplied.', + }; + } else { + try { + outcome = await this.settle(ref); + } catch (err) { + // Never surface a stack trace into the buyer's browser, and + // never 500 here — the money may well have left their account, + // so the page must still render something coherent. + this.logger.error( + `Callback settlement failed for ${ref}: ${(err as Error).message}`, + ); + outcome = { + reference: ref, + status: TransactionStatus.PENDING, + settled: false, + error: + 'We could not confirm this payment automatically. If you were ' + + 'charged, your ticket will be issued shortly.', + }; + } + } + + res + .status(200) + .type('text/html') + .header('Cache-Control', 'no-store') + .send(this.page(outcome)); + } + + /** + * Machine-readable settlement check for the mobile app. Safe to poll — + * bank transfers in particular can stay pending for minutes. + */ + @Get('verify') + @Public() + @ApiOperation({ + summary: + 'Verify and settle a transaction by reference (called on return from checkout)', + }) + @ApiQuery({ name: 'reference', required: true, type: String }) + async verify(@Query('reference') reference: string): Promise { + if (!reference?.trim()) { + throw new BadRequestException('reference is required'); + } + return this.settle(reference.trim()); + } + + // ---------- internals ---------- + + private async settle(reference: string): Promise { + const transaction = await this.prisma.transaction.findUnique({ + where: { reference }, + select: { + id: true, + reference: true, + provider: true, + status: true, + amount: true, + currency: true, + }, + }); + + if (!transaction) { + throw new NotFoundException('Transaction not found'); + } + + // Already terminal — report and stop. Re-verifying a settled + // transaction against the gateway buys nothing and costs a round trip. + if (transaction.status !== TransactionStatus.PENDING) { + return this.shape(transaction.reference, transaction.status); + } + + // Crypto settles through the Circle webhook, not a fiat gateway. + if ( + transaction.provider === PaymentProvider.CRYPTO || + transaction.provider === PaymentProvider.BLOCKRADAR + ) { + return this.shape(transaction.reference, transaction.status); + } + + const result = await this.payments.verifyPayment( + transaction.provider, + transaction.reference, + ); + + if (result.status === 'success') { + // Guard against a tampered or mismatched amount before minting. + // `verifyPayment` normalizes to NGN, as does Transaction.amount. + const expected = Number(transaction.amount); + if (result.amount < expected) { + this.logger.error( + `Underpayment on ${transaction.reference}: gateway reported ` + + `${result.amount} ${transaction.currency}, expected ${expected}. ` + + `Not settling.`, + ); + return { + reference: transaction.reference, + status: TransactionStatus.PENDING, + settled: false, + error: 'Payment amount does not match the order.', + }; + } + + await this.webhooks.handleSuccess({ + reference: transaction.reference, + provider: transaction.provider, + providerReference: result.providerReference, + amount: result.amount, + channel: result.channel, + paidAt: result.paidAt, + }); + this.logger.log( + `Settled ${transaction.reference} on return from checkout ` + + `(${transaction.provider})`, + ); + return this.shape(transaction.reference, TransactionStatus.SUCCESS); + } + + if (result.status === 'failed') { + await this.webhooks.handleFailure({ + reference: transaction.reference, + provider: transaction.provider, + }); + return this.shape(transaction.reference, TransactionStatus.FAILED); + } + + // Still pending at the gateway — the caller should poll. + return this.shape(transaction.reference, TransactionStatus.PENDING); + } + + private shape(reference: string, status: TransactionStatus): SettleOutcome { + return { + reference, + status, + settled: status === TransactionStatus.SUCCESS, + }; + } + + /** + * Minimal self-contained result page. The `` + * tags and `#cp-result` data attributes are the contract for the + * mobile WebView — it can read either instead of scraping visible + * copy, which is free to change. + */ + page(outcome: SettleOutcome): string { + const ok = outcome.settled; + const failed = outcome.status === TransactionStatus.FAILED; + + const heading = ok + ? 'Payment confirmed' + : failed + ? 'Payment failed' + : 'Payment processing'; + const body = outcome.error + ? outcome.error + : ok + ? 'Your ticket is being issued. You can return to the app.' + : failed + ? 'This payment did not go through. You have not been charged.' + : 'This is taking a moment to confirm. You can return to the app — ' + + 'your ticket will appear once payment clears.'; + const accent = ok ? '#0f9d58' : failed ? '#d93025' : '#f4b400'; + + return ` + + + + + + + +${esc(heading)} — CrowdPass + + + +
+
${ok ? '✓' : failed ? '×' : '…'}
+

${esc(heading)}

+

${esc(body)}

+ ${outcome.reference ? `
${esc(outcome.reference)}
` : ''} +
+ +`; + } +} + +/** Escape untrusted values before interpolating into the result page. */ +function esc(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/webhooks/webhooks.controller.ts b/src/webhooks/webhooks.controller.ts index 8f734ca..086c4c9 100644 --- a/src/webhooks/webhooks.controller.ts +++ b/src/webhooks/webhooks.controller.ts @@ -10,6 +10,7 @@ import { UseGuards, } from '@nestjs/common'; import { InjectQueue } from '@nestjs/bullmq'; +import * as crypto from 'crypto'; import { ApiExcludeController } from '@nestjs/swagger'; import { PaymentProvider, Prisma, WebhookSource } from '@prisma/client'; import { Queue } from 'bullmq'; @@ -85,6 +86,14 @@ export class WebhooksController { const event = body.event; const data = body.data; + + await this.recordWebhook( + WebhookSource.PAYSTACK, + raw, + event, + body as unknown as Prisma.InputJsonValue, + ); + if (!data?.reference) return { received: true }; if (event === 'charge.success') { @@ -145,6 +154,14 @@ export class WebhooksController { const eventType = body.eventType; const data = body.eventData; + + await this.recordWebhook( + WebhookSource.MONNIFY, + raw, + eventType, + body as unknown as Prisma.InputJsonValue, + ); + if (!data?.paymentReference) return { received: true }; if (eventType === 'SUCCESSFUL_TRANSACTION') { @@ -260,4 +277,63 @@ export class WebhooksController { return { received: true }; } + + /** + * Persist an accepted (signature-verified) Paystack/Monnify webhook to + * `webhook_events` for forensics. + * + * This exists because fiat webhooks previously left no trace at all — + * only the Circle handler wrote audit rows. When settlement silently + * stopped working there was no way to tell "the webhook arrived and + * failed" from "the webhook never arrived", which is exactly the + * question that mattered. + * + * Two deliberate differences from the Circle handler: + * + * 1. The dedup key is a hash of the raw body, not a provider event id. + * Neither provider sends a reliable per-delivery id, and retries are + * byte-identical, so content-addressing is the correct key. + * 2. A duplicate does NOT short-circuit processing. Circle can bail + * early because its work is queued and retried independently; these + * handlers settle synchronously, so dropping a retry would strand a + * payment whose first delivery failed. `handleSuccess`/`handleFailure` + * are already idempotent, so re-processing is safe. + * + * Never throws — an audit write must not be able to reject a webhook + * and trigger provider retries for a payment we actually handled. + */ + private async recordWebhook( + source: WebhookSource, + raw: Buffer, + type: string | undefined, + payload: Prisma.InputJsonValue, + ): Promise { + const digest = crypto.createHash('sha256').update(raw).digest('hex'); + try { + await this.prisma.webhookEvent.create({ + data: { + source, + notificationId: digest, + type: type ?? null, + payload, + signatureValid: true, + processedAt: new Date(), + }, + select: { id: true }, + }); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + this.logger.log( + `${source} webhook: redelivery of ${digest.slice(0, 12)} — processing anyway`, + ); + return; + } + this.logger.error( + `${source} webhook: failed to write audit row: ${(err as Error).message}`, + ); + } + } } diff --git a/src/webhooks/webhooks.module.ts b/src/webhooks/webhooks.module.ts index 83a4ad3..488e086 100644 --- a/src/webhooks/webhooks.module.ts +++ b/src/webhooks/webhooks.module.ts @@ -5,6 +5,7 @@ import { BlockchainModule } from '../blockchain/blockchain.module'; import { CircleModule } from '../circle/circle.module'; import { CIRCLE_WEBHOOK_QUEUE } from '../blockchain/circle-webhook.queue'; import { WebhooksController } from './webhooks.controller'; +import { SettlementController } from './settlement.controller'; import { WebhooksService } from './webhooks.service'; import { MonnifyIpGuard } from './guards/monnify-ip.guard'; import { CircleIpGuard } from './guards/circle-ip.guard'; @@ -19,7 +20,10 @@ import { CircleIpGuard } from './guards/circle-ip.guard'; // BlockchainModule); registerQueue is idempotent. BullModule.registerQueue({ name: CIRCLE_WEBHOOK_QUEUE }), ], - controllers: [WebhooksController], + // SettlementController serves `/payments/*`, not `/webhooks/*`. It + // lives here because it reuses WebhooksService for settlement — see + // the class doc for why it cannot sit in PaymentsModule. + controllers: [WebhooksController, SettlementController], providers: [WebhooksService, MonnifyIpGuard, CircleIpGuard], }) export class WebhooksModule {}