From a667f5b2df910706de6558b7bf96c522720fb495 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 30 Jun 2026 15:51:47 +0300 Subject: [PATCH] add test payment event --- .../payments/payment-events.consumer.ts | 5 + .../outbox/dto/test-payment-event.dto.ts | 92 +++++++++++++++++++ .../src/modules/outbox/outbox.module.ts | 7 ++ .../modules/outbox/test-events.controller.ts | 88 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/test-events.controller.ts diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index 291402240..f13191c48 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -29,6 +29,11 @@ export class PaymentEventsConsumer { }, }) async handle(event: PaymentEvent): Promise { + // Logged the instant RabbitMQ delivers the message, before any DB work — proves the + // payment -> passenger broker connection works even if processing later fails/hangs. + this.logger.log( + `RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`, + ); try { const result = await this.paymentsService.handlePaymentEvent( event as unknown as PaymentEventDto, diff --git a/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts b/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts new file mode 100644 index 000000000..700d64717 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts @@ -0,0 +1,92 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsEnum, + IsIn, + IsInt, + IsOptional, + IsPositive, + IsString, +} from "class-validator"; +import { + PaymentEventType, + PaymentReferenceType, + PaymentService, + ProviderMethod, +} from "@edr/types"; + +/** + * Body for the dev-only POST /test/payment-event endpoint. Every field is optional — the + * controller fills sensible defaults so an empty `{}` publishes a `payment.succeeded` to the + * passenger queue. Set `referenceId` to a real bookingId to exercise the consumer's side effects + * (seat confirm / ticket issue); leave it blank to only prove RabbitMQ delivery. + */ +export class TestPaymentEventDto { + @ApiPropertyOptional({ + enum: ["payment.succeeded", "payment.failed"], + default: "payment.succeeded", + }) + @IsOptional() + @IsIn(["payment.succeeded", "payment.failed"]) + eventType?: PaymentEventType; + + @ApiPropertyOptional({ enum: PaymentService, default: PaymentService.PASSENGER }) + @IsOptional() + @IsEnum(PaymentService) + service?: PaymentService; + + @ApiPropertyOptional({ + enum: PaymentReferenceType, + default: PaymentReferenceType.BOOKING, + }) + @IsOptional() + @IsEnum(PaymentReferenceType) + referenceType?: PaymentReferenceType; + + @ApiPropertyOptional({ + description: "Domain order id (e.g. bookingId). Defaults to a random uuid.", + }) + @IsOptional() + @IsString() + referenceId?: string; + + @ApiPropertyOptional({ description: "Defaults to a random uuid." }) + @IsOptional() + @IsString() + intentId?: string; + + @ApiPropertyOptional({ description: "Defaults to test-." }) + @IsOptional() + @IsString() + merchantOrderId?: string; + + @ApiPropertyOptional({ enum: ProviderMethod, default: ProviderMethod.WAAFI }) + @IsOptional() + @IsEnum(ProviderMethod) + provider?: ProviderMethod; + + @ApiPropertyOptional({ default: 10000, description: "Amount in minor units." }) + @IsOptional() + @IsInt() + @IsPositive() + amountMinor?: number; + + @ApiPropertyOptional({ default: "ETB" }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: "Only used for payment.succeeded." }) + @IsOptional() + @IsString() + providerTxnId?: string; + + @ApiPropertyOptional({ description: "Only used for payment.failed." }) + @IsOptional() + @IsString() + failureCode?: string; + + @ApiPropertyOptional({ description: "Only used for payment.failed." }) + @IsOptional() + @IsString() + failureMessage?: string; +} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts index eae5515e8..5d8d0cc8a 100644 --- a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts +++ b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts @@ -11,6 +11,12 @@ import { OutboxRepository } from "./outbox.repository"; import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher"; import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher"; import { RabbitMqPaymentEventPublisher } from "./publisher/rabbitmq-payment-event-publisher"; +import { TestEventsController } from "./test-events.controller"; + +// Dev-only harness to publish a synthetic payment event straight to the broker. +// Never registered in production, so the endpoint cannot exist there. +const testControllers = + process.env.NODE_ENV !== "production" ? [TestEventsController] : []; const rabbitImports = isRabbitPublisher() ? [ @@ -42,6 +48,7 @@ const rabbitImports = isRabbitPublisher() HttpModule, ...rabbitImports, ], + controllers: testControllers, providers: [ OutboxRepository, OutboxRelayService, diff --git a/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts b/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts new file mode 100644 index 000000000..d4396a754 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; +import { Body, Controller, Inject, Logger, Post } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { + PaymentEvent, + PaymentReferenceType, + PaymentService, + ProviderMethod, + paymentRoutingKey, +} from "@edr/types"; +import { + PAYMENT_EVENT_PUBLISHER, + PaymentEventPublisher, +} from "./publisher/payment-event-publisher"; +import { TestPaymentEventDto } from "./dto/test-payment-event.dto"; + +/** + * DEV-ONLY test harness. Publishes a synthetic payment event through the real + * PaymentEventPublisher (RabbitMQ in dev), so the passenger/freight consumer receives it + * exactly as in production — without creating an intent or going through a booking + provider + * flow. Registered only when NODE_ENV !== "production" (see OutboxModule); never reachable in prod. + * + * Quick check (no body): POST /test/payment-event -> publishes payment.passenger.succeeded. + * Real side effects: pass a real bookingId as `referenceId`. + */ +@ApiTags("Dev test (non-production)") +@Controller("test") +export class TestEventsController { + private readonly logger = new Logger(TestEventsController.name); + + constructor( + @Inject(PAYMENT_EVENT_PUBLISHER) + private readonly publisher: PaymentEventPublisher, + ) {} + + @Post("payment-event") + @ApiOperation({ + summary: + "DEV ONLY: publish a synthetic payment event to the broker (passenger/freight consumes it)", + description: + "Bypasses intents/booking. Empty body publishes a payment.succeeded for PASSENGER. " + + "Set referenceId to a real bookingId to trigger the consumer's seat/ticket side effects.", + }) + async publishTestEvent( + @Body() dto: TestPaymentEventDto, + ): Promise<{ published: true; routingKey: string; event: PaymentEvent }> { + const eventType = dto.eventType ?? "payment.succeeded"; + const service = dto.service ?? PaymentService.PASSENGER; + const now = new Date().toISOString(); + + const base = { + version: 1 as const, + eventId: randomUUID(), + occurredAt: now, + service, + intentId: dto.intentId ?? randomUUID(), + referenceType: dto.referenceType ?? PaymentReferenceType.BOOKING, + referenceId: dto.referenceId ?? randomUUID(), + merchantOrderId: dto.merchantOrderId ?? `test-${randomUUID().slice(0, 8)}`, + provider: dto.provider ?? ProviderMethod.WAAFI, + amountMinor: dto.amountMinor ?? 10_000, + currency: dto.currency ?? "ETB", + }; + + const event: PaymentEvent = + eventType === "payment.failed" + ? { + ...base, + eventType: "payment.failed", + failureCode: dto.failureCode ?? "TEST_DECLINED", + failureMessage: dto.failureMessage ?? "Synthetic test failure", + } + : { + ...base, + eventType: "payment.succeeded", + providerTxnId: dto.providerTxnId ?? `TEST-${randomUUID().slice(0, 8)}`, + paidAt: now, + }; + + await this.publisher.publish(event); + + const routingKey = paymentRoutingKey(event.service, event.eventType); + this.logger.log( + `published TEST ${event.eventType} (${event.eventId}) ref=${event.referenceId} -> ${routingKey}`, + ); + return { published: true, routingKey, event }; + } +}