From 3b961f17f5acffb4b9158b3f22ea8530f098a056 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sun, 14 Jun 2026 18:47:41 +0300 Subject: [PATCH 01/11] refactor: ( rabbitmq ) temporary remove the rabbitmq --- .../src/modules/payments/payments.module.ts | 79 ++++++++++--------- 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 056986613..ea5fc0af3 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -1,60 +1,67 @@ import { Module } from "@nestjs/common"; 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"; +// --- TEMPORARILY DISABLED: payment-event RabbitMQ consumer ---------------------------------- +// The payment broker is currently unreachable (DevOps is fixing it). @golevelup awaits the +// @RabbitSubscribe registration during bootstrap, so an unreachable broker hangs the whole API +// and it never finishes starting. Disabled so the server boots without the broker. +// TO RE-ENABLE (once the broker is back): uncomment the imports below, the RabbitMQModule entry +// in `imports`, and PaymentEventsConsumer in `providers`. +// 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 { PaymentsService } from "./payments.service"; import { InternalPaymentsController } from "./internal-payments.controller"; import { PaymentClientService } from "./payment-client.service"; -import { PaymentEventsConsumer } from "./payment-events.consumer"; +// import { PaymentEventsConsumer } from "./payment-events.consumer"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { SeatsModule } from "../seats/seats.module"; import { TicketsModule } from "../tickets/tickets.module"; -const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER]; +// const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER]; @Module({ imports: [ SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 }), - RabbitMQModule.forRootAsync({ - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - uri: config.get("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("rabbitmq.prefetch") ?? 10, - connectionInitOptions: { wait: false }, - }), - }), + // --- TEMPORARILY DISABLED (broker unreachable) — re-enable with the imports above. ------- + // RabbitMQModule.forRootAsync({ + // inject: [ConfigService], + // useFactory: (config: ConfigService) => ({ + // uri: config.get("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("rabbitmq.prefetch") ?? 10, + // connectionInitOptions: { wait: false }, + // }), + // }), ], controllers: [PaymentsController, InternalPaymentsController], providers: [ PaymentsService, PaymentClientService, - PaymentEventsConsumer, + // PaymentEventsConsumer, // TEMPORARILY DISABLED — re-enable with RabbitMQModule above. ServiceAuthGuard, ], }) From 4ab987e8fe5d78583dcc83bf1015dc53a00c72bb Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sun, 14 Jun 2026 21:44:36 +0300 Subject: [PATCH 02/11] Revert "refactor: ( rabbitmq ) temporary remove the rabbitmq" This reverts commit 3b961f17f5acffb4b9158b3f22ea8530f098a056. --- .../src/modules/payments/payments.module.ts | 79 +++++++++---------- 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index ea5fc0af3..056986613 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -1,67 +1,60 @@ import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; -// --- TEMPORARILY DISABLED: payment-event RabbitMQ consumer ---------------------------------- -// The payment broker is currently unreachable (DevOps is fixing it). @golevelup awaits the -// @RabbitSubscribe registration during bootstrap, so an unreachable broker hangs the whole API -// and it never finishes starting. Disabled so the server boots without the broker. -// TO RE-ENABLE (once the broker is back): uncomment the imports below, the RabbitMQModule entry -// in `imports`, and PaymentEventsConsumer in `providers`. -// 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 { 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 { PaymentsService } from "./payments.service"; import { InternalPaymentsController } from "./internal-payments.controller"; import { PaymentClientService } from "./payment-client.service"; -// import { PaymentEventsConsumer } from "./payment-events.consumer"; +import { PaymentEventsConsumer } from "./payment-events.consumer"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { SeatsModule } from "../seats/seats.module"; import { TicketsModule } from "../tickets/tickets.module"; -// const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER]; +const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER]; @Module({ imports: [ SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 }), - // --- TEMPORARILY DISABLED (broker unreachable) — re-enable with the imports above. ------- - // RabbitMQModule.forRootAsync({ - // inject: [ConfigService], - // useFactory: (config: ConfigService) => ({ - // uri: config.get("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("rabbitmq.prefetch") ?? 10, - // connectionInitOptions: { wait: false }, - // }), - // }), + RabbitMQModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + uri: config.get("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("rabbitmq.prefetch") ?? 10, + connectionInitOptions: { wait: false }, + }), + }), ], controllers: [PaymentsController, InternalPaymentsController], providers: [ PaymentsService, PaymentClientService, - // PaymentEventsConsumer, // TEMPORARILY DISABLED — re-enable with RabbitMQModule above. + PaymentEventsConsumer, ServiceAuthGuard, ], }) From 73eeee175f911d7443d03c3354530e99164f0de8 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 15 Jun 2026 11:26:51 +0300 Subject: [PATCH 03/11] Update payments.service.ts --- .../src/modules/payments/payments.service.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index fe80ae88e..59438c4ad 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -43,6 +43,14 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ export class PaymentsService { 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; + constructor( private prisma: PrismaService, private seatsService: SeatsService, @@ -196,6 +204,35 @@ export class PaymentsService { private async initiateWalletPayment( booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, ): Promise { + // DEMO ONLY (WALLET_DEMO_AUTO_SUCCEED): pretend the payment succeeded — no balance check, + // no debit — and run the exact same finalize path a real successful payment uses + // (booking → CONFIRMED, seats confirmed, ticket issued). Remove once a real provider works. + if (this.walletDemoAutoSucceed) { + this.logger.warn( + `WALLET_DEMO_AUTO_SUCCEED enabled — faking a successful WALLET payment for booking ${booking.bookingRef} (${booking.id})`, + ); + const demoIntent = await this.prisma.paymentIntent.upsert({ + where: { bookingId: booking.id }, + update: { + status: PaymentIntentStatus.PROCESSING, + failureCode: null, + method: PaymentMethodType.WALLET, + }, + create: { + bookingId: booking.id, + amountMinor: booking.totalMinor, + method: PaymentMethodType.WALLET, + status: PaymentIntentStatus.PROCESSING, + providerRef: `WALLET-DEMO-${Date.now()}`, + }, + }); + await this.finalizePaymentSuccess({ intentId: demoIntent.id }); + const settled = await this.prisma.paymentIntent.findUniqueOrThrow({ + where: { id: demoIntent.id }, + }); + return this.formatIntentResponse(settled); + } + const debitResult = await this.prisma.$transaction(async (tx) => { const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId }, From 96ec2923c256b8cdc6f2150cf109e5b570f92072 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 15 Jun 2026 14:35:14 +0300 Subject: [PATCH 04/11] fix: ( telebirr ) redirect_url --- .../src/providers/telebirr/telebirr.crypto.ts | 5 ++++- .../src/providers/telebirr/telebirr.provider.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.crypto.ts b/packages/payment-providers/src/providers/telebirr/telebirr.crypto.ts index 12ea9624a..d8c2e6763 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.crypto.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.crypto.ts @@ -17,6 +17,7 @@ export function buildCanonicalString(requestObject: Record): st for (const key of Object.keys(requestObject)) { if (EXCLUDE_FIELDS.has(key)) continue; + if (requestObject[key] === undefined) continue; fieldMap[key] = requestObject[key]; } @@ -24,7 +25,9 @@ export function buildCanonicalString(requestObject: Record): st if (biz && typeof biz === 'object') { for (const key of Object.keys(biz as Record)) { if (EXCLUDE_FIELDS.has(key)) continue; - fieldMap[key] = (biz as Record)[key]; + const value = (biz as Record)[key]; + if (value === undefined) continue; + fieldMap[key] = value; } } diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index 39a0e8e90..ecb75c3e8 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -211,7 +211,7 @@ export class TelebirrProvider implements PaymentProvider { total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - redirect_url: input.redirectUrl, + ...(input.redirectUrl ? { redirect_url: input.redirectUrl } : {}), }, }; const sign = signRequestObject( From 0aed9824ae4c8d481209aee1aba178ff9193f4e5 Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 15 Jun 2026 14:54:20 +0300 Subject: [PATCH 05/11] cac payemnt integration and cbe rate exchange webscrabing --- apps/edr-freight-api/src/config/app.config.ts | 11 +- .../cbe-exchange/cbe-exchange.service.ts | 115 ++++---- .../payment/entities/payment.entity.ts | 4 +- .../src/modules/payment/payment.controller.ts | 2 +- .../src/modules/payment/payment.service.ts | 1 + .../src/modules/payment/payments.dto.ts | 11 +- .../src/modules/payments/payments.dto.ts | 9 +- apps/edr-payment-api/src/app.module.ts | 2 + apps/edr-payment-api/src/config/cac.config.ts | 13 + .../intents/dto/confirm-payment.dto.ts | 14 + .../src/modules/intents/intents.controller.ts | 14 + .../src/modules/intents/intents.service.ts | 95 +++++- .../src/modules/providers/providers.module.ts | 2 + .../reconciliation/reconciliation.service.ts | 12 +- packages/payment-providers/src/index.ts | 14 + .../src/providers/cac-bank/cac-bank.auth.ts | 88 ++++++ .../providers/cac-bank/cac-bank.provider.ts | 271 ++++++++++++++++++ .../src/providers/cac-bank/cac-bank.types.ts | 65 +++++ .../providers/telebirr/telebirr.provider.ts | 2 +- packages/types/src/common/payments.ts | 11 + 20 files changed, 680 insertions(+), 76 deletions(-) create mode 100644 apps/edr-payment-api/src/config/cac.config.ts create mode 100644 apps/edr-payment-api/src/modules/intents/dto/confirm-payment.dto.ts create mode 100644 packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts create mode 100644 packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts create mode 100644 packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index e659b950a..fa8644945 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -15,7 +15,16 @@ export default registerAs("app", () => ({ maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), }, cbeExchange: { - apiUrl: process.env.CBE_EXCHANGE_API_URL ?? "", + /** ethio.forex CBET page — scraped for USD buying/selling rates. */ + scrapeUrl: + process.env.CBE_EXCHANGE_SCRAPE_URL ?? + process.env.CBE_EXCHANGE_API_URL ?? + "https://ethio.forex/bank/CBET", + /** @deprecated use scrapeUrl — kept for backward-compatible config reads */ + apiUrl: + process.env.CBE_EXCHANGE_SCRAPE_URL ?? + process.env.CBE_EXCHANGE_API_URL ?? + "https://ethio.forex/bank/CBET", fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130), cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000), }, diff --git a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts b/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts index 0f596831f..27e89896b 100644 --- a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts +++ b/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts @@ -1,6 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET'; + +/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ +const USD_RATE_REGEX = + /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; + @Injectable() export class CbeExchangeService { private readonly logger = new Logger(CbeExchangeService.name); @@ -10,9 +16,8 @@ export class CbeExchangeService { constructor(private readonly configService: ConfigService) {} /** - * Returns the current CBE USD→ETB exchange rate. - * Fetches live from CBE_EXCHANGE_API_URL, caches for CBE_EXCHANGE_CACHE_TTL_MS, - * and falls back to CBE_EXCHANGE_FALLBACK_RATE when the API is unreachable. + * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. + * Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure. */ async getUsdToEtbRate(): Promise { const now = Date.now(); @@ -21,41 +26,43 @@ export class CbeExchangeService { return this.cachedRate; } - const apiUrl = this.configService.get('app.cbeExchange.apiUrl') ?? ''; - const fallbackRate = this.configService.get('app.cbeExchange.fallbackRate') ?? 130; - const cacheTtlMs = this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; - - if (!apiUrl) { - this.logger.warn( - `CBE_EXCHANGE_API_URL not configured — using fallback rate ${fallbackRate} ETB/USD`, - ); - return fallbackRate; - } + const scrapeUrl = this.getScrapeUrl(); + const fallbackRate = + this.configService.get('app.cbeExchange.fallbackRate') ?? 130; + const cacheTtlMs = + this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; try { - const response = await fetch(apiUrl, { + const response = await fetch(scrapeUrl, { signal: AbortSignal.timeout(8_000), - headers: { Accept: 'application/json' }, + headers: { 'User-Agent': 'Mozilla/5.0' }, }); if (!response.ok) { - throw new Error(`CBE API responded with status ${response.status}`); + throw new Error(`CBE scrape responded with status ${response.status}`); } - const json = await response.json(); - const rate = this.parseRate(json); + const html = await response.text(); + const rates = this.parseScrapedRates(html); - if (!rate || !Number.isFinite(rate) || rate <= 0) { - throw new Error(`Invalid rate value parsed from CBE API response: ${rate}`); + if (!rates) { + throw new Error('USD rate not found in ethio.forex page HTML'); + } + + const rate = rates.selling; + if (!Number.isFinite(rate) || rate <= 0) { + throw new Error(`Invalid selling rate parsed: ${rate}`); } this.cachedRate = rate; this.cacheExpiresAt = now + cacheTtlMs; - this.logger.log(`CBE USD→ETB rate refreshed: ${rate}`); + this.logger.log( + `CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`, + ); return rate; } catch (err) { this.logger.error( - `Failed to fetch CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`, + `Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`, ); if (this.cachedRate !== null) { @@ -67,49 +74,33 @@ export class CbeExchangeService { } } - /** - * Parses the USD→ETB selling rate from the CBE API JSON response. - * CBE API typically returns an array of currency objects. - * Adjust this method if the API shape differs. - * - * Expected shape (one common format): - * [ { currency: "USD", selling: "130.50", ... }, ... ] - */ - private parseRate(json: unknown): number | null { - if (Array.isArray(json)) { - const usdEntry = json.find( - (entry: unknown) => - typeof entry === 'object' && - entry !== null && - ( - (entry as Record)['currency'] === 'USD' || - (entry as Record)['Currency'] === 'USD' - ), - ) as Record | undefined; + private getScrapeUrl(): string { + const configured = + this.configService.get('app.cbeExchange.scrapeUrl') ?? + this.configService.get('app.cbeExchange.apiUrl'); + return configured?.trim() || DEFAULT_SCRAPE_URL; + } - if (!usdEntry) return null; + private parseScrapedRates( + html: string, + ): { buying: number; selling: number } | null { + const decoded = this.unescapeHtml(html); + const match = USD_RATE_REGEX.exec(decoded); + if (!match) return null; - const selling = - usdEntry['selling'] ?? - usdEntry['Selling'] ?? - usdEntry['sellingRate'] ?? - usdEntry['rate'] ?? - usdEntry['Rate']; + const buying = Number(match[1]); + const selling = Number(match[2]); + if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null; - return selling !== undefined ? Number(selling) : null; - } + return { buying, selling }; + } - if (typeof json === 'object' && json !== null) { - const obj = json as Record; - const selling = - obj['selling'] ?? - obj['Selling'] ?? - obj['sellingRate'] ?? - obj['usdToEtb'] ?? - obj['rate']; - return selling !== undefined ? Number(selling) : null; - } - - return null; + private unescapeHtml(html: string): string { + return html + .replace(/"/g, '"') + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); } } diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index a9999675e..2bb81a331 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -3,7 +3,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity"; type PaymentType = "booking" -type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" +type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" type Currency = "ETB" | "USD" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @@ -18,7 +18,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "enum", enum: ["booking"] }) type!: PaymentType; - @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney"] }) + @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] }) method!: PaymentMethod @Column({ type: "enum", enum: ["ETB", "USD"] }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index bb8f21e7e..736f5d274 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -58,7 +58,7 @@ export class PaymentController { @Post("initiate") @ApiOperation({ summary: "Initiate payment for a freight booking", - description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money`, + description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`, }) @ApiOkResponse({ type: InitiateResponseDto }) initiatePayment(@Body() dto: InitiatePaymentDto) { diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1b5b9e69f..3f90628f0 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -157,6 +157,7 @@ export class PaymentService { WAAFI: "waafi", CARD: "card", DMONEY: "dmoney", + CAC_BANK: "cac-bank", }; const method: PaymentEntity["method"] = PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index b5c09c2c3..67ca68e87 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -11,6 +11,7 @@ export enum PaymentMethodTypeEnum { WAAFI = "WAAFI", CARD = "CARD", DMONEY = "DMONEY", + CAC_BANK = "CAC_BANK", } export class InitiatePaymentDto { @@ -59,8 +60,8 @@ export class RefundDto { } export class ClientActionDto { - @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) - type!: "REDIRECT" | "LAUNCH_APP"; + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) + type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @@ -73,6 +74,12 @@ export class ClientActionDto { @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) shortCode?: string; + + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) + providerOrderId?: string; + + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + message?: string; } export class InitiateResponseDto { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 309bd4a0a..39a621a9b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -95,9 +95,8 @@ export class SupportedPaymentMethodDto { } export class ClientActionDto { - @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) type: - | "REDIRECT" - | "LAUNCH_APP"; + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) + type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @ApiPropertyOptional({ @@ -112,6 +111,10 @@ export class ClientActionDto { description: "Set when type=LAUNCH_APP (mobile flow)", }) shortCode?: string; + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) + providerOrderId?: string; + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + message?: string; } export class InitiateResponseDto { diff --git a/apps/edr-payment-api/src/app.module.ts b/apps/edr-payment-api/src/app.module.ts index de92f3909..410a8146b 100644 --- a/apps/edr-payment-api/src/app.module.ts +++ b/apps/edr-payment-api/src/app.module.ts @@ -12,6 +12,7 @@ import cbeConfig from "./config/cbe.config"; import ebirrConfig from "./config/ebirr.config"; import cardConfig from "./config/card.config"; import dmoneyConfig from "./config/dmoney.config"; +import cacConfig from "./config/cac.config"; import { HealthModule } from "./modules/health/health.module"; import { IntentsModule } from "./modules/intents/intents.module"; import { OutboxModule } from "./modules/outbox/outbox.module"; @@ -34,6 +35,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module"; ebirrConfig, cardConfig, dmoneyConfig, + cacConfig, ], }), TypeOrmModule.forRootAsync({ diff --git a/apps/edr-payment-api/src/config/cac.config.ts b/apps/edr-payment-api/src/config/cac.config.ts new file mode 100644 index 000000000..360e64299 --- /dev/null +++ b/apps/edr-payment-api/src/config/cac.config.ts @@ -0,0 +1,13 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("cac", () => ({ + baseUrl: process.env.CAC_BASE_URL || "", + username: process.env.CAC_USERNAME || "", + password: process.env.CAC_PASSWORD || "", + appKey: process.env.CAC_APP_KEY || "", + apiKey: process.env.CAC_API_KEY || "", + companyServicesId: Number(process.env.CAC_COMPANY_SERVICES_ID || 0), + currency: process.env.CAC_CURRENCY || "DJF", + tokenTtlMs: Number(process.env.CAC_TOKEN_TTL_MS || 23 * 60 * 60 * 1000), + otpExpiryMs: Number(process.env.CAC_OTP_EXPIRY_MS || 10 * 60 * 1000), +})); diff --git a/apps/edr-payment-api/src/modules/intents/dto/confirm-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/confirm-payment.dto.ts new file mode 100644 index 000000000..1dfae2dde --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/dto/confirm-payment.dto.ts @@ -0,0 +1,14 @@ +import { IsString, Length } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { ConfirmPaymentRequest } from "@edr/types"; + +/** Wire shape is the shared `ConfirmPaymentRequest` contract from @edr/types. */ +export class ConfirmPaymentDto implements ConfirmPaymentRequest { + @ApiProperty({ + description: "One-time password sent to the payer's mobile via SMS", + example: "123456", + }) + @IsString() + @Length(1, 10) + otp!: string; +} diff --git a/apps/edr-payment-api/src/modules/intents/intents.controller.ts b/apps/edr-payment-api/src/modules/intents/intents.controller.ts index 858ad3bae..35f3461d2 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.controller.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.controller.ts @@ -15,6 +15,7 @@ import { InitiatePaymentRequestDto, IntentReferenceQueryDto, } from "./dto/initiate-payment.dto"; +import { ConfirmPaymentDto } from "./dto/confirm-payment.dto"; import { IntentsService } from "./intents.service"; /** @@ -66,4 +67,17 @@ export class IntentsController { query.referenceId, ); } + + @Post("intents/:id/confirm") + @ApiOperation({ + summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)", + description: + "Submits the SMS OTP to complete payment. Only supported for providers that use COLLECT_OTP clientAction.", + }) + async confirm( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ConfirmPaymentDto, + ): Promise { + return this.intentsService.confirm(id, dto); + } } diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 492a86b78..aebb65ebb 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -6,12 +6,14 @@ import { NotFoundException, } from "@nestjs/common"; import { DataSource, QueryFailedError } from "typeorm"; -import { createMerchantOrderId } from "@edr/payment-providers"; +import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers"; import { + ConfirmPaymentRequest, InitiatePaymentRequest, PaymentIntentSnapshot, PaymentReferenceType, PaymentService, + ProviderMethod, ProviderPaymentStatus, ProviderStatus, } from "@edr/types"; @@ -52,6 +54,7 @@ export class IntentsService { private readonly dataSource: DataSource, @Inject(PAYMENT_PROVIDER_MAP) private readonly providers: PaymentProviderMap, + private readonly cacBankProvider: CacBankProvider, ) {} /* ------------------------------------------------------------------ initiate */ @@ -85,6 +88,15 @@ export class IntentsService { ); } + if ( + request.provider === ProviderMethod.CAC_BANK && + !request.payerAccount?.trim() + ) { + throw new BadRequestException( + "payerAccount (customer mobile number) is required for CAC_BANK", + ); + } + const merchantOrderId = createMerchantOrderId(); const result = await provider.initiate({ merchantOrderId, @@ -134,6 +146,65 @@ export class IntentsService { } } + /* ------------------------------------------------------------------ confirm (OTP providers) */ + + async confirm( + intentId: string, + request: ConfirmPaymentRequest, + ): Promise { + const intent = await this.intentsRepository.findById(intentId); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + + if (intent.provider !== ProviderMethod.CAC_BANK) { + throw new BadRequestException( + `Confirm is not supported for provider: ${intent.provider}`, + ); + } + + if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) { + throw new BadRequestException( + `Intent is not awaiting confirmation (status=${intent.status})`, + ); + } + + if (!intent.providerOrderId) { + throw new BadRequestException("Intent has no provider order id"); + } + + const confirmResult = await this.cacBankProvider.confirmPayment( + intent.providerOrderId, + request.otp, + ); + + if (confirmResult.reference) { + await this.intentsRepository.update(intent.id, { + rawInitiation: { + ...(intent.rawInitiation ?? {}), + reference: confirmResult.reference, + confirmResponse: confirmResult.rawResponse, + }, + }); + } + + if (confirmResult.status === "SUCCEEDED") { + await this.applyProviderResult(intent.id, { + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: confirmResult.providerTxnId, + paidAt: new Date(), + }); + } else { + await this.applyProviderResult(intent.id, { + status: ProviderPaymentStatus.FAILED, + failureCode: confirmResult.failureCode, + failureMessage: confirmResult.failureMessage, + }); + } + + const updated = await this.intentsRepository.findById(intent.id); + if (!updated) throw new NotFoundException("PaymentIntent not found"); + return this.toSnapshot(updated); + } + /** * Decide whether an existing active intent can be returned as-is. An expired * REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid) @@ -192,7 +263,7 @@ export class IntentsService { if (!refreshable || !stale || !provider) return intent; try { - const status = await provider.queryStatus(intent.merchantOrderId); + const status = await this.queryProviderStatus(intent); await this.applyProviderResult( intent.id, this.fromProviderStatus(status), @@ -207,6 +278,26 @@ export class IntentsService { } } + private async queryProviderStatus( + intent: PaymentIntent, + ): Promise { + const provider = this.providers.get(intent.provider); + if (!provider) { + throw new Error(`Unknown provider: ${intent.provider}`); + } + + if (intent.provider === ProviderMethod.CAC_BANK) { + const reference = (intent.rawInitiation as { reference?: string }) + ?.reference; + return this.cacBankProvider.queryStatus( + intent.merchantOrderId, + reference, + ); + } + + return provider.queryStatus(intent.merchantOrderId); + } + fromProviderStatus(status: ProviderStatus): ProviderResultInput { return { status: status.status, diff --git a/apps/edr-payment-api/src/modules/providers/providers.module.ts b/apps/edr-payment-api/src/modules/providers/providers.module.ts index af2983ee4..88af1a721 100644 --- a/apps/edr-payment-api/src/modules/providers/providers.module.ts +++ b/apps/edr-payment-api/src/modules/providers/providers.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { CardProvider, + CacBankProvider, CbeBirrProvider, DMoneyProvider, EBirrProvider, @@ -23,6 +24,7 @@ const providerClasses = [ CardProvider, WaafiProvider, DMoneyProvider, + CacBankProvider, ]; /** diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts index 5216798f2..b74c9b899 100644 --- a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts +++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts @@ -7,7 +7,8 @@ import { } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { SchedulerRegistry } from "@nestjs/schedule"; -import { ProviderPaymentStatus } from "@edr/types"; +import { ProviderPaymentStatus, ProviderMethod } from "@edr/types"; +import { CacBankProvider } from "@edr/payment-providers"; import { PAYMENT_PROVIDER_MAP, PaymentProviderMap, @@ -38,6 +39,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy { private readonly schedulerRegistry: SchedulerRegistry, @Inject(PAYMENT_PROVIDER_MAP) private readonly providers: PaymentProviderMap, + private readonly cacBankProvider: CacBankProvider, ) { this.intervalMs = config.get("app.reconciliation.sweepIntervalMs") ?? 60_000; @@ -82,7 +84,13 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy { try { const provider = this.providers.get(intent.provider); if (provider) { - const status = await provider.queryStatus(intent.merchantOrderId); + const status = + intent.provider === ProviderMethod.CAC_BANK + ? await this.cacBankProvider.queryStatus( + intent.merchantOrderId, + (intent.rawInitiation as { reference?: string })?.reference, + ) + : await provider.queryStatus(intent.merchantOrderId); const result = this.intentsService.fromProviderStatus(status); if (result.status !== intent.status || result.providerTxnId) { await this.intentsService.applyProviderResult(intent.id, result); diff --git a/packages/payment-providers/src/index.ts b/packages/payment-providers/src/index.ts index 7b77b5684..0fa061eea 100644 --- a/packages/payment-providers/src/index.ts +++ b/packages/payment-providers/src/index.ts @@ -20,6 +20,7 @@ export { EBirrProvider } from './providers/ebirr/ebirr.provider'; export { CardProvider } from './providers/card/card.provider'; export { WaafiProvider } from './providers/waafi/waafi.provider'; export { DMoneyProvider } from './providers/dmoney/dmoney.provider'; +export { CacBankProvider } from './providers/cac-bank/cac-bank.provider'; // Telebirr crypto + types (exported for apps that build/verify signatures directly) export { @@ -49,6 +50,19 @@ export type { WaafiGetTranInfoResponse, } from './providers/waafi/waafi.types'; +// CAC Bank request/response types +export type { + CacSigninRequest, + CacSigninResponse, + CacPaymentInitiateRequest, + CacPaymentInitiateResponse, + CacPaymentConfirmRequest, + CacPaymentConfirmResponse, + CacGetPaymentByReferenceRequest, + CacPaymentByReferenceResponse, + CacConfirmResult, +} from './providers/cac-bank/cac-bank.types'; + // Webhook payload types export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types'; export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types'; diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts new file mode 100644 index 000000000..ee20b0aa5 --- /dev/null +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts @@ -0,0 +1,88 @@ +import { Logger } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { AxiosError } from "axios"; +import { firstValueFrom } from "rxjs"; +import type { CacSigninRequest, CacSigninResponse } from "./cac-bank.types"; + +interface TokenCache { + accessToken: string; + expiresAt: number; +} + +export interface CacAuthConfig { + baseUrl: string; + username: string; + password: string; + tokenTtlMs: number; +} + +/** + * In-memory JWT cache for CAC Bank. Tokens are valid 24h per the API docs; + * we refresh proactively before expiry. + */ +export class CacBankAuth { + private readonly logger = new Logger(CacBankAuth.name); + private cache: TokenCache | null = null; + private signinInFlight: Promise | null = null; + + constructor( + private readonly http: HttpService, + private readonly config: CacAuthConfig, + ) {} + + async getAccessToken(): Promise { + if (this.cache && Date.now() < this.cache.expiresAt) { + return this.cache.accessToken; + } + return this.signin(); + } + + invalidate(): void { + this.cache = null; + } + + private async signin(): Promise { + if (this.signinInFlight) return this.signinInFlight; + + this.signinInFlight = this.doSignin(); + try { + return await this.signinInFlight; + } finally { + this.signinInFlight = null; + } + } + + private async doSignin(): Promise { + const body: CacSigninRequest = { + username: this.config.username, + password: this.config.password, + }; + const url = `${this.config.baseUrl}/paymentapi/auth/signin`; + + try { + const res = await firstValueFrom( + this.http.post(url, body, { + headers: { "Content-Type": "application/json" }, + timeout: 10_000, + }), + ); + const token = res.data.accessToken; + if (!token) { + throw new Error("CAC signin returned no accessToken"); + } + this.cache = { + accessToken: token, + expiresAt: Date.now() + this.config.tokenTtlMs, + }; + this.logger.debug("CAC signin succeeded; token cached"); + return token; + } catch (err) { + if (err instanceof AxiosError) { + this.logger.error( + `CAC signin failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, + ); + } + throw err; + } + } +} diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts new file mode 100644 index 000000000..8d2afa548 --- /dev/null +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts @@ -0,0 +1,271 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { + PaymentProvider, + ProviderInitiationInput, + ProviderInitiationResult, + ProviderStatus, + ProviderPaymentStatus, + ProviderMethod, +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import { CacBankAuth } from "./cac-bank.auth"; +import type { + CacConfirmResult, + CacGetPaymentByReferenceRequest, + CacPaymentByReferenceResponse, + CacPaymentConfirmRequest, + CacPaymentConfirmResponse, + CacPaymentInitiateRequest, + CacPaymentInitiateResponse, +} from "./cac-bank.types"; + +@Injectable() +export class CacBankProvider implements PaymentProvider { + readonly method = ProviderMethod.CAC_BANK; + private readonly logger = new Logger(CacBankProvider.name); + private auth: CacBankAuth | null = null; + + constructor( + private readonly config: ConfigService, + private readonly http: HttpService, + ) {} + + async initiate( + input: ProviderInitiationInput, + ): Promise { + if (!input.payerAccount) { + throw new Error("CAC Bank requires payerAccount (customer mobile number)"); + } + + const requestBody: CacPaymentInitiateRequest = { + app_key: this.appKey, + api_key: this.apiKey, + customer_mobile: input.payerAccount, + currency: input.currency || this.defaultCurrency, + desc: `EDR ${input.orderRef}`.slice(0, 500), + vender_ref: input.merchantOrderId, + amount: this.toMajorAmount(input.amountMinor, input.currency), + company_services_id: this.companyServicesId, + }; + + const response = await this.postJson( + "/paymentapi/PaymentInitiateRequest", + requestBody, + ); + + if (response.paymentRequestId == null) { + throw new Error( + `CAC Bank initiate failed: ${JSON.stringify(response)}`, + ); + } + + const providerOrderId = String(response.paymentRequestId); + const expiresAt = new Date(Date.now() + this.otpExpiryMs); + + return { + providerOrderId, + clientAction: { + type: "COLLECT_OTP", + providerOrderId, + message: "Enter the OTP sent to your phone", + }, + expiresAt, + rawInitiation: { + request: this.sanitizeKeys(requestBody), + response, + venderRef: input.merchantOrderId, + }, + }; + } + + async confirmPayment( + paymentRequestId: string, + otp: string, + ): Promise { + const requestBody: CacPaymentConfirmRequest = { + app_key: this.appKey, + api_key: this.apiKey, + payment_request_id: Number(paymentRequestId), + otp, + }; + + try { + const response = await this.postJson( + "/paymentapi/PaymentConfirmationRequest", + requestBody, + ); + + if (response.confirmReference == null && !response.reference) { + return { + status: "FAILED", + failureCode: "CONFIRM_REJECTED", + failureMessage: response.description ?? "Confirmation rejected", + rawResponse: response as unknown as Record, + }; + } + + return { + status: "SUCCEEDED", + providerTxnId: String( + response.confirmReference ?? response.reference, + ), + reference: response.reference, + rawResponse: response as unknown as Record, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + status: "FAILED", + failureCode: "CONFIRM_ERROR", + failureMessage: message, + rawResponse: {}, + }; + } + } + + async queryStatus( + merchantOrderId: string, + reference?: string, + ): Promise { + const lookupRef = reference ?? merchantOrderId; + const requestBody: CacGetPaymentByReferenceRequest = { + app_key: this.appKey, + api_key: this.apiKey, + reference: lookupRef, + }; + + try { + const response = await this.postJson( + "/paymentapi/GetPaymentByReferenceRequest", + requestBody, + ); + + if (response.transactionNo != null && response.transactionDate) { + return { + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: String(response.transactionNo), + rawResponse: response as unknown as Record, + }; + } + + return { + status: ProviderPaymentStatus.PROCESSING, + rawResponse: response as unknown as Record, + }; + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) { + return { + status: ProviderPaymentStatus.PROCESSING, + rawResponse: { notFound: true, reference: lookupRef }, + }; + } + throw err; + } + } + + private async postJson(path: string, body: unknown): Promise { + const token = await this.getAuth().getAccessToken(); + const url = `${this.baseUrl}${path}`; + const config: AxiosRequestConfig = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + timeout: 10_000, + }; + + const started = Date.now(); + try { + const res = await firstValueFrom(this.http.post(url, body, config)); + this.logger.debug( + `CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`, + ); + return res.data; + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 401) { + this.getAuth().invalidate(); + const retryToken = await this.getAuth().getAccessToken(); + const retryConfig: AxiosRequestConfig = { + ...config, + headers: { + ...config.headers, + Authorization: `Bearer ${retryToken}`, + }, + }; + const res = await firstValueFrom( + this.http.post(url, body, retryConfig), + ); + return res.data; + } + + if (err instanceof AxiosError) { + this.logger.error( + `CAC Bank POST ${path} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, + ); + } else { + this.logger.error( + `CAC Bank POST ${path} threw: ${err instanceof Error ? err.message : err}`, + ); + } + throw err; + } + } + + private getAuth(): CacBankAuth { + if (!this.auth) { + this.auth = new CacBankAuth(this.http, { + baseUrl: this.baseUrl, + username: this.username, + password: this.password, + tokenTtlMs: this.tokenTtlMs, + }); + } + return this.auth; + } + + /** DJF has no fractional units — amountMinor is the major amount. */ + private toMajorAmount(amountMinor: number, currency: string): number { + if (currency.toUpperCase() === "DJF") { + return amountMinor; + } + return amountMinor / 100; + } + + private sanitizeKeys( + body: CacPaymentInitiateRequest | CacPaymentConfirmRequest, + ): Record { + const { app_key: _appKey, api_key: _apiKey, ...rest } = body; + return rest; + } + + private get baseUrl(): string { + return (this.config.get("cac.baseUrl") ?? "").replace(/\/$/, ""); + } + private get username(): string { + return this.config.get("cac.username") ?? ""; + } + private get password(): string { + return this.config.get("cac.password") ?? ""; + } + private get appKey(): string { + return this.config.get("cac.appKey") ?? ""; + } + private get apiKey(): string { + return this.config.get("cac.apiKey") ?? ""; + } + private get companyServicesId(): number { + return this.config.get("cac.companyServicesId") ?? 0; + } + private get defaultCurrency(): string { + return this.config.get("cac.currency") ?? "DJF"; + } + private get tokenTtlMs(): number { + return this.config.get("cac.tokenTtlMs") ?? 23 * 60 * 60 * 1000; + } + private get otpExpiryMs(): number { + return this.config.get("cac.otpExpiryMs") ?? 10 * 60 * 1000; + } +} diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts new file mode 100644 index 000000000..6e2b7ba5b --- /dev/null +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts @@ -0,0 +1,65 @@ +export interface CacSigninRequest { + username: string; + password: string; +} + +export interface CacSigninResponse { + id: number; + username: string; + email: string; + accessToken: string; + tokenType: string; +} + +export interface CacPaymentInitiateRequest { + app_key: string; + api_key: string; + customer_mobile: string; + currency: string; + desc?: string; + vender_ref?: string; + amount: number; + company_services_id: number; +} + +export interface CacPaymentInitiateResponse { + description: string; + paymentRequestId: number; +} + +export interface CacPaymentConfirmRequest { + app_key: string; + api_key: string; + payment_request_id: number; + otp: string; +} + +export interface CacPaymentConfirmResponse { + description: string; + confirmReference: number; + reference: string; +} + +export interface CacGetPaymentByReferenceRequest { + app_key: string; + api_key: string; + reference: string; +} + +export interface CacPaymentByReferenceResponse { + description: string; + customerName?: string; + reference: string; + amount: number; + transactionDate: string; + transactionNo: number; +} + +export interface CacConfirmResult { + status: "SUCCEEDED" | "FAILED"; + providerTxnId?: string; + reference?: string; + failureCode?: string; + failureMessage?: string; + rawResponse: Record; +} diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index dd2a77bec..846fb2930 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -211,7 +211,7 @@ export class TelebirrProvider implements PaymentProvider { total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - redirect_url: input.redirectUrl, + redirect_url: "https://google.com", }, }; const sign = signRequestObject( diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 02930d151..6439395f4 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -23,6 +23,7 @@ export enum ProviderMethod { WAAFI = "WAAFI", CARD = "CARD", DMONEY = "DMONEY", + CAC_BANK = "CAC_BANK", } export type PaymentPlatform = "web" | "mobile"; @@ -34,6 +35,11 @@ export type ClientAction = appId: string; receiveCode?: string; shortCode: string; + } + | { + type: "COLLECT_OTP"; + providerOrderId: string; + message?: string; }; export interface ProviderInitiationInput { @@ -125,6 +131,11 @@ export interface InitiatePaymentRequest { idempotencyKey?: string; } +/** Body of `POST /payments/intents/:id/confirm` (OTP-based providers such as CAC Bank). */ +export interface ConfirmPaymentRequest { + otp: string; +} + /** Response of `POST /payments/initiate` and shape of intent lookups. */ export interface PaymentIntentSnapshot { intentId: string; From 28ad9363d1ad6e0ccae68c9416ca181c79cf58e4 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 15 Jun 2026 12:01:44 +0000 Subject: [PATCH 06/11] tracking ui --- .../TrainScheduleTrackPage.tsx | 292 +++++++++++------- .../backoffice/user-management | 1 - 2 files changed, 180 insertions(+), 113 deletions(-) delete mode 160000 apps/edr-freight-web/backoffice/user-management diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx index 2091d091c..6f85b82d6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx @@ -16,7 +16,7 @@ import { Group, Loader, Paper, - SimpleGrid, + Progress, Stack, Text, ThemeIcon, @@ -25,12 +25,8 @@ import { } from "@mantine/core"; import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack"; -import { - RouteCorridor, - StatTile, - StatusPill, - scheduleBrand, -} from "@/components/trainScheduling/scheduleVisuals"; +import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals"; +import { freightBrand } from "@/theme/freight-brand"; import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling"; import { useToast } from "@/hooks/use-toast"; @@ -54,6 +50,33 @@ function formatDateTime(iso?: string | null) { }); } +/** Compact icon + label + value cell used in the header meta strip. */ +function MetaStat({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( + + + {icon} + + + + {label} + + + {value} + + + + ); +} + export default function TrainScheduleTrackPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const { toast } = useToast(); @@ -63,7 +86,7 @@ export default function TrainScheduleTrackPage() { if (trackQuery.isLoading) { return ( - + ); } @@ -80,7 +103,9 @@ export default function TrainScheduleTrackPage() { const canLog = track.status === "DISPATCHED"; const totalStations = track.stations.length; const reached = Math.min(track.currentSequenceNo + 1, totalStations); - const progressLabel = `${reached} / ${totalStations}`; + const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0; + const clampedPct = Math.min(100, Math.max(0, progressPct)); + const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"; const handleLog = (sequenceNo: number) => { const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo; @@ -105,7 +130,7 @@ export default function TrainScheduleTrackPage() { }; return ( - + )} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index a498580a7..bc57dcdeb 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -45,16 +45,9 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ }, { prefix: "/dashboard/operations/train-scheduling-v2", - meta: { - title: "Train Schedules v2", - subtitle: "Operational train scheduling with full allocation workflow", - }, - }, - { - prefix: "/dashboard/operations/train-scheduling", meta: { title: "Train Schedules", - subtitle: "Create and manage container train schedules", + subtitle: "Operational train scheduling with full allocation workflow", }, }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx deleted file mode 100644 index 53ee2fa6f..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx +++ /dev/null @@ -1,482 +0,0 @@ -import { useMemo, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { isAxiosError } from 'axios'; -import toast from 'react-hot-toast'; -import { Calendar, RefreshCw, TrainTrack } from 'lucide-react'; -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@edr/ui-common'; - -import Breadcrumbs from '@/components/ui/Breadcrumbs'; -import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; -import { useRoutes } from '@/hooks/useRoutes'; -import { trainSchedulingService } from '@/services/trainScheduling.service'; - -const inputClassName = - 'w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950'; - -const formatDate = (value?: string | null) => { - if (!value) return '-'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return '-'; - return new Intl.DateTimeFormat('en', { - year: 'numeric', - month: 'short', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }).format(date); -}; - -const parseError = (error: unknown, fallback: string) => { - if (isAxiosError(error)) { - const message = error.response?.data?.message; - if (Array.isArray(message)) return message.join(', '); - if (typeof message === 'string') return message; - const violations = error.response?.data?.violations; - if (Array.isArray(violations)) return violations.join(', '); - } - return fallback; -}; - -const TrainsPage = () => { - const qc = useQueryClient(); - const [routeId, setRouteId] = useState(''); - const [scheduleDate, setScheduleDate] = useState(''); - const [selectedLocomotiveId, setSelectedLocomotiveId] = useState(''); - const [detailId, setDetailId] = useState(null); - const [scheduleSearch, setScheduleSearch] = useState(''); - const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL'); - - const routesQuery = useRoutes(); - const locomotivesQuery = useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(), - queryFn: () => trainSchedulingService.getAvailableLocomotives(), - }); - const schedulesQuery = useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(), - queryFn: () => trainSchedulingService.listSchedules(), - }); - const detailQuery = useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''), - queryFn: () => trainSchedulingService.getScheduleById(detailId!), - enabled: Boolean(detailId), - }); - - const activeRoutes = useMemo( - () => (routesQuery.data ?? []).filter((route) => route.isActive), - [routesQuery.data], - ); - const selectedRoute = activeRoutes.find((route) => route.id === routeId) ?? null; - const selectedLocomotive = (locomotivesQuery.data ?? []).find( - (locomotive) => locomotive.id === selectedLocomotiveId, - ); - - const filteredSchedules = useMemo(() => { - const query = scheduleSearch.trim().toLowerCase(); - - return (schedulesQuery.data ?? []).filter((schedule) => { - const matchesStatus = - scheduleStatusFilter === 'ALL' || schedule.status === scheduleStatusFilter; - - if (!matchesStatus) { - return false; - } - - if (!query) { - return true; - } - - const haystack = [ - schedule.id, - schedule.routeName ?? '', - schedule.origin ?? '', - schedule.destination ?? '', - schedule.locomotive?.code ?? '', - schedule.status, - ] - .join(' ') - .toLowerCase(); - - return haystack.includes(query); - }); - }, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]); - - const createMutation = useMutation({ - mutationFn: () => { - if (!routeId || !scheduleDate || !selectedLocomotiveId) { - throw new Error('Please select route, departure date, and locomotive'); - } - - return trainSchedulingService.createSchedule({ - routeId, - scheduleDate: new Date(`${scheduleDate}T08:00:00.000Z`).toISOString(), - locomotiveId: selectedLocomotiveId, - }); - }, - onSuccess: (data) => { - toast.success('Train schedule created'); - setRouteId(''); - setScheduleDate(''); - setSelectedLocomotiveId(''); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() }); - setDetailId(data.id); - }, - onError: (error) => { - toast.error(parseError(error, 'Failed to create train schedule')); - }, - }); - - const cancelMutation = useMutation({ - mutationFn: (id: string) => trainSchedulingService.cancelSchedule(id), - onSuccess: (data) => { - toast.success('Train schedule cancelled'); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(data.id) }); - setDetailId(data.id); - }, - onError: (error) => { - toast.error(parseError(error, 'Failed to cancel train schedule')); - }, - }); - - const detail = detailQuery.data; - const isBusy = createMutation.isPending; - - return ( -
- - -
-
-
-
- -
-
-

Train Schedules

-

- Create the train schedule first, reserve the locomotive, and assign bookings and wagons later. -

-
-
- -
- -
-
-
- -

Schedule builder

-
- -
-
- - -
- -
- - setScheduleDate(event.target.value)} - /> -
- -
- - -
-
- -
-
-

Origin

-

- {selectedRoute?.originYard?.label ?? selectedRoute?.originYard?.code ?? '-'} -

-
-
-

Destination

-

- {selectedRoute?.destinationYard?.label ?? selectedRoute?.destinationYard?.code ?? '-'} -

-
-
-

Locomotive capacity

-

- {selectedLocomotive - ? `${selectedLocomotive.maxPullWeightTons}T / ${selectedLocomotive.maxTrainLengthMeters}m` - : '-'} -

-
-
-

Next step

-

Assign bookings, then allocate wagons

-
-
- -
- -
-
- -
-
-
-

Created schedules

-

- Open a schedule to inspect the reserved locomotive and prepare for later booking and wagon work. -

-
- - {filteredSchedules.length} schedules - -
- -
- setScheduleSearch(event.target.value)} - /> - -
- -
- - - - - - - - - - - - - - - - - {filteredSchedules.map((schedule) => ( - - - - - - - - - - - - - ))} - {!schedulesQuery.isLoading && filteredSchedules.length === 0 ? ( - - - - ) : null} - -
ScheduleDepartureRouteLocomotiveBookingsWagonsWeightLengthStatusActions
{schedule.id}{formatDate(schedule.scheduleDate)} - {schedule.routeName ?? `${schedule.origin ?? '-'} to ${schedule.destination ?? '-'}`} - {schedule.locomotive?.code ?? '-'}{schedule.bookingsCount}{schedule.wagonCount}{schedule.totalWeightTons} T{schedule.totalLengthMeters} m{schedule.status} -
- - {schedule.status !== 'CANCELLED' ? ( - - ) : null} -
-
- No train schedules matched the current filters. -
-
-
-
-
- - (!open ? setDetailId(null) : null)}> - - - Train schedule detail - - Inspect the selected schedule. Booking assignment and wagon allocation happen after schedule creation. - - - - {detail ? ( -
-
-
-

Schedule

-

{detail.id}

-
-
-

Departure

-

{formatDate(detail.scheduledDepartureDate)}

-
-
-

Route

-

{detail.route?.name ?? '-'}

-
-
-

Origin / destination

-

- {detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '} - {detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'} -

-
-
-

Status

-

{detail.status}

-
-
- -
-

Locomotive

-

- {detail.trainSet?.locomotive - ? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity / ${detail.trainSet.locomotive.maxTrainLengthMeters ?? 0}m)` - : 'No locomotive attached'} -

-
- -
-

Wagons and allocations

- {(detail.trainSet?.wagons?.length ?? 0) === 0 ? ( -

No wagons allocated yet.

- ) : ( -
- {(detail.trainSet?.wagons ?? []).map((wagon) => ( -
-
-
-

- Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'} -

-

- {wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m -

-
-
-
- ))} -
- )} -
- -
-

Bookings in schedule

- {detail.bookings.length === 0 ? ( -

No bookings assigned yet.

- ) : ( -
- - - - - - - - - - - {detail.bookings.map((booking) => ( - - - - - - - ))} - -
ReferenceCustomerWeightStatus
{booking.reference ?? booking.id}{booking.customer ?? '-'}{booking.weightTons} T{booking.status ?? '-'}
-
- )} -
-
- ) : ( -

Loading schedule detail...

- )} -
-
-
- ); -}; - -export default TrainsPage;