import { DynamicModule, Module } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { TypeOrmModule } from "@nestjs/typeorm"; import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq"; import { PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_EXCHANGE } from "@edr/types"; import { CutoverModule } from "../cutover/cutover.module"; import { RevenueMapping } from "./entities/revenue-mapping.entity"; import { InboundEvent } from "./entities/inbound-event.entity"; import { InboundEventsRepository, RevenueMappingsRepository, } from "./revenue.repository"; import { RevenueMappingsService } from "./revenue-mappings.service"; import { RevenueProjectionService } from "./revenue-projection.service"; import { RevenuePostingService } from "./revenue-posting.service"; import { RevenueController } from "./revenue.controller"; import { FINANCE_PAYMENT_BINDING, FINANCE_PAYMENT_DLQ, PaymentEventsConsumer, } from "./payment-events.consumer"; import { AccountsModule } from "../accounts/accounts.module"; import { JournalsModule } from "../journals/journals.module"; /** * The broker is OPTIONAL. * * Without `PAYMENT_RABBITMQ_URL` this returns nothing and the app boots with * every screen and projection working — only live payment ingest is off. That * matters because Finance is useful long before it is wired to a broker (the * chart, journals, periods and projections need no messaging at all), and * because a developer without RabbitMQ should not be blocked from running it. * * Same gate freight and passenger use, so all three behave alike. */ function rabbitMQImport(): DynamicModule[] { if (!process.env.PAYMENT_RABBITMQ_URL) return []; return [ RabbitMQModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ uri: config.get("rabbitmq.url") ?? process.env.PAYMENT_RABBITMQ_URL ?? "", exchanges: [ { name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } }, { name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } }, ], // The dead-letter queue is declared HERE, not by @RabbitSubscribe — // that decorator only declares the main queue and its DLX pointer. With // no bound DLQ, a dead-lettered message is silently discarded by the // broker. Freight and passenger declare theirs the same way. queues: [ { name: FINANCE_PAYMENT_DLQ, exchange: PAYMENT_EVENTS_DLX, routingKey: FINANCE_PAYMENT_BINDING, options: { durable: true }, }, ], prefetchCount: Number(process.env.PAYMENT_EVENTS_PREFETCH ?? 10), // Do not block boot on the broker: the API must still serve its // read-only screens when messaging is down. connectionInitOptions: { wait: false }, }), }), ]; } @Module({ imports: [ TypeOrmModule.forFeature([RevenueMapping, InboundEvent]), AccountsModule, JournalsModule, // For the cutover boundary: a payment settled before go-live is already // inside the opening balances and must not post again. CutoverModule, ...rabbitMQImport(), ], controllers: [RevenueController], providers: [ RevenueMappingsRepository, InboundEventsRepository, RevenueMappingsService, RevenueProjectionService, RevenuePostingService, // Registered unconditionally. Without RabbitMQModule the @RabbitSubscribe // decorator is inert, so the class is simply never invoked — and it stays // directly callable, which is how the consumer's idempotency is tested // without standing up a broker. PaymentEventsConsumer, ], exports: [ RevenueProjectionService, RevenuePostingService, RevenueMappingsRepository, ], }) export class RevenueModule {}