add test payment event

This commit is contained in:
Abubeker Yasin
2026-06-30 15:51:47 +03:00
parent 35a2a200d6
commit a667f5b2df
4 changed files with 192 additions and 0 deletions

View File

@@ -29,6 +29,11 @@ export class PaymentEventsConsumer {
},
})
async handle(event: PaymentEvent): Promise<Nack | void> {
// 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,

View File

@@ -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-<random>." })
@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;
}

View File

@@ -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,

View File

@@ -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 };
}
}