mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
Merge pull request #177 from Tria-plc/feat/payment-microservice
Feat/payment microservice
This commit is contained in:
@@ -88,6 +88,19 @@ WAAFI_INSECURE_TLS=false
|
|||||||
# Payment Configuration
|
# Payment Configuration
|
||||||
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
|
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
|
||||||
|
|
||||||
|
# Browser return targets after a hosted payment page (UX only — payment is confirmed by the
|
||||||
|
# webhook/queryStatus, never this redirect). Global fallback used when a method-specific URL
|
||||||
|
# below is unset. Most providers use a single redirect; Waafi takes separate success/failure.
|
||||||
|
PAYMENT_RETURN_URL=
|
||||||
|
PAYMENT_FAILURE_URL=
|
||||||
|
TELEBIRR_RETURN_URL=
|
||||||
|
WAAFI_SUCCESS_REDIRECT=
|
||||||
|
WAAFI_FAIL_REDIRECT=
|
||||||
|
DMONEY_RETURN_URL=
|
||||||
|
CBE_RETURN_URL=
|
||||||
|
EBIRR_RETURN_URL=
|
||||||
|
CARD_RETURN_URL=
|
||||||
|
|
||||||
# Session Configuration
|
# Session Configuration
|
||||||
SESSION_INACTIVITY_MINUTES=30
|
SESSION_INACTIVITY_MINUTES=30
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,60 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||||
|
import {
|
||||||
|
PAYMENT_EVENTS_DLX,
|
||||||
|
PAYMENT_EVENTS_EXCHANGE,
|
||||||
|
PAYMENT_QUEUES,
|
||||||
|
PaymentService,
|
||||||
|
paymentServiceBindingPattern,
|
||||||
|
} from "@edr/types";
|
||||||
import { PaymentsController } from "./payments.controller";
|
import { PaymentsController } from "./payments.controller";
|
||||||
import { PaymentsService } from "./payments.service";
|
import { PaymentsService } from "./payments.service";
|
||||||
import { InternalPaymentsController } from "./internal-payments.controller";
|
import { InternalPaymentsController } from "./internal-payments.controller";
|
||||||
import { PaymentClientService } from "./payment-client.service";
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
|
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||||
import { SeatsModule } from "../seats/seats.module";
|
import { SeatsModule } from "../seats/seats.module";
|
||||||
import { TicketsModule } from "../tickets/tickets.module";
|
import { TicketsModule } from "../tickets/tickets.module";
|
||||||
|
|
||||||
|
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
SeatsModule,
|
SeatsModule,
|
||||||
TicketsModule,
|
TicketsModule,
|
||||||
HttpModule.register({ timeout: 10_000 }),
|
HttpModule.register({ timeout: 10_000 }),
|
||||||
|
RabbitMQModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
uri: config.get<string>("rabbitmq.url") as string,
|
||||||
|
exchanges: [
|
||||||
|
{
|
||||||
|
name: PAYMENT_EVENTS_EXCHANGE,
|
||||||
|
type: "topic",
|
||||||
|
options: { durable: true },
|
||||||
|
},
|
||||||
|
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
|
||||||
|
],
|
||||||
|
queues: [
|
||||||
|
{
|
||||||
|
name: PASSENGER_QUEUE.dlq,
|
||||||
|
exchange: PAYMENT_EVENTS_DLX,
|
||||||
|
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER),
|
||||||
|
options: { durable: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prefetchCount: config.get<number>("rabbitmq.prefetch") ?? 10,
|
||||||
|
connectionInitOptions: { wait: false },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
controllers: [PaymentsController, InternalPaymentsController],
|
controllers: [PaymentsController, InternalPaymentsController],
|
||||||
providers: [
|
providers: [
|
||||||
PaymentsService,
|
PaymentsService,
|
||||||
PaymentClientService,
|
PaymentClientService,
|
||||||
|
PaymentEventsConsumer,
|
||||||
ServiceAuthGuard,
|
ServiceAuthGuard,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,13 +42,6 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentsService {
|
export class PaymentsService {
|
||||||
private readonly logger = new Logger(PaymentsService.name);
|
private readonly logger = new Logger(PaymentsService.name);
|
||||||
|
|
||||||
/**
|
|
||||||
* DEMO ONLY: when true, a WALLET "payment" is treated as instantly successful — the wallet
|
|
||||||
* balance check and debit are skipped and the booking is confirmed + ticket issued as if fully
|
|
||||||
* paid. Lets the happy-path be demoed while a real provider (e.g. Telebirr) is unavailable.
|
|
||||||
* Never enable in production. Toggle with WALLET_DEMO_AUTO_SUCCEED in the env.
|
|
||||||
*/
|
|
||||||
private readonly walletDemoAutoSucceed = true;
|
private readonly walletDemoAutoSucceed = true;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -136,9 +129,7 @@ export class PaymentsService {
|
|||||||
return this.initiateWalletPayment(booking);
|
return this.initiateWalletPayment(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider methods go through the payment microservice (docs/payment-service §7.1):
|
const { returnUrl, failureUrl } = this.resolveReturnUrls(method);
|
||||||
// it owns the intent, the provider session, and the single webhook per provider.
|
|
||||||
// Re-initiating is safe — the service returns the existing active intent (idempotent).
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
service: PaymentServiceEnum.PASSENGER,
|
service: PaymentServiceEnum.PASSENGER,
|
||||||
referenceType: PaymentReferenceType.BOOKING,
|
referenceType: PaymentReferenceType.BOOKING,
|
||||||
@@ -148,10 +139,8 @@ export class PaymentsService {
|
|||||||
currency: booking.currency,
|
currency: booking.currency,
|
||||||
provider: method as unknown as ProviderMethod,
|
provider: method as unknown as ProviderMethod,
|
||||||
platform: dto.platform,
|
platform: dto.platform,
|
||||||
// PASSENGER-owned browser bounce-back after the hosted page (freight passes its own).
|
returnUrl,
|
||||||
// UX only — payment is confirmed by the webhook/mark-paid event, never this redirect.
|
failureUrl,
|
||||||
returnUrl: process.env.PAYMENT_RETURN_URL || undefined,
|
|
||||||
failureUrl: process.env.PAYMENT_FAILURE_URL || undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||||
@@ -168,6 +157,40 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
return this.formatIntentResponse(intent);
|
return this.formatIntentResponse(intent);
|
||||||
}
|
}
|
||||||
|
private resolveReturnUrls(method: PaymentMethodType): {
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
} {
|
||||||
|
const perMethod: Partial<
|
||||||
|
Record<PaymentMethodType, { returnUrl?: string; failureUrl?: string }>
|
||||||
|
> = {
|
||||||
|
[PaymentMethodType.TELEBIRR]: {
|
||||||
|
returnUrl: process.env.TELEBIRR_RETURN_URL,
|
||||||
|
},
|
||||||
|
[PaymentMethodType.WAAFI]: {
|
||||||
|
returnUrl: process.env.WAAFI_SUCCESS_REDIRECT,
|
||||||
|
failureUrl: process.env.WAAFI_FAIL_REDIRECT,
|
||||||
|
},
|
||||||
|
[PaymentMethodType.DMONEY]: {
|
||||||
|
returnUrl: process.env.DMONEY_RETURN_URL,
|
||||||
|
},
|
||||||
|
[PaymentMethodType.CBE_BIRR]: {
|
||||||
|
returnUrl: process.env.CBE_RETURN_URL,
|
||||||
|
},
|
||||||
|
[PaymentMethodType.EBIRR]: {
|
||||||
|
returnUrl: process.env.EBIRR_RETURN_URL,
|
||||||
|
},
|
||||||
|
[PaymentMethodType.CARD]: {
|
||||||
|
returnUrl: process.env.CARD_RETURN_URL,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const m = perMethod[method] ?? {};
|
||||||
|
const returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined;
|
||||||
|
const failureUrl =
|
||||||
|
m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl;
|
||||||
|
return { returnUrl, failureUrl };
|
||||||
|
}
|
||||||
|
|
||||||
private async syncIntentProjection(
|
private async syncIntentProjection(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
merch_code: this.merchantCode,
|
merch_code: this.merchantCode,
|
||||||
merch_order_id: input.merchantOrderId,
|
merch_order_id: input.merchantOrderId,
|
||||||
trade_type: "Checkout" as const,
|
trade_type: "Checkout" as const,
|
||||||
title: `EDR ${input.orderRef}`,
|
title: `EDR booking payment`,
|
||||||
total_amount: totalAmount,
|
total_amount: totalAmount,
|
||||||
trans_currency: input.currency,
|
trans_currency: input.currency,
|
||||||
timeout_express: this.timeoutExpress,
|
timeout_express: this.timeoutExpress,
|
||||||
|
|||||||
Reference in New Issue
Block a user