From 3b961f17f5acffb4b9158b3f22ea8530f098a056 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sun, 14 Jun 2026 18:47:41 +0300 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 ca9a67837c302fcc4fbbc7cd2d0d5cfbfabca023 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 16 Jun 2026 08:49:41 +0300 Subject: [PATCH 5/7] feat: ( payment ) implement d-money payment --- .../migration.sql | 2 + apps/edr-passenger-api/prisma/schema.prisma | 903 +++++++++--------- .../src/modules/payments/payments.dto.ts | 3 +- .../src/config/dmoney.config.ts | 14 +- .../handlers/dmoney-webhook.service.ts | 30 +- .../modules/webhooks/webhooks.controller.ts | 2 +- packages/payment-providers/src/index.ts | 10 + .../src/providers/dmoney/dmoney.provider.ts | 381 +++++--- .../src/providers/dmoney/dmoney.types.ts | 76 ++ .../src/webhooks/dmoney-webhook.types.ts | 25 +- 10 files changed, 825 insertions(+), 621 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql create mode 100644 packages/payment-providers/src/providers/dmoney/dmoney.types.ts diff --git a/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql new file mode 100644 index 000000000..9b9228768 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY'; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 34fe198d2..33eeb2386 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -3,9 +3,9 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - schemas = ["passenger"] + provider = "postgresql" + url = env("DATABASE_URL") + schemas = ["passenger"] } enum UserRole { @@ -71,12 +71,12 @@ enum Currency { } model CoachType { - id String @id @default(uuid()) + id String @id @default(uuid()) code String name String - type String @default("passenger") // 'passenger', 'sleeper', 'dining', 'baggage' - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + type String @default("passenger") // 'passenger', 'sleeper', 'dining', 'baggage' + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt coaches Coach[] seatClasses SeatClass[] @@ -84,21 +84,21 @@ model CoachType { } model SeatClass { - id String @id @default(uuid()) - coachTypeId String - name String - description String? - baseFareMinor Int - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - coachType CoachType @relation(fields: [coachTypeId], references: [id]) - fareRules FareRule[] + id String @id @default(uuid()) + coachTypeId String + name String + description String? + baseFareMinor Int + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + coachType CoachType @relation(fields: [coachTypeId], references: [id]) + fareRules FareRule[] routeFareRules RouteFareRule[] - segmentFares SegmentFareRule[] + segmentFares SegmentFareRule[] + @@unique([coachTypeId, name]) @@index([coachTypeId]) - @@schema("passenger") } @@ -130,6 +130,7 @@ enum PaymentMethodType { CARD WALLET WAAFI + DMONEY @@schema("passenger") } @@ -225,34 +226,34 @@ enum DevicePlatform { } model User { - id String @id @default(uuid()) - email String @unique - phone String @unique - fullName String - passwordHash String - role UserRole @default(PASSENGER) - nationality String? - nationalityCode String? - passportNumber String? - nationalId String? - failedLoginAttempts Int @default(0) - lockedUntil DateTime? - blockedUntil DateTime? - lastLoginAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + email String @unique + phone String @unique + fullName String + passwordHash String + role UserRole @default(PASSENGER) + nationality String? + nationalityCode String? + passportNumber String? + nationalId String? + failedLoginAttempts Int @default(0) + lockedUntil DateTime? + blockedUntil DateTime? + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - faydaVerified Boolean @default(false) - faydaVerifiedAt DateTime? - faydaSub String? @unique + faydaVerified Boolean @default(false) + faydaVerifiedAt DateTime? + faydaSub String? @unique - passenger Passenger? - agent Agent? - sessions Session[] - devices Device[] - preferences UserPreferences? - auditLogs AuditLog[] - fraudAlerts FraudAlert[] + passenger Passenger? + agent Agent? + sessions Session[] + devices Device[] + preferences UserPreferences? + auditLogs AuditLog[] + fraudAlerts FraudAlert[] faydaVerificationSessions FaydaVerificationSession[] @@ -260,34 +261,34 @@ model User { } model Session { - id String @id @default(uuid()) - userId String - token String @unique - expiresAt DateTime - ipAddress String? - userAgent String? + id String @id @default(uuid()) + userId String + token String @unique + expiresAt DateTime + ipAddress String? + userAgent String? lastActivityAt DateTime @default(now()) - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@schema("passenger") } model Passenger { - id String @id @default(uuid()) - userId String @unique + id String @id @default(uuid()) + userId String @unique defaultTravelerProfileId String? - preferredLanguage String? - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) - bookings Booking[] - loyalty LoyaltyAccount? - wallet WalletAccount? - notifications Notification[] - travelerProfiles TravelerProfile[] - savedRoutes SavedRoute[] - @@index([userId]) + preferredLanguage String? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) + bookings Booking[] + loyalty LoyaltyAccount? + wallet WalletAccount? + notifications Notification[] + travelerProfiles TravelerProfile[] + savedRoutes SavedRoute[] + @@index([userId]) @@schema("passenger") } @@ -306,21 +307,21 @@ model TravelerProfile { } model Station { - id String @id @default(uuid()) - code String @unique - name String - city String - countryCode String? - isOperational Boolean @default(true) - timezone String @default("Africa/Addis_Ababa") - lat Decimal @db.Decimal(9, 6) - lng Decimal @db.Decimal(9, 6) - originSchedules TrainSchedule[] @relation("OriginTrips") - destinationSchedules TrainSchedule[] @relation("DestinationTrips") + id String @id @default(uuid()) + code String @unique + name String + city String + countryCode String? + isOperational Boolean @default(true) + timezone String @default("Africa/Addis_Ababa") + lat Decimal @db.Decimal(9, 6) + lng Decimal @db.Decimal(9, 6) + originSchedules TrainSchedule[] @relation("OriginTrips") + destinationSchedules TrainSchedule[] @relation("DestinationTrips") stopTimes TripStopTime[] crowdSignals StationCrowdSignal[] - @@index([city, countryCode]) + @@index([city, countryCode]) @@schema("passenger") } @@ -354,18 +355,18 @@ model TrainSchedule { onTimePercent Int @default(100) carbonRating String @default("A") notes String? - train Train @relation(fields: [trainId], references: [id]) - route Route? @relation(fields: [routeId], references: [id]) - originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) - destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id]) - coachAssignments CoachAssignment[] - bookings Booking[] - stopTimes TripStopTime[] - liveStatus TripLiveStatus? - menuItems MenuItem[] - journeySegments JourneySegment[] - @@index([departureAt, originStationId]) + train Train @relation(fields: [trainId], references: [id]) + route Route? @relation(fields: [routeId], references: [id]) + originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) + destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id]) + coachAssignments CoachAssignment[] + bookings Booking[] + stopTimes TripStopTime[] + liveStatus TripLiveStatus? + menuItems MenuItem[] + journeySegments JourneySegment[] + @@index([departureAt, originStationId]) @@schema("passenger") } @@ -378,10 +379,10 @@ model TripStopTime { plannedDepartureAt DateTime? actualArrivalAt DateTime? status StopStatus @default(UPCOMING) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - station Station @relation(fields: [stationId], references: [id]) - @@unique([scheduleId, sequence]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + station Station @relation(fields: [stationId], references: [id]) + @@unique([scheduleId, sequence]) @@schema("passenger") } @@ -395,64 +396,64 @@ model TripLiveStatus { currentSpeedKph Int? platformLabel String? updatedAt DateTime @updatedAt - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) @@schema("passenger") } model Coach { - id String @id @default(uuid()) - coachTypeId String - number String @unique - arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2' - capacity Int @default(0) // Total seats/beds - status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE' - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + coachTypeId String + number String @unique + arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2' + capacity Int @default(0) // Total seats/beds + status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE' + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt coachType CoachType @relation(fields: [coachTypeId], references: [id]) seats Seat[] assignments CoachAssignment[] - @@index([coachTypeId]) + @@index([coachTypeId]) @@schema("passenger") } model CoachAssignment { - id String @id @default(uuid()) - scheduleId String - coachId String - positionNumber Int - isOperational Boolean @default(true) - createdAt DateTime @default(now()) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - coach Coach @relation(fields: [coachId], references: [id]) + id String @id @default(uuid()) + scheduleId String + coachId String + positionNumber Int + isOperational Boolean @default(true) + createdAt DateTime @default(now()) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + coach Coach @relation(fields: [coachId], references: [id]) + @@unique([scheduleId, positionNumber]) @@index([scheduleId]) - @@schema("passenger") } model Seat { - id String @id @default(uuid()) + id String @id @default(uuid()) coachId String - seatNumber String // Auto-generated: e.g., '1', '2', '3' (unique per coach) + seatNumber String // Auto-generated: e.g., '1', '2', '3' (unique per coach) row Int col String - kind SeatKind @default(STANDARD) - status SeatStatus @default(AVAILABLE) + kind SeatKind @default(STANDARD) + status SeatStatus @default(AVAILABLE) heldUntil DateTime? - isWindow Boolean @default(false) - isAisle Boolean @default(false) - bedPosition String? // 'lower', 'middle', 'upper' - premiumFeeMinor Int @default(0) - coach Coach @relation(fields: [coachId], references: [id]) - bookingSeats BookingSeat[] - blocks SeatBlock[] - ticketSeats TicketSeat[] + isWindow Boolean @default(false) + isAisle Boolean @default(false) + bedPosition String? // 'lower', 'middle', 'upper' + premiumFeeMinor Int @default(0) + coach Coach @relation(fields: [coachId], references: [id]) + bookingSeats BookingSeat[] + blocks SeatBlock[] + ticketSeats TicketSeat[] + @@unique([coachId, seatNumber]) @@unique([coachId, row, col]) @@index([coachId]) - @@schema("passenger") } @@ -465,8 +466,8 @@ model SeatHold { createdBy String? expiresAt DateTime createdAt DateTime @default(now()) - @@index([expiresAt]) + @@index([expiresAt]) @@schema("passenger") } @@ -474,77 +475,77 @@ model FareRule { id String @id @default(uuid()) tripId String? route String? - nationality String? // Ethiopian, Djiboutian, Other + nationality String? // Ethiopian, Djiboutian, Other seatClassId String baseFareMinor Int - seatClass SeatClass @relation(fields: [seatClassId], references: [id]) - currency String @default("ETB") - refundable Boolean @default(true) + seatClass SeatClass @relation(fields: [seatClassId], references: [id]) + currency String @default("ETB") + refundable Boolean @default(true) validFrom DateTime validUntil DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@schema("passenger") } model Booking { - id String @id @default(uuid()) - bookingRef String @unique - passengerId String - scheduleId String - status BookingStatus @default(DRAFT) - currency String @default("ETB") - totalMinor Int - adultCount Int @default(1) - childCount Int @default(0) - displayCurrency Currency? + id String @id @default(uuid()) + bookingRef String @unique + passengerId String + scheduleId String + status BookingStatus @default(DRAFT) + currency String @default("ETB") + totalMinor Int + adultCount Int @default(1) + childCount Int @default(0) + displayCurrency Currency? displayTotalMinor Int? - bookingType String @default("ONE_WAY") - contactEmail String? - contactPhone String? - userAgent String? - source String @default("WEB") - promoCode String? - paidAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - passenger Passenger @relation(fields: [passengerId], references: [id]) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - seats BookingSeat[] - paymentIntent PaymentIntent? - ticket Ticket? - foodOrders FoodOrder[] - agentBooking AgentBooking? - modifications BookingModification[] - cancellation BookingCancellation? - baggage BaggageBooking[] - @@index([passengerId, status]) + bookingType String @default("ONE_WAY") + contactEmail String? + contactPhone String? + userAgent String? + source String @default("WEB") + promoCode String? + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + passenger Passenger @relation(fields: [passengerId], references: [id]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + seats BookingSeat[] + paymentIntent PaymentIntent? + ticket Ticket? + foodOrders FoodOrder[] + agentBooking AgentBooking? + modifications BookingModification[] + cancellation BookingCancellation? + baggage BaggageBooking[] + @@index([passengerId, status]) @@schema("passenger") } model BookingSeat { - id String @id @default(uuid()) - bookingId String - seatId String - passengerName String - dateOfBirth DateTime? - passengerCategory PassengerCategory @default(ADULT) - idDocumentType IdDocumentType? - idDocumentNumber String? - passportNumber String? - passportCountry String? - verifaydaVerified Boolean @default(false) - verifaydaData Json? - faydaVerifiedAt DateTime? - faydaSub String? - faydaVerifiedName String? - seatLabelSnapshot String? - fareMinor Int? - displayCurrency Currency? - displayFareMinor Int? - booking Booking @relation(fields: [bookingId], references: [id]) - seat Seat @relation(fields: [seatId], references: [id]) + id String @id @default(uuid()) + bookingId String + seatId String + passengerName String + dateOfBirth DateTime? + passengerCategory PassengerCategory @default(ADULT) + idDocumentType IdDocumentType? + idDocumentNumber String? + passportNumber String? + passportCountry String? + verifaydaVerified Boolean @default(false) + verifaydaData Json? + faydaVerifiedAt DateTime? + faydaSub String? + faydaVerifiedName String? + seatLabelSnapshot String? + fareMinor Int? + displayCurrency Currency? + displayFareMinor Int? + booking Booking @relation(fields: [bookingId], references: [id]) + seat Seat @relation(fields: [seatId], references: [id]) @@schema("passenger") } @@ -587,11 +588,11 @@ model PaymentIntent { expiresAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - booking Booking @relation(fields: [bookingId], references: [id]) - refunds PaymentRefund[] + booking Booking @relation(fields: [bookingId], references: [id]) + refunds PaymentRefund[] + @@index([providerOrderId]) @@index([providerTxnId]) - @@schema("passenger") } @@ -607,68 +608,68 @@ model PaymentWebhookEvent { receivedAt DateTime @default(now()) processedAt DateTime? processingError String? + @@unique([provider, externalEventId]) @@index([merchantOrderId]) - @@schema("passenger") } model PaymentRefund { - id String @id @default(uuid()) + id String @id @default(uuid()) paymentIntentId String amountMinor Int reason String? providerRefundId String? status String - createdAt DateTime @default(now()) - paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id]) + createdAt DateTime @default(now()) + paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id]) @@schema("passenger") } model Ticket { - id String @id @default(uuid()) - bookingId String @unique - bookingRef String - status String @default("CONFIRMED") - qrPayload String - barcodePayload String? - pdfUrl String? - deliveryChannel String @default("EMAIL") - issuedAt DateTime @default(now()) - validatedAt DateTime? - validatorId String? - booking Booking @relation(fields: [bookingId], references: [id]) - validationLogs GateValidationLog[] - seats TicketSeat[] + id String @id @default(uuid()) + bookingId String @unique + bookingRef String + status String @default("CONFIRMED") + qrPayload String + barcodePayload String? + pdfUrl String? + deliveryChannel String @default("EMAIL") + issuedAt DateTime @default(now()) + validatedAt DateTime? + validatorId String? + booking Booking @relation(fields: [bookingId], references: [id]) + validationLogs GateValidationLog[] + seats TicketSeat[] @@schema("passenger") } model TicketSeat { - id String @id @default(uuid()) + id String @id @default(uuid()) ticketId String seatId String - seatIndex Int @default(0) - ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) - seat Seat @relation(fields: [seatId], references: [id]) + seatIndex Int @default(0) + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + seat Seat @relation(fields: [seatId], references: [id]) + @@index([ticketId]) @@index([seatId]) - @@schema("passenger") } model LoyaltyAccount { - id String @id @default(uuid()) - passengerId String @unique - pointsBalance Int @default(0) - lifetimePoints Int @default(0) - tier LoyaltyTier @default(BRONZE) - tierUpdatedAt DateTime? - updatedAt DateTime @updatedAt - passenger Passenger @relation(fields: [passengerId], references: [id]) - ledger LoyaltyLedgerEntry[] - rewards LoyaltyReward[] + id String @id @default(uuid()) + passengerId String @unique + pointsBalance Int @default(0) + lifetimePoints Int @default(0) + tier LoyaltyTier @default(BRONZE) + tierUpdatedAt DateTime? + updatedAt DateTime @updatedAt + passenger Passenger @relation(fields: [passengerId], references: [id]) + ledger LoyaltyLedgerEntry[] + rewards LoyaltyReward[] @@schema("passenger") } @@ -681,35 +682,35 @@ model LoyaltyLedgerEntry { bookingId String? balanceAfter Int createdAt DateTime @default(now()) - account LoyaltyAccount @relation(fields: [accountId], references: [id]) + account LoyaltyAccount @relation(fields: [accountId], references: [id]) @@schema("passenger") } model LoyaltyReward { - id String @id @default(uuid()) + id String @id @default(uuid()) accountId String title String costPoints Int - available Boolean @default(true) + available Boolean @default(true) description String? - account LoyaltyAccount @relation(fields: [accountId], references: [id]) + account LoyaltyAccount @relation(fields: [accountId], references: [id]) @@schema("passenger") } model WalletAccount { - id String @id @default(uuid()) - passengerId String @unique - balanceMinor Int @default(0) - status String @default("ACTIVE") - holdMinor Int @default(0) - currency String @default("ETB") - updatedAt DateTime @updatedAt - passenger Passenger @relation(fields: [passengerId], references: [id]) - ledger WalletLedgerEntry[] - @@index([passengerId]) + id String @id @default(uuid()) + passengerId String @unique + balanceMinor Int @default(0) + status String @default("ACTIVE") + holdMinor Int @default(0) + currency String @default("ETB") + updatedAt DateTime @updatedAt + passenger Passenger @relation(fields: [passengerId], references: [id]) + ledger WalletLedgerEntry[] + @@index([passengerId]) @@schema("passenger") } @@ -722,7 +723,7 @@ model WalletLedgerEntry { description String relatedBookingId String? createdAt DateTime @default(now()) - wallet WalletAccount @relation(fields: [walletId], references: [id]) + wallet WalletAccount @relation(fields: [walletId], references: [id]) @@schema("passenger") } @@ -737,7 +738,7 @@ model Notification { deepLink String? metadata Json? createdAt DateTime @default(now()) - passenger Passenger @relation(fields: [passengerId], references: [id]) + passenger Passenger @relation(fields: [passengerId], references: [id]) @@schema("passenger") } @@ -759,15 +760,15 @@ model Promotion { } model StationCrowdSignal { - id String @id @default(uuid()) + id String @id @default(uuid()) stationId String level String label String statusLabel String confidence Int? observedAt DateTime? - updatedAt DateTime @updatedAt - station Station @relation(fields: [stationId], references: [id]) + updatedAt DateTime @updatedAt + station Station @relation(fields: [stationId], references: [id]) @@schema("passenger") } @@ -793,44 +794,44 @@ model MenuCategory { } model MenuItem { - id String @id @default(uuid()) - scheduleId String - categoryId String - name String - priceMinor Int - currency String @default("ETB") - available Boolean @default(true) + id String @id @default(uuid()) + scheduleId String + categoryId String + name String + priceMinor Int + currency String @default("ETB") + available Boolean @default(true) availableUntil DateTime? - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - category MenuCategory @relation(fields: [categoryId], references: [id]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + category MenuCategory @relation(fields: [categoryId], references: [id]) @@schema("passenger") } model FoodOrder { - id String @id @default(uuid()) - bookingId String - status FoodOrderStatus @default(PENDING) - totalMinor Int - currency String @default("ETB") + id String @id @default(uuid()) + bookingId String + status FoodOrderStatus @default(PENDING) + totalMinor Int + currency String @default("ETB") specialInstructions String? - estimatedReadyAt DateTime? - createdAt DateTime @default(now()) - booking Booking @relation(fields: [bookingId], references: [id]) - items FoodOrderItem[] + estimatedReadyAt DateTime? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + items FoodOrderItem[] @@schema("passenger") } model FoodOrderItem { - id String @id @default(uuid()) + id String @id @default(uuid()) orderId String menuItemId String name String quantity Int unitPriceMinor Int? lineTotalMinor Int - order FoodOrder @relation(fields: [orderId], references: [id]) + order FoodOrder @relation(fields: [orderId], references: [id]) @@schema("passenger") } @@ -850,30 +851,30 @@ model FaqArticle { question String answerMarkdown String rank Int @default(0) - category FaqCategory @relation(fields: [categoryId], references: [id]) + category FaqCategory @relation(fields: [categoryId], references: [id]) @@schema("passenger") } model SupportConversation { - id String @id @default(uuid()) - userId String + id String @id @default(uuid()) + userId String assignedAgentId String? - status SupportConversationStatus @default(OPEN) - createdAt DateTime @default(now()) - messages SupportMessage[] + status SupportConversationStatus @default(OPEN) + createdAt DateTime @default(now()) + messages SupportMessage[] @@schema("passenger") } model SupportMessage { - id String @id @default(uuid()) + id String @id @default(uuid()) conversationId String sender SupportSender text String attachments Json? - createdAt DateTime @default(now()) - conversation SupportConversation @relation(fields: [conversationId], references: [id]) + createdAt DateTime @default(now()) + conversation SupportConversation @relation(fields: [conversationId], references: [id]) @@schema("passenger") } @@ -893,7 +894,7 @@ model UserPreferences { locale String @default("en") darkMode Boolean @default(false) language String @default("en") - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@schema("passenger") } @@ -906,48 +907,48 @@ model Device { pushToken String? trusted Boolean @default(false) lastSeenAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@schema("passenger") } model SavedRoute { - id String @id @default(uuid()) + id String @id @default(uuid()) passengerId String fromStationId String toStationId String fromName String toName String - tripCount Int @default(0) - createdAt DateTime @default(now()) - passenger Passenger @relation(fields: [passengerId], references: [id]) + tripCount Int @default(0) + createdAt DateTime @default(now()) + passenger Passenger @relation(fields: [passengerId], references: [id]) @@schema("passenger") } model Journey { - id String @id @default(uuid()) - passengerId String - status String - totalMinor Int - currency String @default("ETB") - createdAt DateTime @default(now()) + id String @id @default(uuid()) + passengerId String + status String + totalMinor Int + currency String @default("ETB") + createdAt DateTime @default(now()) journeySegments JourneySegment[] @@schema("passenger") } model JourneySegment { - id String @id @default(uuid()) - journeyId String - scheduleId String - segmentOrder Int - seatId String? - coachId String? - departureStationId String - arrivalStationId String - journey Journey @relation(fields: [journeyId], references: [id]) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + id String @id @default(uuid()) + journeyId String + scheduleId String + segmentOrder Int + seatId String? + coachId String? + departureStationId String + arrivalStationId String + journey Journey @relation(fields: [journeyId], references: [id]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) @@schema("passenger") } @@ -962,8 +963,8 @@ model OtpCode { expiresAt DateTime verified Boolean @default(false) createdAt DateTime @default(now()) - @@index([email, phone]) + @@index([email, phone]) @@schema("passenger") } @@ -974,39 +975,39 @@ model PasswordResetToken { expiresAt DateTime used Boolean @default(false) createdAt DateTime @default(now()) - @@index([userId]) + @@index([userId]) @@schema("passenger") } model Route { - id String @id @default(uuid()) - code String @unique - name String - description String? - effectiveFrom DateTime + id String @id @default(uuid()) + code String @unique + name String + description String? + effectiveFrom DateTime effectiveUntil DateTime? - active Boolean @default(true) - createdAt DateTime @default(now()) - stops RouteStop[] - fareRules RouteFareRule[] - segmentFares SegmentFareRule[] - schedules TrainSchedule[] + active Boolean @default(true) + createdAt DateTime @default(now()) + stops RouteStop[] + fareRules RouteFareRule[] + segmentFares SegmentFareRule[] + schedules TrainSchedule[] @@schema("passenger") } model RouteStop { - id String @id @default(uuid()) - routeId String - stationId String - sequence Int - distanceKm Int? - createdAt DateTime @default(now()) - route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + routeId String + stationId String + sequence Int + distanceKm Int? + createdAt DateTime @default(now()) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + @@unique([routeId, sequence]) @@index([routeId, stationId]) - @@schema("passenger") } @@ -1023,120 +1024,120 @@ model RouteFareRule { validFrom DateTime validUntil DateTime? createdAt DateTime @default(now()) - route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) - seatClass SeatClass @relation(fields: [seatClassId], references: [id]) - @@index([routeId, seatClassId]) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + seatClass SeatClass @relation(fields: [seatClassId], references: [id]) + @@index([routeId, seatClassId]) @@schema("passenger") } model SegmentFareRule { - id String @id @default(uuid()) - routeId String - originStopSequence Int + id String @id @default(uuid()) + routeId String + originStopSequence Int destinationStopSequence Int - seatClassId String - baseFareMinor Int - nationality String? // Optional: Ethiopian, Djiboutian, Other - currency String @default("ETB") - validFrom DateTime - validUntil DateTime? - createdAt DateTime @default(now()) - route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) - seatClass SeatClass @relation(fields: [seatClassId], references: [id]) + seatClassId String + baseFareMinor Int + nationality String? // Optional: Ethiopian, Djiboutian, Other + currency String @default("ETB") + validFrom DateTime + validUntil DateTime? + createdAt DateTime @default(now()) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + seatClass SeatClass @relation(fields: [seatClassId], references: [id]) + @@unique([routeId, originStopSequence, destinationStopSequence, seatClassId, nationality]) @@index([routeId, seatClassId]) - @@schema("passenger") } model Agent { - id String @id @default(uuid()) - userId String @unique - agentCode String @unique - stationId String? - commissionRate Int @default(5) - active Boolean @default(true) - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) - bookings AgentBooking[] - shifts AgentShift[] - commissions AgentCommission[] + id String @id @default(uuid()) + userId String @unique + agentCode String @unique + stationId String? + commissionRate Int @default(5) + active Boolean @default(true) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) + bookings AgentBooking[] + shifts AgentShift[] + commissions AgentCommission[] @@schema("passenger") } model AgentBooking { - id String @id @default(uuid()) - agentId String - bookingId String @unique - paymentMethod String - cashReceived Int? - changeGiven Int? - paperTicket Boolean @default(false) - createdAt DateTime @default(now()) - agent Agent @relation(fields: [agentId], references: [id]) - booking Booking @relation(fields: [bookingId], references: [id]) + id String @id @default(uuid()) + agentId String + bookingId String @unique + paymentMethod String + cashReceived Int? + changeGiven Int? + paperTicket Boolean @default(false) + createdAt DateTime @default(now()) + agent Agent @relation(fields: [agentId], references: [id]) + booking Booking @relation(fields: [bookingId], references: [id]) @@schema("passenger") } model AgentShift { - id String @id @default(uuid()) - agentId String - openedAt DateTime @default(now()) - closedAt DateTime? - openingBalance Int @default(0) - closingBalance Int? - reconciled Boolean @default(false) - notes String? - agent Agent @relation(fields: [agentId], references: [id]) - @@index([agentId, openedAt]) + id String @id @default(uuid()) + agentId String + openedAt DateTime @default(now()) + closedAt DateTime? + openingBalance Int @default(0) + closingBalance Int? + reconciled Boolean @default(false) + notes String? + agent Agent @relation(fields: [agentId], references: [id]) + @@index([agentId, openedAt]) @@schema("passenger") } model AgentCommission { - id String @id @default(uuid()) + id String @id @default(uuid()) agentId String bookingId String amountMinor Int rate Int paidAt DateTime? - createdAt DateTime @default(now()) - agent Agent @relation(fields: [agentId], references: [id]) - @@index([agentId, paidAt]) + createdAt DateTime @default(now()) + agent Agent @relation(fields: [agentId], references: [id]) + @@index([agentId, paidAt]) @@schema("passenger") } model BookingModification { - id String @id @default(uuid()) - bookingId String - modifiedBy String + id String @id @default(uuid()) + bookingId String + modifiedBy String modificationType String - oldData Json - newData Json - fareAdjustment Int @default(0) - reason String? - createdAt DateTime @default(now()) - booking Booking @relation(fields: [bookingId], references: [id]) - @@index([bookingId]) + oldData Json + newData Json + fareAdjustment Int @default(0) + reason String? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + @@index([bookingId]) @@schema("passenger") } model BookingCancellation { - id String @id @default(uuid()) - bookingId String @unique - cancelledBy String - reason String? - refundAmount Int - refundMethod String - refundStatus String - processedAt DateTime? - createdAt DateTime @default(now()) - booking Booking @relation(fields: [bookingId], references: [id]) + id String @id @default(uuid()) + bookingId String @unique + cancelledBy String + reason String? + refundAmount Int + refundMethod String + refundStatus String + processedAt DateTime? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) @@schema("passenger") } @@ -1150,79 +1151,79 @@ model GateValidationLog { reason String? validatedAt DateTime @default(now()) ticket Ticket @relation(fields: [ticketId], references: [id]) + @@index([ticketId]) @@index([validatorId]) - @@schema("passenger") } model BaggageAllowance { - id String @id @default(uuid()) - seatClassId String - maxWeightKg Int - maxPiecesCount Int - excessFeePerKg Int - currency String @default("ETB") - createdAt DateTime @default(now()) + id String @id @default(uuid()) + seatClassId String + maxWeightKg Int + maxPiecesCount Int + excessFeePerKg Int + currency String @default("ETB") + createdAt DateTime @default(now()) @@schema("passenger") } model BaggageBooking { - id String @id @default(uuid()) - bookingId String - weightKg Int - piecesCount Int - excessFeeMinor Int @default(0) - paid Boolean @default(false) - createdAt DateTime @default(now()) - booking Booking @relation(fields: [bookingId], references: [id]) - @@index([bookingId]) + id String @id @default(uuid()) + bookingId String + weightKg Int + piecesCount Int + excessFeeMinor Int @default(0) + paid Boolean @default(false) + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + @@index([bookingId]) @@schema("passenger") } model AuditLog { - id String @id @default(uuid()) - userId String? - action String - entityType String - entityId String? - oldData Json? - newData Json? - ipAddress String? - userAgent String? - createdAt DateTime @default(now()) - user User? @relation(fields: [userId], references: [id]) + id String @id @default(uuid()) + userId String? + action String + entityType String + entityId String? + oldData Json? + newData Json? + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + user User? @relation(fields: [userId], references: [id]) + @@index([userId, createdAt]) @@index([entityType, entityId]) - @@schema("passenger") } model NotificationTemplate { - id String @id @default(uuid()) - code String @unique - channel String - subject String? + id String @id @default(uuid()) + code String @unique + channel String + subject String? bodyTemplate String - active Boolean @default(true) - createdAt DateTime @default(now()) + active Boolean @default(true) + createdAt DateTime @default(now()) @@schema("passenger") } model SeatBlock { - id String @id @default(uuid()) - seatId String - reason String - blockedBy String - approvedBy String? - blockedAt DateTime @default(now()) - unblockAt DateTime? - seat Seat @relation(fields: [seatId], references: [id]) - @@index([seatId]) + id String @id @default(uuid()) + seatId String + reason String + blockedBy String + approvedBy String? + blockedAt DateTime @default(now()) + unblockAt DateTime? + seat Seat @relation(fields: [seatId], references: [id]) + @@index([seatId]) @@schema("passenger") } @@ -1234,8 +1235,8 @@ model OperationalReport { data Json generatedBy String? createdAt DateTime @default(now()) - @@index([reportType, dateFrom]) + @@index([reportType, dateFrom]) @@schema("passenger") } @@ -1261,9 +1262,9 @@ model FraudAlert { acknowledged Boolean @default(false) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@index([userId, createdAt]) @@index([acknowledged]) - @@schema("passenger") } @@ -1275,45 +1276,45 @@ model CurrencyExchangeRate { effectiveDate DateTime @default(now()) source String @default("MANUAL") createdAt DateTime @default(now()) + @@unique([fromCurrency, toCurrency, effectiveDate]) @@index([fromCurrency, toCurrency]) - @@schema("passenger") } model VerifaydaVerification { - id String @id @default(uuid()) - bookingId String? - nationalId String - requestPayload Json - responsePayload Json? - verified Boolean @default(false) - failureReason String? - verifiedAt DateTime? - createdAt DateTime @default(now()) + id String @id @default(uuid()) + bookingId String? + nationalId String + requestPayload Json + responsePayload Json? + verified Boolean @default(false) + failureReason String? + verifiedAt DateTime? + createdAt DateTime @default(now()) + @@index([nationalId]) @@index([bookingId]) - @@schema("passenger") } model SavedPassengerProfile { - id String @id @default(uuid()) - userId String? - deviceId String? - passengerName String - dateOfBirth DateTime - idDocumentType IdDocumentType - passportNumber String? - passportCountry String? - nationality String? - phone String? - email String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + userId String? + deviceId String? + passengerName String + dateOfBirth DateTime + idDocumentType IdDocumentType + passportNumber String? + passportCountry String? + nationality String? + phone String? + email String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@index([userId]) @@index([deviceId]) - @@schema("passenger") } @@ -1335,13 +1336,11 @@ model FaydaVerificationSession { userId String? bookingId String? - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([bookingId]) @@index([state]) @@index([expiresAt]) - @@schema("passenger") } - 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..7cff2c8ad 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -20,7 +20,8 @@ export enum PaymentMethodTypeEnum { TELEBIRR = "TELEBIRR", // Ethiopia CBE_BIRR = "CBE_BIRR", // Ethiopia EBIRR = "EBIRR", // Ethiopia - WAAFI = "WAAFI", // Djibouti + WAAFI = "WAAFI", + DMONEY= "DMONEY",// Djibouti CARD = "CARD", // International WALLET = "WALLET", // Internal } diff --git a/apps/edr-payment-api/src/config/dmoney.config.ts b/apps/edr-payment-api/src/config/dmoney.config.ts index 78751f8d5..d0938eeea 100644 --- a/apps/edr-payment-api/src/config/dmoney.config.ts +++ b/apps/edr-payment-api/src/config/dmoney.config.ts @@ -2,9 +2,17 @@ import { registerAs } from "@nestjs/config"; export default registerAs("dmoney", () => ({ baseUrl: process.env.DMONEY_BASE_URL ?? "", - appId: process.env.DMONEY_APP_ID ?? "", + webBaseUrl: process.env.DMONEY_WEB_BASE_URL ?? "", + fabricAppId: process.env.DMONEY_FABRIC_APP_ID ?? "", appSecret: process.env.DMONEY_APP_SECRET ?? "", - publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", - privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", + merchantAppId: process.env.DMONEY_MERCHANT_APP_ID ?? "", + merchantCode: process.env.DMONEY_MERCHANT_CODE ?? "", notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "", + returnUrl: process.env.DMONEY_RETURN_URL ?? "", + timeoutExpress: process.env.DMONEY_TIMEOUT_EXPRESS ?? "120m", + language: process.env.DMONEY_LANGUAGE ?? "en", + currency: process.env.DMONEY_CURRENCY ?? "FDJ", + privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", + publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", + insecureTls: process.env.DMONEY_INSECURE_TLS === "true", })); diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts index 51937dee4..163f01107 100644 --- a/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts @@ -13,22 +13,36 @@ export class DMoneyWebhookService { const signatureValid = this.provider.verifyWebhookSignature( payload as unknown as Record, ); - const mapped = this.provider.mapWebhookStatus(payload.status); + const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status); + const providerTxnId = payload.transId ?? payload.payment_order_id; await this.processor.process({ provider: this.provider.method, - externalEventId: `${payload.orderId}_${payload.status}`, - merchantOrderId: payload.merchantOrderId, - providerTxnId: payload.transactionId, + externalEventId: `${payload.payment_order_id}_${payload.trade_status}`, + merchantOrderId: payload.merch_order_id, + providerTxnId, signatureValid, - rawStatus: payload.status, + rawStatus: payload.trade_status, payload: payload as unknown as Record, result: { status: mapped, - providerTxnId: payload.transactionId, - paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined, - failureCode: payload.status, + providerTxnId, + paidAt: this.parseTransEndTime(payload.trans_end_time), + failureCode: payload.trade_status, }, }); } + + /** D-Money sends trans_end_time either as epoch ms/s or "YYYY-MM-DD HH:mm:ss". */ + private parseTransEndTime(raw: string | undefined): Date | undefined { + if (!raw) return undefined; + if (/^\d+$/.test(raw)) { + const n = parseInt(raw, 10); + if (Number.isNaN(n)) return undefined; + // 13-digit value is milliseconds, otherwise seconds. + return new Date(raw.length >= 13 ? n : n * 1000); + } + const parsed = new Date(raw.replace(" ", "T")); + return Number.isNaN(parsed.getTime()) ? undefined : parsed; + } } diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts index 8f1d1bd9e..d367971d0 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -136,7 +136,7 @@ export class WebhooksController { } catch (err) { this.logger.error(`D-Money webhook handler threw: ${this.message(err)}`); } - return { success: true }; + return { code: "0", msg: "Success", result: "SUCCESS" }; } private message(err: unknown): string { diff --git a/packages/payment-providers/src/index.ts b/packages/payment-providers/src/index.ts index 7b77b5684..cc4871e4c 100644 --- a/packages/payment-providers/src/index.ts +++ b/packages/payment-providers/src/index.ts @@ -40,6 +40,16 @@ export type { TelebirrTradeStatus, } from './providers/telebirr/telebirr.types'; +// D-Money request/response types (exported for apps that build/inspect requests directly) +export type { + DMoneyFabricTokenResponse, + DMoneyPreOrderBizContent, + DMoneyPreOrderRequest, + DMoneyPreOrderResponse, + DMoneyQueryOrderResponse, + DMoneyOrderStatus, +} from './providers/dmoney/dmoney.types'; + // Waafi HPP request/response types (exported for apps that build/inspect requests directly) export type { WaafiState, diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 35ca54a3b..2c1a928c5 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -11,97 +11,83 @@ import { } from "@edr/types"; import { AxiosError, AxiosRequestConfig } from "axios"; import { firstValueFrom } from "rxjs"; -import * as crypto from "node:crypto"; +import * as https from "node:https"; +import { + createNonceStr, + createTimestamp, + signRequestObject, + verifyRequestObject, +} from "../telebirr/telebirr.crypto"; +import { + DMoneyFabricTokenResponse, + DMoneyPreOrderRequest, + DMoneyPreOrderResponse, + DMoneyQueryOrderResponse, +} from "./dmoney.types"; -interface DMoneyAuthResponse { - token: string; -} - -interface DMoneyInitiateRequest { - merchantId: string; - merchantOrderId: string; - amount: string; - currency: string; - description: string; - returnUrl: string; - notifyUrl: string; - payerPhone?: string; - timestamp: string; - signature: string; -} - -interface DMoneyInitiateResponse { - success: boolean; - orderId: string; - checkoutUrl?: string; - expiresIn: number; -} - -interface DMoneyQueryResponse { - success: boolean; - orderId: string; - status: string; - transactionId?: string; - amount?: string; - currency?: string; - paidAt?: string; - payerPhone?: string; -} +const DMONEY_HTTP_TIMEOUT_MS = 10_000; +/** + * D-Money (Djibouti) shares the same payment-gateway platform as Telebirr: fabric-token auth, + * payment.preorder / payment.queryorder, SHA256withRSA (PSS) signing, and a signed paygate + * web-checkout redirect. This provider mirrors TelebirrProvider, differing only in endpoint + * paths, the already-"Bearer"-prefixed token, the queryOrder status field (order_status), and + * the web-only client action (no LAUNCH_APP). Crypto is reused from telebirr.crypto (RSA-PSS). + */ @Injectable() export class DMoneyProvider implements PaymentProvider { readonly method = ProviderMethod.DMONEY; private readonly logger = new Logger(DMoneyProvider.name); + private readonly httpsAgent: https.Agent; constructor( private readonly config: ConfigService, private readonly http: HttpService, - ) {} + ) { + const insecure = this.config.get("dmoney.insecureTls"); + if (insecure) { + this.logger.warn( + "DMONEY_INSECURE_TLS=true — TLS verification disabled for D-Money calls. DEV ONLY.", + ); + } + this.httpsAgent = new https.Agent({ + rejectUnauthorized: !insecure, + secureProtocol: "TLSv1_2_method", + }); + } async initiate( input: ProviderInitiationInput, ): Promise { - const token = await this.getFabricToken(); - const amount = (input.amountMinor / 100).toFixed(2); - const timestamp = new Date().toISOString(); - - const requestBody: DMoneyInitiateRequest = { - merchantId: this.merchantId, - merchantOrderId: input.merchantOrderId, - amount, - currency: input.currency, - description: `EDR ${input.orderRef}`, - returnUrl: this.returnUrl, - notifyUrl: this.notifyUrl, - timestamp, - signature: this.signRequest({ - merchantId: this.merchantId, - merchantOrderId: input.merchantOrderId, - amount, - timestamp, - }), - }; - - const response = await this.postJson( - `${this.baseUrl}/api/v1/payment/initiate`, + const fabricToken = await this.applyFabricToken(); + const requestBody = this.buildPreOrderRequest(input); + const response = await this.postJson( + `${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`, requestBody, - token, + { + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, + Authorization: fabricToken, + }, ); - if (!response.success || !response.orderId) { - throw new Error(`DMoney initiate failed: ${JSON.stringify(response)}`); + const prepayId = response.biz_content?.prepay_id; + if (response.result !== "SUCCESS" || !prepayId) { + throw new Error( + `D-Money preOrder failed: ${JSON.stringify(response)}`, + ); } - const expiresAt = new Date(Date.now() + response.expiresIn * 1000); + const expiresAt = this.computeExpiresAt( + requestBody.biz_content.timeout_express, + ); return { - providerOrderId: response.orderId, - clientAction: response.checkoutUrl - ? { type: "REDIRECT", url: response.checkoutUrl } - : { - type: "REDIRECT", - url: `${this.baseUrl}/checkout/${response.orderId}`, - }, + providerOrderId: prepayId, + clientAction: { + type: "REDIRECT", + url: this.buildCheckoutUrl(prepayId), + }, expiresAt, rawInitiation: { request: this.sanitize(requestBody), @@ -111,151 +97,239 @@ export class DMoneyProvider implements PaymentProvider { } async queryStatus(merchantOrderId: string): Promise { - const token = await this.getFabricToken(); - const timestamp = new Date().toISOString(); - const signature = this.signRequest({ - merchantId: this.merchantId, - merchantOrderId, - timestamp, - }); - - const response = await this.postJson( - `${this.baseUrl}/api/v1/payment/query`, + const fabricToken = await this.applyFabricToken(); + const requestBody = this.buildQueryOrderRequest(merchantOrderId); + const response = await this.postJson( + `${this.baseUrl}/apiaccess/payment/v1/merchant/queryOrder`, + requestBody, { - merchantId: this.merchantId, - merchantOrderId, - timestamp, - signature, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, + Authorization: fabricToken, }, - token, ); - const mapped = this.mapStatus(response.status); + const orderStatus = response.biz_content?.order_status; + const providerTxnId = response.biz_content?.payment_order_id; + const mapped = this.mapOrderStatus(orderStatus); return { status: mapped, - providerTxnId: response.transactionId, + providerTxnId, failureCode: - mapped === ProviderPaymentStatus.FAILED ? response.status : undefined, - rawResponse: response as unknown as Record, + mapped === ProviderPaymentStatus.FAILED && orderStatus + ? orderStatus + : undefined, + rawResponse: response as Record, }; } - verifyWebhookSignature(payload: Record): boolean { - const { signature, ...data } = payload; - if (!signature || typeof signature !== "string") return false; - - const expectedSignature = this.signRequest(data); - return crypto.timingSafeEqual( - Buffer.from(signature), - Buffer.from(expectedSignature), - ); - } - - mapWebhookStatus(status: string): ProviderPaymentStatus { - return this.mapStatus(status); - } - - private mapStatus(status: string): ProviderPaymentStatus { - switch (status?.toUpperCase()) { + /** queryOrder `order_status` → shared status. */ + mapOrderStatus(orderStatus: string | undefined): ProviderPaymentStatus { + switch (orderStatus) { + case "PAY_SUCCESS": + case "Completed": case "SUCCESS": - case "COMPLETED": return ProviderPaymentStatus.SUCCEEDED; - case "FAILED": - case "REJECTED": - case "EXPIRED": - case "CANCELLED": + case "PAY_FAILED": + case "Failure": + case "ORDER_CLOSED": + case "Expired": return ProviderPaymentStatus.FAILED; - case "PENDING": + case "WAIT_PAY": return ProviderPaymentStatus.REQUIRES_ACTION; - case "PROCESSING": + case "PAYING": + case "Paying": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; } } - private async getFabricToken(): Promise { - const response = await this.postJson( + /** Notification `trade_status` → shared status. */ + mapWebhookTradeStatus( + tradeStatus: string | undefined, + ): ProviderPaymentStatus { + switch (tradeStatus) { + case "Completed": + return ProviderPaymentStatus.SUCCEEDED; + case "Failure": + case "Expired": + return ProviderPaymentStatus.FAILED; + case "Paying": + return ProviderPaymentStatus.PROCESSING; + default: + return ProviderPaymentStatus.PROCESSING; + } + } + + verifyWebhookSignature(payload: Record): boolean { + if (!this.publicKey) { + this.logger.error( + "DMONEY_PUBLIC_KEY not configured; rejecting all webhooks", + ); + return false; + } + return verifyRequestObject(payload, this.publicKey); + } + + private async applyFabricToken(): Promise { + const response = await this.postJson( `${this.baseUrl}/apiaccess/payment/gateway/payment/v1/token`, + { appSecret: this.appSecret }, { - appSecret: this.appSecret, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, }, ); - - if (!response.token) { + if (!response?.token) { throw new Error( - `DMoney authentication failed: ${JSON.stringify(response)}`, + `D-Money token request failed: ${JSON.stringify(response)}`, ); } - + // D-Money returns the token already prefixed with "Bearer " — use it verbatim. return response.token; } - private signRequest(data: Record): string { - const sortedKeys = Object.keys(data).sort(); - const signString = sortedKeys.map((key) => `${key}=${data[key]}`).join("&"); + private buildPreOrderRequest( + input: ProviderInitiationInput, + ): DMoneyPreOrderRequest { + const totalAmount = (input.amountMinor / 100).toFixed(2); + const redirectUrl = input.redirectUrl ?? this.returnUrl; + const req = { + timestamp: createTimestamp(), + nonce_str: createNonceStr(), + method: "payment.preorder" as const, + version: "1.0" as const, + biz_content: { + notify_url: this.notifyUrl, + appid: this.merchantAppId, + merch_code: this.merchantCode, + merch_order_id: input.merchantOrderId, + trade_type: "Checkout" as const, + title: `EDR ${input.orderRef}`, + total_amount: totalAmount, + trans_currency: 1 == 1 ? "DJF": this.currency, + timeout_express: this.timeoutExpress, + ...(redirectUrl ? { redirect_url: redirectUrl } : {}), + }, + }; - return crypto - .createHmac("sha256", this.secretKey) - .update(signString) - .digest("hex"); + console.log("\n\n\n") + console.log(req) + console.log("\n\n\n") + const sign = signRequestObject( + req as unknown as Record, + this.privateKey, + ); + return { ...req, sign, sign_type: "SHA256WithRSA" }; + } + + private buildQueryOrderRequest( + merchantOrderId: string, + ): Record { + const req = { + timestamp: createTimestamp(), + nonce_str: createNonceStr(), + method: "payment.queryorder", + version: "1.0", + biz_content: { + appid: this.merchantAppId, + merch_code: this.merchantCode, + merch_order_id: merchantOrderId, + }, + }; + const sign = signRequestObject( + req as Record, + this.privateKey, + ); + return { ...req, sign, sign_type: "SHA256WithRSA" }; + } + + private buildCheckoutUrl(prepayId: string): string { + // Only these five fields are signed for the paygate URL. + const map: Record = { + appid: this.merchantAppId, + merch_code: this.merchantCode, + nonce_str: createNonceStr(), + prepay_id: prepayId, + timestamp: createTimestamp(), + }; + const sign = signRequestObject(map, this.privateKey); + const query = [ + `appid=${map.appid}`, + `merch_code=${map.merch_code}`, + `nonce_str=${map.nonce_str}`, + `prepay_id=${map.prepay_id}`, + `timestamp=${map.timestamp}`, + `sign=${sign}`, + "sign_type=SHA256WithRSA", + "version=1.0", + "trade_type=Checkout", + `language=${this.language}`, + ].join("&"); + return `${this.webBaseUrl}/payment/web/paygate?${query}`; + } + + private computeExpiresAt(timeoutExpress: string): Date { + const match = /^(\d+)m$/.exec(timeoutExpress); + const minutes = match ? parseInt(match[1], 10) : 120; + return new Date(Date.now() + minutes * 60_000); } private async postJson( url: string, body: unknown, - token?: string, + headers: Record, ): Promise { - const headers: Record = { - "Content-Type": "application/json", - }; - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - const config: AxiosRequestConfig = { headers, - timeout: 10_000, + timeout: DMONEY_HTTP_TIMEOUT_MS, + httpsAgent: this.httpsAgent, }; - const started = Date.now(); try { const res = await firstValueFrom(this.http.post(url, body, config)); this.logger.debug( - `DMoney POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + `D-Money POST ${url} status=${res.status} latency=${Date.now() - started}ms`, ); return res.data; } catch (err) { if (err instanceof AxiosError) { this.logger.error( - `DMoney POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, + `D-Money POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`, ); } else { this.logger.error( - `DMoney POST ${url} threw: ${err instanceof Error ? err.message : err}`, + `D-Money POST ${url} threw: ${err instanceof Error ? err.message : err}`, ); } throw err; } } - private sanitize(body: DMoneyInitiateRequest): Record { - const { signature: _signature, ...rest } = body; + private sanitize(body: DMoneyPreOrderRequest): Record { + const { sign: _sign, ...rest } = body; return rest; } private get baseUrl(): string { return this.config.get("dmoney.baseUrl") ?? ""; } - private get merchantId(): string { - return this.config.get("dmoney.merchantId") ?? ""; + private get webBaseUrl(): string { + return this.config.get("dmoney.webBaseUrl") ?? ""; + } + private get fabricAppId(): string { + return this.config.get("dmoney.fabricAppId") ?? ""; } private get appSecret(): string { return this.config.get("dmoney.appSecret") ?? ""; } - private get secretKey(): string { - return this.config.get("dmoney.secretKey") ?? ""; + private get merchantAppId(): string { + return this.config.get("dmoney.merchantAppId") ?? ""; + } + private get merchantCode(): string { + return this.config.get("dmoney.merchantCode") ?? ""; } private get notifyUrl(): string { return this.config.get("dmoney.notifyUrl") ?? ""; @@ -263,4 +337,19 @@ export class DMoneyProvider implements PaymentProvider { private get returnUrl(): string { return this.config.get("dmoney.returnUrl") ?? ""; } + private get timeoutExpress(): string { + return this.config.get("dmoney.timeoutExpress") ?? "120m"; + } + private get language(): string { + return this.config.get("dmoney.language") ?? "en"; + } + private get currency(): string { + return this.config.get("dmoney.currency") ?? "FDJ"; + } + private get privateKey(): string { + return this.config.get("dmoney.privateKey") ?? ""; + } + private get publicKey(): string { + return this.config.get("dmoney.publicKey") ?? ""; + } } diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.types.ts b/packages/payment-providers/src/providers/dmoney/dmoney.types.ts new file mode 100644 index 000000000..72a5f2669 --- /dev/null +++ b/packages/payment-providers/src/providers/dmoney/dmoney.types.ts @@ -0,0 +1,76 @@ +export interface DMoneyFabricTokenResponse { + /** Returned already prefixed with "Bearer " — set Authorization to this value verbatim. */ + token: string; + effectiveDate?: string; + expirationDate?: string; +} + +export interface DMoneyPreOrderBizContent { + notify_url: string; + appid: string; + merch_code: string; + merch_order_id: string; + trade_type: 'Checkout'; + title: string; + total_amount: string; + trans_currency: string; + timeout_express: string; + business_type?: string; + redirect_url?: string; + callback_info?: string; +} + +export interface DMoneyPreOrderRequest { + timestamp: string; + nonce_str: string; + method: 'payment.preorder'; + version: '1.0'; + biz_content: DMoneyPreOrderBizContent; + sign: string; + sign_type: 'SHA256WithRSA'; +} + +export interface DMoneyPreOrderResponse { + result?: 'SUCCESS' | 'FAIL'; + code?: string; + msg?: string; + nonce_str?: string; + sign?: string; + sign_type?: string; + biz_content?: { + merch_order_id?: string; + prepay_id?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export type DMoneyOrderStatus = + | 'PAY_SUCCESS' + | 'PAY_FAILED' + | 'WAIT_PAY' + | 'ORDER_CLOSED' + | 'PAYING' + | 'Completed' + | 'Failure' + | 'Expired' + | 'Paying'; + +export interface DMoneyQueryOrderResponse { + result?: 'SUCCESS' | 'FAIL'; + code?: string; + msg?: string; + nonce_str?: string; + sign?: string; + sign_type?: string; + biz_content?: { + merch_order_id?: string; + order_status?: DMoneyOrderStatus | string; + payment_order_id?: string; + trans_time?: string; + trans_currency?: string; + total_amount?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} diff --git a/packages/payment-providers/src/webhooks/dmoney-webhook.types.ts b/packages/payment-providers/src/webhooks/dmoney-webhook.types.ts index 3a8362066..7b0ed9147 100644 --- a/packages/payment-providers/src/webhooks/dmoney-webhook.types.ts +++ b/packages/payment-providers/src/webhooks/dmoney-webhook.types.ts @@ -1,13 +1,18 @@ export interface DMoneyWebhookPayload { - merchantId: string; - merchantOrderId: string; - orderId: string; - status: string; - transactionId?: string; - amount?: string; - currency?: string; - paidAt?: string; - payerPhone?: string; - signature: string; + appid: string; + merch_code: string; + merch_order_id: string; + payment_order_id: string; + notify_time?: string; + trans_end_time?: string; + total_amount?: string; + trans_currency?: string; + /** Paying | Expired | Completed | Failure */ + trade_status: string; + transId?: string; + callback_info?: string; + notify_url?: string; + sign: string; + sign_type?: string; [key: string]: unknown; } From e5877e53392c5f0d4b7949511cbac85ca3017963 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 16 Jun 2026 10:52:36 +0300 Subject: [PATCH 6/7] fix: ( telebirr ) fix query status --- .../src/providers/telebirr/telebirr.provider.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index ecb75c3e8..291bcd0a6 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -101,17 +101,20 @@ export class TelebirrProvider implements PaymentProvider { }, ); - const tradeStatus = response.biz_content?.trade_status; + this.logger.log(response); + + // const tradeStatus = response.biz_content?.trade_status; + const orderStatus = response.biz_content?.order_status; const providerTxnId = response.biz_content?.trans_id ?? response.biz_content?.payment_order_id; - const mapped = this.mapTradeStatus(tradeStatus); + const mapped = this.mapTradeStatus(orderStatus); return { status: mapped, providerTxnId, failureCode: - mapped === ProviderPaymentStatus.FAILED && tradeStatus - ? tradeStatus + mapped === ProviderPaymentStatus.FAILED && orderStatus + ? orderStatus : undefined, rawResponse: response as Record, }; From 8520c3add399fcd3baa87ca805ec993f6b04fe29 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 16 Jun 2026 11:02:07 +0300 Subject: [PATCH 7/7] fix: ( payment ) remove /100 --- .../payment-providers/src/providers/dmoney/dmoney.provider.ts | 2 +- .../src/providers/telebirr/telebirr.provider.ts | 2 +- .../payment-providers/src/providers/waafi/waafi.provider.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 2c1a928c5..cadb5e482 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -194,7 +194,7 @@ export class DMoneyProvider implements PaymentProvider { private buildPreOrderRequest( input: ProviderInitiationInput, ): DMoneyPreOrderRequest { - const totalAmount = (input.amountMinor / 100).toFixed(2); + const totalAmount = (input.amountMinor).toFixed(2); const redirectUrl = input.redirectUrl ?? this.returnUrl; const req = { timestamp: createTimestamp(), diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index 291bcd0a6..d4be867da 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -198,7 +198,7 @@ export class TelebirrProvider implements PaymentProvider { private buildCreateOrderRequest( input: ProviderInitiationInput, ): CreateOrderRequest { - const totalAmount = String(input.amountMinor / 100); + const totalAmount = String(input.amountMinor); const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index 276ff2de2..8551f6086 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -221,7 +221,7 @@ export class WaafiProvider implements PaymentProvider { /** Convert integer minor units to a 2-decimal major amount (truncated, never rounded up). */ private toAmount(amountMinor: number): number { - return Math.trunc(amountMinor) / 100; + return Math.trunc(amountMinor); } private timestamp(): string {