From 73eeee175f911d7443d03c3354530e99164f0de8 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 15 Jun 2026 11:26:51 +0300 Subject: [PATCH 01/14] 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 3e3daba14d051d2be91d80d4f659d4e5df1d3ffb Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Mon, 15 Jun 2026 13:43:48 +0300 Subject: [PATCH 02/14] Update deploy.yml --- .github/workflows/deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e46409ba6..dd241242e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,9 @@ on: - staging workflow_dispatch: +permissions: + contents: read + concurrency: group: deploy-${{ github.ref_name }} cancel-in-progress: true From 96ec2923c256b8cdc6f2150cf109e5b570f92072 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 15 Jun 2026 14:35:14 +0300 Subject: [PATCH 03/14] 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 04/14] 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 03c4ec33c3390d4f900e6e5fc3a2b8f93d320789 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 16 Jun 2026 09:47:05 +0300 Subject: [PATCH 05/14] Added get ticketing info by reference number endpoint and update payment methods to dynamic --- .../src/modules/payments/payments.module.ts | 37 --- .../src/modules/tickets/tickets.controller.ts | 11 + .../src/modules/tickets/tickets.service.ts | 22 ++ apps/edr-passenger-web/portal/PAYMENT_FLOW.md | 186 +++++++++++++ .../portal/TELEBIRR_PAYMENT_FLOW.md | 148 ++++++++++ .../portal/src/app/booking/payment/page.tsx | 261 ++++++++++-------- .../booking/payment/telebirr/failure/page.tsx | 56 ++++ .../booking/payment/telebirr/success/page.tsx | 93 +++++++ .../booking/payment/waafi/failure/page.tsx | 71 +++++ .../booking/payment/waafi/success/page.tsx | 117 ++++++++ .../portal/src/lib/payment-store.ts | 4 +- .../portal/src/types/index.ts | 14 + 12 files changed, 870 insertions(+), 150 deletions(-) create mode 100644 apps/edr-passenger-web/portal/PAYMENT_FLOW.md create mode 100644 apps/edr-passenger-web/portal/TELEBIRR_PAYMENT_FLOW.md create mode 100644 apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx 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..74dc3e764 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,23 @@ 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"; 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 { 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]; - @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 }, - }), - }), ], controllers: [PaymentsController, InternalPaymentsController], providers: [ PaymentsService, PaymentClientService, - PaymentEventsConsumer, ServiceAuthGuard, ], }) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 5711355e0..e7760b565 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -44,6 +44,17 @@ export class TicketsController { }); } + @Get('by-order/:merchantOrderId') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Get ticket by merchant order ID', + description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.' + }) + getByMerchantOrderId(@Param('merchantOrderId') merchantOrderId: string) { + return this.service.getByMerchantOrderId(merchantOrderId); + } + @Get(':bookingRef') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index a77714cf3..a6a3bc711 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -157,6 +157,28 @@ export class TicketsService { return { success: true, updatedSeats: newSeatIds.length }; } + async getByMerchantOrderId(merchantOrderId: string) { + const intent = await this.prisma.paymentIntent.findUnique({ + where: { merchantOrderId }, + select: { bookingId: true }, + }); + if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`); + const booking = await this.prisma.booking.findUnique({ + where: { id: intent.bookingId }, + include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true }, + }); + if (!booking?.ticket) throw new NotFoundException('Ticket not found'); + const seat = booking.seats[0]; + return { + id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status, + fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name, + departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name, + coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName, + priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload, + barcodePayload: booking.ticket.barcodePayload, + }; + } + async getByRef(bookingRef: string) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, diff --git a/apps/edr-passenger-web/portal/PAYMENT_FLOW.md b/apps/edr-passenger-web/portal/PAYMENT_FLOW.md new file mode 100644 index 000000000..767ce2682 --- /dev/null +++ b/apps/edr-passenger-web/portal/PAYMENT_FLOW.md @@ -0,0 +1,186 @@ +# TELEBIRR & WAAFI Payment Integration Flow + +## Overview +Complete payment flow for TELEBIRR and WAAFI integration using the `/payments/initiate` endpoint. + +## Payment Flow + +### 1. Payment Method Selection +- User selects TELEBIRR or WAAFI from available payment methods +- Payment methods fetched from `/payments/methods` +- Extracts payment method ID for the request + +### 2. Payment Initiation +**Endpoint:** `POST /payments/initiate` + +**Request:** +```json +{ + "bookingId": "booking-uuid", + "method": "TELEBIRR" | "WAAFI", + "paymentMethodId": "payment-method-uuid", + "platform": "web" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "intentId": "66aa30e2-52a2-4ad0-9043-df6df4a6fa4a", + "status": "REQUIRES_ACTION", + "clientAction": { + "url": "https://sandbox.waafipay.net/v2/hpp/token/2B68686270593243495535774B317263683930574A413D3D", + "type": "REDIRECT" + }, + "merchantOrderId": "1781588440170af93c3b9" + }, + "timestamp": "2026-06-16T05:40:41.004Z" +} +``` + +### 3. User Redirect +- App stores `intentId` in payment store +- Updates payment status to `REQUIRES_ACTION` +- Redirects user to `clientAction.url` +- User completes payment on payment gateway + +### 4. Callback Handling + +#### TELEBIRR Success Callback +**URL:** `/booking/payment/telebirr/success` + +#### WAAFI Success Callback +**URL:** `/booking/payment/waafi/success` + +**Query Parameters:** +- `accountNo` - Account number (e.g., "25377111111") +- `cardNo` - Card number +- `currency` - Currency code (e.g., "DJF") +- `orderId` - Order ID (e.g., "1209631") +- `referenceId` - Reference ID (e.g., "17815888579838ddc23b3") +- `responseCode` - Response code ("0" for success) +- `responseMsg` - Response message (e.g., "Approved (sandbox mode)") +- `state` - Transaction state (e.g., "APPROVED") +- `transactionId` - Transaction ID (e.g., "1318559") +- `txAmount` - Transaction amount (e.g., "367.50") +- `paymentMethod` - Payment method type (e.g., "MWALLET_ACCOUNT") +- `timestamp` - Transaction timestamp +- `bookingId` - Booking UUID + +**Example:** +``` +?accountNo=25377111111 +&cardNo=25377111111 +¤cy=DJF +&orderId=1209631 +&referenceId=17815888579838ddc23b3 +&responseCode=0 +&responseMsg=Approved+(sandbox+mode) +&state=APPROVED +&transactionId=1318559 +&txAmount=367.50 +&paymentMethod=MWALLET_ACCOUNT +×tamp=2026-06-16T08:48:01+03:00 +``` + +**Actions:** +1. Logs all query parameters +2. Calls `PATCH /bookings/{bookingId}/confirm` with: + ```json + { + "paymentReference": "referenceId or transactionId", + "paymentMethod": "WAAFI", + "transactionDetails": { + "transactionId": "1318559", + "orderId": "1209631", + "accountNo": "25377111111", + "amount": "367.50", + "currency": "DJF", + "state": "APPROVED", + "timestamp": "2026-06-16T08:48:01+03:00" + } + } + ``` +3. Updates payment status to `SUCCEEDED` +4. Redirects to `/booking/confirmation` + +#### TELEBIRR Failure Callback +**URL:** `/booking/payment/telebirr/failure` + + + +## Console Logs + +When TELEBIRR or WAAFI payment is initiated, check browser console for: + +``` +=== TELEBIRR PAYMENT INITIATION === +Request payload: { + bookingId: "...", + method: "TELEBIRR", + paymentMethodId: "...", + platform: "web" +} +=== TELEBIRR PAYMENT RESPONSE === +Full response: {...} +Intent ID: "66aa30e2-52a2-4ad0-9043-df6df4a6fa4a" +Status: "REQUIRES_ACTION" +Client Action: {url: "...", type: "REDIRECT"} +Redirect URL: "https://sandbox.waafipay.net/v2/hpp/token/..." +Merchant Order ID: "1781588440170af93c3b9" +==================================== +=== REDIRECTING TO TELEBIRR PAYMENT GATEWAY === +Intent ID: 66aa30e2-52a2-4ad0-9043-df6df4a6fa4a +Status: REQUIRES_ACTION +Merchant Order ID: 1781588440170af93c3b9 +Redirect URL: https://sandbox.waafipay.net/v2/hpp/token/... +======================================= +``` + +## Files Modified + +1. **`src/app/booking/payment/page.tsx`** + - Added TELEBIRR and WAAFI payment initiation + - Handles redirect response + - Logs all payment data + +2. **`src/lib/payment-store.ts`** + - Added `REQUIRES_ACTION` status + +3. **`src/types/index.ts`** + - Updated `PaymentMethod` interface + +4. **`src/app/booking/payment/telebirr/success/page.tsx`** + - Handles TELEBIRR success callback + +5. **`src/app/booking/payment/telebirr/failure/page.tsx`** + - Handles TELEBIRR failure callback + +6. **`src/app/booking/payment/waafi/success/page.tsx`** + - Handles WAAFI success callback + +7. **`src/app/booking/payment/waafi/failure/page.tsx`** + - Handles WAAFI failure callback + +## Testing Checklist + +- [ ] Payment methods load from API +- [ ] TELEBIRR appears in payment options +- [ ] WAAFI appears in payment options +- [ ] Selecting TELEBIRR calls `/payments/initiate` +- [ ] Selecting WAAFI calls `/payments/initiate` +- [ ] Console logs show correct request/response +- [ ] User redirects to payment gateway +- [ ] Success callback confirms booking +- [ ] Failure callback shows error +- [ ] User can retry after failure + +## Notes + +- Only TELEBIRR and WAAFI use `/payments/initiate` endpoint +- Other payment methods use `/payments/intent` endpoint +- Payment store supports `REQUIRES_ACTION` status +- All callback query parameters are logged for debugging +- Both payment methods use same response structure diff --git a/apps/edr-passenger-web/portal/TELEBIRR_PAYMENT_FLOW.md b/apps/edr-passenger-web/portal/TELEBIRR_PAYMENT_FLOW.md new file mode 100644 index 000000000..24c62e2d2 --- /dev/null +++ b/apps/edr-passenger-web/portal/TELEBIRR_PAYMENT_FLOW.md @@ -0,0 +1,148 @@ +# TELEBIRR Payment Integration Flow + +## Overview +Complete payment flow for TELEBIRR integration using the `/payments/initiate` endpoint. + +## Payment Flow + +### 1. Payment Method Selection +- User selects TELEBIRR from available payment methods +- Payment methods fetched from `/payments/methods` +- Extracts payment method ID for the request + +### 2. Payment Initiation +**Endpoint:** `POST /payments/initiate` + +**Request:** +```json +{ + "bookingId": "booking-uuid", + "method": "TELEBIRR", + "paymentMethodId": "payment-method-uuid", + "platform": "web" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "intentId": "66aa30e2-52a2-4ad0-9043-df6df4a6fa4a", + "status": "REQUIRES_ACTION", + "clientAction": { + "url": "https://sandbox.waafipay.net/v2/hpp/token/2B68686270593243495535774B317263683930574A413D3D", + "type": "REDIRECT" + }, + "merchantOrderId": "1781588440170af93c3b9" + }, + "timestamp": "2026-06-16T05:40:41.004Z" +} +``` + +### 3. User Redirect +- App stores `intentId` in payment store +- Updates payment status to `REQUIRES_ACTION` +- Redirects user to `clientAction.url` +- User completes payment on WaafiPay gateway + +### 4. Callback Handling + +#### Success Callback +**URL:** `/booking/payment/telebirr/success` + +**Query Parameters:** +- `trxRef` or `outTradeNo` - Transaction reference +- `resultCode` or `code` - Result code +- `resultMsg` or `message` - Result message +- `msisdn` - Phone number (optional) +- `bookingId` - Booking UUID + +**Actions:** +1. Logs all query parameters +2. Calls `PATCH /bookings/{bookingId}/confirm` with: + ```json + { + "paymentReference": "trxRef", + "paymentMethod": "TELEBIRR" + } + ``` +3. Updates payment status to `SUCCEEDED` +4. Redirects to `/booking/confirmation` + +#### Failure Callback +**URL:** `/booking/payment/telebirr/failure` + +**Query Parameters:** +- `trxRef` or `outTradeNo` - Transaction reference +- `resultCode` or `code` - Error code +- `resultMsg` or `message` - Error message + +**Actions:** +1. Logs all query parameters +2. Updates payment status to `FAILED` +3. Shows error message to user +4. Provides options to retry or go back + +## Console Logs + +When TELEBIRR payment is initiated, check browser console for: + +``` +=== TELEBIRR PAYMENT INITIATION === +Request payload: { + bookingId: "...", + method: "TELEBIRR", + paymentMethodId: "...", + platform: "web" +} +=== TELEBIRR PAYMENT RESPONSE === +Full response: {...} +Intent ID: "66aa30e2-52a2-4ad0-9043-df6df4a6fa4a" +Status: "REQUIRES_ACTION" +Client Action: {url: "...", type: "REDIRECT"} +Redirect URL: "https://sandbox.waafipay.net/v2/hpp/token/..." +Merchant Order ID: "1781588440170af93c3b9" +==================================== +=== REDIRECTING TO PAYMENT GATEWAY === +Intent ID: 66aa30e2-52a2-4ad0-9043-df6df4a6fa4a +Status: REQUIRES_ACTION +Merchant Order ID: 1781588440170af93c3b9 +Redirect URL: https://sandbox.waafipay.net/v2/hpp/token/... +======================================= +``` + +## Files Modified + +1. **`src/app/booking/payment/page.tsx`** + - Added TELEBIRR-specific payment initiation + - Handles redirect response + - Logs all payment data + +2. **`src/lib/payment-store.ts`** + - Added `REQUIRES_ACTION` status + +3. **`src/types/index.ts`** + - Updated `PaymentMethod` interface + +4. **Existing Callback Pages:** + - `src/app/booking/payment/telebirr/success/page.tsx` + - `src/app/booking/payment/telebirr/failure/page.tsx` + +## Testing Checklist + +- [ ] Payment methods load from API +- [ ] TELEBIRR appears in payment options +- [ ] Selecting TELEBIRR calls `/payments/initiate` +- [ ] Console logs show correct request/response +- [ ] User redirects to WaafiPay gateway +- [ ] Success callback confirms booking +- [ ] Failure callback shows error +- [ ] User can retry after failure + +## Notes + +- Other payment methods still use `/payments/intent` endpoint +- Only TELEBIRR uses the new `/payments/initiate` flow +- Payment store now supports `REQUIRES_ACTION` status +- All callback query parameters are logged for debugging diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index d271e0042..0a6f2da3e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -3,9 +3,10 @@ import { useRouter } from "next/navigation"; import { useBookingStore } from "@/lib/booking-store"; import { usePaymentStore } from "@/lib/payment-store"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { useState, useEffect } from "react"; +import { PaymentMethod } from "@/types"; import { CreditCard, Smartphone, @@ -14,44 +15,11 @@ import { CheckCircle, } from "lucide-react"; -// Mock payment methods with Ethiopian providers -const paymentMethods = [ - { - id: "TELEBIRR", - name: "Telebirr", - icon: Smartphone, - description: "Pay with Telebirr mobile money", - color: "bg-orange-50 border-orange-200 hover:border-orange-400", - }, - { - id: "CBE_BIRR", - name: "CBE Birr", - icon: Smartphone, - description: "Pay with CBE Birr", - color: "bg-blue-50 border-blue-200 hover:border-blue-400", - }, - { - id: "EBIRR", - name: "eBirr", - icon: Smartphone, - description: "Pay with eBirr", - color: "bg-green-50 border-green-200 hover:border-green-400", - }, - { - id: "CARD", - name: "Card Payment", - icon: CreditCard, - description: "Pay with credit/debit card", - color: "bg-purple-50 border-purple-200 hover:border-purple-400", - }, - { - id: "WALLET", - name: "Wallet", - icon: Wallet, - description: "Pay from your wallet balance", - color: "bg-indigo-50 border-indigo-200 hover:border-indigo-400", - }, -]; +const getIconForMethod = (methodId: string) => { + if (methodId.includes('CARD')) return CreditCard; + if (methodId.includes('WALLET')) return Wallet; + return Smartphone; +}; export default function PaymentPage() { const router = useRouter(); @@ -61,6 +29,18 @@ export default function PaymentPage() { const [selectedMethod, setSelectedMethod] = useState(null); const [isProcessing, setIsProcessing] = useState(false); + const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery({ + queryKey: ['paymentMethods'], + queryFn: async () => { + const response = await apiClient.get('/payments/methods'); + return Array.isArray(response) ? response : []; + }, + }); + + console.log('Payment methods:', paymentMethods); + console.log('Loading methods:', loadingMethods); + console.log('Error:', error); + // Calculate total amount const baseFare = passengers.reduce( (sum) => sum + (selectedSchedule?.baseFareAdult || 0), @@ -70,7 +50,36 @@ export default function PaymentPage() { const paymentMutation = useMutation({ mutationFn: async (data: any) => { - // Try to call the real API, fallback to mock if it fails + // For TELEBIRR and WAAFI, use the initiate endpoint + if (data.method === 'TELEBIRR' || data.method === 'WAAFI') { + console.log(`=== ${data.method} PAYMENT INITIATION ===`); + console.log('Request payload:', { + bookingId: data.bookingId, + method: data.method, + paymentMethodId: data.paymentMethodId, + platform: 'web' + }); + + const response = await apiClient.post('/payments/initiate', { + bookingId: data.bookingId, + method: data.method, + paymentMethodId: data.paymentMethodId, + platform: 'web' + }); + + console.log(`=== ${data.method} PAYMENT RESPONSE ===`); + console.log('Full response:', response); + console.log('Intent ID:', response?.intentId); + console.log('Status:', response?.status); + console.log('Client Action:', response?.clientAction); + console.log('Redirect URL:', response?.clientAction?.url); + console.log('Merchant Order ID:', response?.merchantOrderId); + console.log('===================================='); + + return response; + } + + // For other payment methods, try the regular payment intent API try { return await apiClient.post("/payments/intent", data); } catch (error) { @@ -86,23 +95,35 @@ export default function PaymentPage() { } }, onSuccess: async (data: any) => { - setPaymentIntent(data.paymentIntentId); + console.log('Payment success response:', data); + + // Handle TELEBIRR/WAAFI redirect response + if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') { + const redirectUrl = data.clientAction.url; + console.log(`=== REDIRECTING TO ${selectedMethod} PAYMENT GATEWAY ===`); + console.log('Intent ID:', data.intentId); + console.log('Status:', data.status); + console.log('Merchant Order ID:', data.merchantOrderId); + console.log('Redirect URL:', redirectUrl); + console.log('======================================='); + + // Store the intent ID for later verification + setPaymentIntent(data.intentId); + updateStatus("REQUIRES_ACTION"); + + // Redirect to payment gateway + window.location.href = redirectUrl; + return; + } + + setPaymentIntent(data.paymentIntentId || data.intentId); updateStatus("PROCESSING"); // Simulate payment processing await new Promise((resolve) => setTimeout(resolve, 2000)); - // Generate tickets after successful payment - try { - await generateTickets(); - updateStatus("SUCCEEDED"); - router.push("/booking/confirmation"); - } catch (error) { - console.error("Ticket generation failed:", error); - // Still proceed to confirmation even if ticket generation fails - updateStatus("SUCCEEDED"); - router.push("/booking/confirmation"); - } + updateStatus("SUCCEEDED"); + router.push("/booking/confirmation"); }, onError: (error: any) => { console.error("Payment failed:", error); @@ -116,20 +137,7 @@ export default function PaymentPage() { }, }); - const generateTickets = async () => { - // Try to generate tickets via API, fallback to mock - try { - await apiClient.post("/tickets/generate", { - bookingId, - pnr, - }); - } catch (error) { - console.log( - "Ticket API not available, tickets will be generated on confirmation page", - ); - // Mock ticket generation - tickets will be displayed on confirmation page - } - }; + const handlePayment = async () => { if (!selectedMethod || !bookingId) { @@ -139,9 +147,21 @@ export default function PaymentPage() { setIsProcessing(true); + // Find the selected payment method to get its ID + const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod); + + if (!selectedPaymentMethod) { + alert("Invalid payment method selected"); + setIsProcessing(false); + return; + } + + console.log('Selected payment method:', selectedPaymentMethod); + paymentMutation.mutate({ bookingId, method: selectedMethod, + paymentMethodId: selectedPaymentMethod.id, currency: selectedCurrency, amountMinor: totalAmount, }); @@ -197,7 +217,7 @@ export default function PaymentPage() { Payment successful!

- Generating your tickets... + Redirecting to confirmation...

@@ -271,51 +291,70 @@ export default function PaymentPage() {

Select payment method

-
- {paymentMethods.map((method) => { - const Icon = method.icon; - const isSelected = selectedMethod === method.id; - return ( - - ); - })} -
+
+

+ {method.displayName} +

+

+ {method.region} · {method.currency} +

+
+ {isSelected && ( +
+ +
+ )} + + + ); + })} + + )} {/* Action Buttons */} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx new file mode 100644 index 000000000..0f21cc4ef --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useSearchParams, useRouter } from 'next/navigation'; +import { usePaymentStore } from '@/lib/payment-store'; +import { useEffect, Suspense } from 'react'; +import { XCircle, Loader2, RefreshCw } from 'lucide-react'; + +function TelebirrFailureContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { updateStatus } = usePaymentStore(); + + const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; + const resultCode = searchParams.get('resultCode') || searchParams.get('code') || ''; + const resultMsg = searchParams.get('resultMsg') || searchParams.get('message') || 'Payment was not completed.'; + + useEffect(() => { + console.log('[Telebirr Failure] Query params:', { + trxRef, resultCode, resultMsg, + all: Object.fromEntries(searchParams.entries()), + }); + updateStatus('FAILED'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ +

Payment Failed

+

{resultMsg}

+ {resultCode &&

Code: {resultCode}

} + {trxRef &&

Ref: {trxRef}

} +
+ + +
+
+
+ ); +} + +export default function TelebirrFailurePage() { + return ( + }> + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx new file mode 100644 index 000000000..b1948fa18 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useBookingStore } from '@/lib/booking-store'; +import { usePaymentStore } from '@/lib/payment-store'; +import { apiClient } from '@/lib/api-client'; +import { CheckCircle, Loader2 } from 'lucide-react'; +import { Suspense } from 'react'; + +function TelebirrSuccessContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { bookingId } = useBookingStore(); + const { updateStatus } = usePaymentStore(); + const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); + const [error, setError] = useState(''); + + // Common Telebirr callback query params + const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; + const resultCode = searchParams.get('resultCode') || searchParams.get('code') || ''; + const resultMsg = searchParams.get('resultMsg') || searchParams.get('message') || ''; + const msisdn = searchParams.get('msisdn') || ''; + const bookingIdQp = searchParams.get('bookingId') || bookingId || ''; + + useEffect(() => { + const confirm = async () => { + try { + console.log('[Telebirr Success] Query params:', { + trxRef, resultCode, resultMsg, msisdn, bookingId: bookingIdQp, + all: Object.fromEntries(searchParams.entries()), + }); + + if (bookingIdQp) { + await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, { + paymentReference: trxRef, + paymentMethod: 'TELEBIRR', + }); + } + + updateStatus('SUCCEEDED'); + setStatus('done'); + setTimeout(() => router.push('/booking/confirmation'), 1500); + } catch (err: any) { + console.error('[Telebirr Success] Confirm failed:', err); + updateStatus('SUCCEEDED'); // still navigate — payment succeeded even if confirm API fails + setStatus('done'); + setTimeout(() => router.push('/booking/confirmation'), 1500); + } + }; + + confirm(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ {status === 'processing' && ( + <> + +

Confirming payment…

+

Please wait while we confirm your Telebirr payment.

+ + )} + {status === 'done' && ( + <> + +

Payment Successful!

+

Your Telebirr payment was received.

+ {trxRef &&

Ref: {trxRef}

} +

Redirecting to your booking confirmation…

+ + )} + {status === 'error' && ( + <> +
+ ⚠️ +
+

Something went wrong

+

{error}

+ + + )} +
+
+ ); +} + +export default function TelebirrSuccessPage() { + return }>; +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx new file mode 100644 index 000000000..4f2781fc5 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useSearchParams, useRouter } from 'next/navigation'; +import { usePaymentStore } from '@/lib/payment-store'; +import { useEffect, Suspense } from 'react'; +import { XCircle, Loader2, RefreshCw } from 'lucide-react'; + +function WaafiFailureContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { updateStatus } = usePaymentStore(); + + const referenceId = searchParams.get('referenceId') || ''; + const responseCode = searchParams.get('responseCode') || ''; + const responseMsg = searchParams.get('responseMsg') || 'Payment was not completed.'; + const orderId = searchParams.get('orderId') || ''; + const transactionId = searchParams.get('transactionId') || ''; + const state = searchParams.get('state') || ''; + const txAmount = searchParams.get('txAmount') || ''; + const currency = searchParams.get('currency') || ''; + + useEffect(() => { + console.log('[Waafi Failure] Query params:', { + referenceId, + responseCode, + responseMsg, + orderId, + transactionId, + state, + txAmount, + currency, + all: Object.fromEntries(searchParams.entries()), + }); + updateStatus('FAILED'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ +

Payment Failed

+

{responseMsg}

+ {responseCode &&

Code: {responseCode}

} + {state &&

State: {state}

} + {(referenceId || transactionId) && ( +

Ref: {referenceId || transactionId}

+ )} +
+ + +
+
+
+ ); +} + +export default function WaafiFailurePage() { + return ( + }> + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx new file mode 100644 index 000000000..9a631d7fe --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useEffect, useState, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useBookingStore } from '@/lib/booking-store'; +import { usePaymentStore } from '@/lib/payment-store'; +import { apiClient } from '@/lib/api-client'; +import { CheckCircle, Loader2 } from 'lucide-react'; + +function WaafiSuccessContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { bookingId } = useBookingStore(); + const { updateStatus } = usePaymentStore(); + const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); + + // Waafi callback query params + const accountNo = searchParams.get('accountNo') || ''; + const cardNo = searchParams.get('cardNo') || ''; + const currency = searchParams.get('currency') || ''; + const orderId = searchParams.get('orderId') || ''; + const referenceId = searchParams.get('referenceId') || ''; + const responseCode = searchParams.get('responseCode') || ''; + const responseMsg = searchParams.get('responseMsg') || ''; + const state = searchParams.get('state') || ''; + const transactionId = searchParams.get('transactionId') || ''; + const txAmount = searchParams.get('txAmount') || ''; + const paymentMethod = searchParams.get('paymentMethod') || ''; + const timestamp = searchParams.get('timestamp') || ''; + const bookingIdQp = searchParams.get('bookingId') || bookingId || ''; + + useEffect(() => { + const confirm = async () => { + try { + console.log('[Waafi Success] Query params:', { + accountNo, + cardNo, + currency, + orderId, + referenceId, + responseCode, + responseMsg, + state, + transactionId, + txAmount, + paymentMethod, + timestamp, + bookingId: bookingIdQp, + all: Object.fromEntries(searchParams.entries()), + }); + + if (bookingIdQp) { + await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, { + paymentReference: referenceId || transactionId, + paymentMethod: 'WAAFI', + transactionDetails: { + transactionId, + orderId, + accountNo, + amount: txAmount, + currency, + state, + timestamp, + }, + }); + } + + updateStatus('SUCCEEDED'); + setStatus('done'); + setTimeout(() => router.push('/booking/confirmation'), 1500); + } catch (err: any) { + console.error('[Waafi Success] Confirm failed:', err); + updateStatus('SUCCEEDED'); + setStatus('done'); + setTimeout(() => router.push('/booking/confirmation'), 1500); + } + }; + + confirm(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ {status === 'processing' && ( + <> + +

Confirming payment…

+

Please wait while we confirm your Waafi payment.

+ + )} + {status === 'done' && ( + <> + +

Payment Successful!

+

Your Waafi payment was received.

+ {transactionId &&

Transaction ID: {transactionId}

} + {referenceId &&

Reference: {referenceId}

} + {txAmount && currency && ( +

Amount: {txAmount} {currency}

+ )} +

Redirecting to your booking confirmation…

+ + )} +
+
+ ); +} + +export default function WaafiSuccessPage() { + return ( + }> + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/lib/payment-store.ts b/apps/edr-passenger-web/portal/src/lib/payment-store.ts index 0557720c4..057639004 100644 --- a/apps/edr-passenger-web/portal/src/lib/payment-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/payment-store.ts @@ -2,11 +2,11 @@ import { create } from 'zustand'; interface PaymentState { paymentIntentId: string | null; - paymentStatus: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED' | null; + paymentStatus: 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED' | null; selectedCurrency: 'ETB' | 'DJF' | 'USD'; setPaymentIntent: (id: string) => void; - updateStatus: (status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED') => void; + updateStatus: (status: 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED') => void; setCurrency: (currency: 'ETB' | 'DJF' | 'USD') => void; clearPayment: () => void; } diff --git a/apps/edr-passenger-web/portal/src/types/index.ts b/apps/edr-passenger-web/portal/src/types/index.ts index 179db4f1e..532bef8d5 100644 --- a/apps/edr-passenger-web/portal/src/types/index.ts +++ b/apps/edr-passenger-web/portal/src/types/index.ts @@ -108,3 +108,17 @@ export interface FaydaVerificationResponse { nationality: string; }; } + +export interface PaymentMethod { + id: string; + type: string; + displayName: string; + region: string; + currency: string; + providerId: string | null; + isDefault: boolean; + enabled: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; +} From c267d4f6416560c4cc94276239e50822f2f2cfa7 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 16 Jun 2026 10:18:32 +0300 Subject: [PATCH 06/14] Enhance deployment workflow with change detection Added a job to detect changed services and conditionally deploy based on changes. Updated deployment strategy to handle service-specific environment files. --- .github/workflows/deploy.yml | 181 ++++++++++++++++++++++++++++++----- 1 file changed, 155 insertions(+), 26 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dd241242e..c7ad58650 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,5 +1,4 @@ name: Deploy Stacks - on: push: branches: @@ -9,52 +8,182 @@ on: workflow_dispatch: permissions: - contents: read + contents: read concurrency: group: deploy-${{ github.ref_name }} cancel-in-progress: true jobs: + detect-changes: + name: Detect changed services + runs-on: self-hosted + outputs: + matrix: ${{ steps.filter.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Determine changed services + id: filter + run: | + set -euo pipefail + ALL_SERVICES=( + "freight-api" + # "freight-portal" + # "freight-backoffice" + "passenger-api" + "passenger-portal" + "passenger-backoffice" + "payment-api" + ) + + # workflow_dispatch: deploy everything + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CHANGED=$(git diff --name-only HEAD~1 HEAD) + echo "=== Changed files ===" + echo "$CHANGED" + echo "=====================" + + SERVICES=() + + # ------------------------------------------------------- + # Tier 1: Non-deployable files — skip if ONLY these changed + # ------------------------------------------------------- + NON_DEPLOYABLE_PATTERN="^docs/\ +|^README\.md$\ +|^DEPLOYMENT\.md$\ +|^CLAUDE\.md$\ +|^checkpoint\.md$\ +|^orgstructure\.md$\ +|^ITMLS_DB_Design\.md$\ +|.*\.md$\ +|^\.eslintrc\ +|^\.prettierrc\ +|^\.editorconfig\ +|^\.gitignore\ +|^\.gitattributes\ +|^commitlint\.config\.js$" + + ALL_NON_DEPLOYABLE=true + while IFS= read -r file; do + if ! echo "$file" | grep -qE "$NON_DEPLOYABLE_PATTERN"; then + ALL_NON_DEPLOYABLE=false + break + fi + done <<< "$CHANGED" + + if [ "$ALL_NON_DEPLOYABLE" = "true" ]; then + echo "Only non-deployable files changed. Skipping deploy." + echo "matrix=[]" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # ------------------------------------------------------- + # Tier 2: Global files — deploy all services + # ------------------------------------------------------- + GLOBAL_PATTERN="^\.github/\ +|^docker-compose\.yaml$\ +|^turbo\.json$\ +|^tsconfig\.json$\ +|^tsconfig\.base\.json$\ +|^pnpm-workspace\.yaml$\ +|^pnpm-lock\.yaml$\ +|^package\.json$\ +|^\.env(\.[a-z]+)?$\ +|^packages/\ +|^infrastructure/\ +|^scripts/deploy/\ +|^wagon.*\.ts$\ +|^cargo.*\.ts$\ +|^container.*\.ts$\ +|^use-.*\.ts$\ +|^.*\.service\.ts$\ +|^.*\.entity\.ts$\ +|^.*-types\.ts$" + + if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then + echo "Global file(s) changed — deploying all services." + JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # ------------------------------------------------------- + # Tier 3: Per-service app paths (exact structure) + # ------------------------------------------------------- + + # Freight + echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") + # echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal") + # echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice") + + # Passenger + echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api") + echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") + echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") + + # Payment + echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") + + # Deduplicate while preserving consistent order + SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u)) + + if [ ${#SERVICES[@]} -eq 0 ]; then + echo "No deployable service changes detected." + echo "matrix=[]" >> "$GITHUB_OUTPUT" + else + echo "Services to deploy: ${SERVICES[*]}" + JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + fi + deploy: name: Deploy ${{ matrix.service }} + needs: detect-changes + if: ${{ needs.detect-changes.outputs.matrix != '[]' }} runs-on: self-hosted strategy: fail-fast: false matrix: - include: - - project: edr-freight - build_env_file: freight-web.build.env - service: freight-api - # - project: edr-freight - # build_env_file: freight-web.build.env - # service: freight-portal - # - project: edr-freight - # build_env_file: freight-web.build.env - # service: freight-backoffice - - project: edr-passenger - build_env_file: passenger-web.build.env - service: passenger-api - - project: edr-passenger - build_env_file: passenger-web.build.env - service: passenger-portal - - project: edr-passenger - build_env_file: passenger-web.build.env - service: passenger-backoffice - - project: edr-payment - build_env_file: payment-web.build.env - service: payment-api + service: ${{ fromJson(needs.detect-changes.outputs.matrix) }} env: - PROJECT: ${{ matrix.project }} BRANCH: ${{ github.ref_name }} DEPLOY_USER: tria - BUILD_ENV_FILE: ${{ matrix.build_env_file }} DOCKER_BUILDKIT: "1" COMPOSE_DOCKER_CLI_BUILD: "1" + steps: - name: Checkout uses: actions/checkout@v4 + - name: Resolve project and build env file + run: | + case "${{ matrix.service }}" in + freight-api|freight-portal|freight-backoffice) + echo "PROJECT=edr-freight" >> "$GITHUB_ENV" + echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV" + ;; + passenger-api|passenger-portal|passenger-backoffice) + echo "PROJECT=edr-passenger" >> "$GITHUB_ENV" + echo "BUILD_ENV_FILE=passenger-web.build.env" >> "$GITHUB_ENV" + ;; + payment-api) + echo "PROJECT=edr-payment" >> "$GITHUB_ENV" + echo "BUILD_ENV_FILE=payment-web.build.env" >> "$GITHUB_ENV" + ;; + *) + echo "Unknown service: ${{ matrix.service }}" && exit 1 + ;; + esac + - name: Sync environment from server run: | chmod +x scripts/deploy/*.sh From f52fc44ca1cc01c4d6a602046805bb86a270f5d4 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 16 Jun 2026 10:21:57 +0300 Subject: [PATCH 07/14] Update deploy.yml --- .github/workflows/deploy.yml | 36 ++---------------------------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c7ad58650..091226cee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -57,20 +57,7 @@ jobs: # ------------------------------------------------------- # Tier 1: Non-deployable files — skip if ONLY these changed # ------------------------------------------------------- - NON_DEPLOYABLE_PATTERN="^docs/\ -|^README\.md$\ -|^DEPLOYMENT\.md$\ -|^CLAUDE\.md$\ -|^checkpoint\.md$\ -|^orgstructure\.md$\ -|^ITMLS_DB_Design\.md$\ -|.*\.md$\ -|^\.eslintrc\ -|^\.prettierrc\ -|^\.editorconfig\ -|^\.gitignore\ -|^\.gitattributes\ -|^commitlint\.config\.js$" + NON_DEPLOYABLE_PATTERN="^docs/|^README\.md$|^DEPLOYMENT\.md$|^CLAUDE\.md$|^checkpoint\.md$|^orgstructure\.md$|^ITMLS_DB_Design\.md$|.*\.md$|^\.eslintrc|^\.prettierrc|^\.editorconfig|^\.gitignore|^\.gitattributes|^commitlint\.config\.js$" ALL_NON_DEPLOYABLE=true while IFS= read -r file; do @@ -89,26 +76,7 @@ jobs: # ------------------------------------------------------- # Tier 2: Global files — deploy all services # ------------------------------------------------------- - GLOBAL_PATTERN="^\.github/\ -|^docker-compose\.yaml$\ -|^turbo\.json$\ -|^tsconfig\.json$\ -|^tsconfig\.base\.json$\ -|^pnpm-workspace\.yaml$\ -|^pnpm-lock\.yaml$\ -|^package\.json$\ -|^\.env(\.[a-z]+)?$\ -|^packages/\ -|^infrastructure/\ -|^scripts/deploy/\ -|^wagon.*\.ts$\ -|^cargo.*\.ts$\ -|^container.*\.ts$\ -|^use-.*\.ts$\ -|^.*\.service\.ts$\ -|^.*\.entity\.ts$\ -|^.*-types\.ts$" - + GLOBAL_PATTERN="^\.github/^docker-compose\.yaml$|^turbo\.json$|^tsconfig\.json$|^tsconfig\.base\.json$|^pnpm-workspace\.yaml$|^pnpm-lock\.yaml$|^package\.json$|^\.env(\.[a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon.*\.ts$|^cargo.*\.ts$|^container.*\.ts$|^use-.*\.ts$|^.*\.service\.ts$|^.*\.entity\.ts$|^.*-types\.ts$" if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then echo "Global file(s) changed — deploying all services." JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) From d0689dfe04b46cc6489a08db563d80ce4a095673 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 16 Jun 2026 10:23:51 +0300 Subject: [PATCH 08/14] Update deploy.yml --- .github/workflows/deploy.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 091226cee..79d5ec843 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -76,8 +76,7 @@ jobs: # ------------------------------------------------------- # Tier 2: Global files — deploy all services # ------------------------------------------------------- - GLOBAL_PATTERN="^\.github/^docker-compose\.yaml$|^turbo\.json$|^tsconfig\.json$|^tsconfig\.base\.json$|^pnpm-workspace\.yaml$|^pnpm-lock\.yaml$|^package\.json$|^\.env(\.[a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon.*\.ts$|^cargo.*\.ts$|^container.*\.ts$|^use-.*\.ts$|^.*\.service\.ts$|^.*\.entity\.ts$|^.*-types\.ts$" - if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then + GLOBAL_PATTERN="^[.]github/|^docker-compose\.yaml$|^turbo\.json$|^tsconfig\.json$|^tsconfig\.base\.json$|^pnpm-workspace\.yaml$|^pnpm-lock\.yaml$|^package\.json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon.*\.ts$|^cargo.*\.ts$|^container.*\.ts$|^use-.*\.ts$|^.*\.service\.ts$|^.*\.entity\.ts$|^.*-types\.ts$" if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then echo "Global file(s) changed — deploying all services." JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" From ae967b324c2d92eabe1cc8621038269483d24731 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 16 Jun 2026 10:26:01 +0300 Subject: [PATCH 09/14] Update deploy.yml --- .github/workflows/deploy.yml | 127 ++++++++++++++--------------------- 1 file changed, 52 insertions(+), 75 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 79d5ec843..c05e88f58 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,91 +27,68 @@ jobs: fetch-depth: 2 - name: Determine changed services - id: filter - run: | - set -euo pipefail - ALL_SERVICES=( - "freight-api" - # "freight-portal" - # "freight-backoffice" - "passenger-api" - "passenger-portal" - "passenger-backoffice" - "payment-api" - ) + id: filter + run: | + set -euo pipefail + ALL_SERVICES=( + "freight-api" + "passenger-api" + "passenger-portal" + "passenger-backoffice" + "payment-api" + ) - # workflow_dispatch: deploy everything - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) - echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" - exit 0 - fi + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + exit 0 + fi - CHANGED=$(git diff --name-only HEAD~1 HEAD) - echo "=== Changed files ===" - echo "$CHANGED" - echo "=====================" + CHANGED=$(git diff --name-only HEAD~1 HEAD) + echo "=== Changed files ===" + echo "$CHANGED" + echo "=====================" - SERVICES=() + SERVICES=() - # ------------------------------------------------------- - # Tier 1: Non-deployable files — skip if ONLY these changed - # ------------------------------------------------------- - NON_DEPLOYABLE_PATTERN="^docs/|^README\.md$|^DEPLOYMENT\.md$|^CLAUDE\.md$|^checkpoint\.md$|^orgstructure\.md$|^ITMLS_DB_Design\.md$|.*\.md$|^\.eslintrc|^\.prettierrc|^\.editorconfig|^\.gitignore|^\.gitattributes|^commitlint\.config\.js$" + NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" - ALL_NON_DEPLOYABLE=true - while IFS= read -r file; do - if ! echo "$file" | grep -qE "$NON_DEPLOYABLE_PATTERN"; then - ALL_NON_DEPLOYABLE=false - break - fi - done <<< "$CHANGED" + GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon.*[.]ts$|^cargo.*[.]ts$|^container.*[.]ts$|^use-.*[.]ts$|^.*[.]service[.]ts$|^.*[.]entity[.]ts$|^.*-types[.]ts$" - if [ "$ALL_NON_DEPLOYABLE" = "true" ]; then - echo "Only non-deployable files changed. Skipping deploy." - echo "matrix=[]" >> "$GITHUB_OUTPUT" - exit 0 - fi + # Tier 1: skip if only non-deployable files changed + DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) + if [ -z "$DEPLOYABLE" ]; then + echo "Only non-deployable files changed. Skipping deploy." + echo "matrix=[]" >> "$GITHUB_OUTPUT" + exit 0 + fi - # ------------------------------------------------------- - # Tier 2: Global files — deploy all services - # ------------------------------------------------------- - GLOBAL_PATTERN="^[.]github/|^docker-compose\.yaml$|^turbo\.json$|^tsconfig\.json$|^tsconfig\.base\.json$|^pnpm-workspace\.yaml$|^pnpm-lock\.yaml$|^package\.json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon.*\.ts$|^cargo.*\.ts$|^container.*\.ts$|^use-.*\.ts$|^.*\.service\.ts$|^.*\.entity\.ts$|^.*-types\.ts$" if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then - echo "Global file(s) changed — deploying all services." - JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) - echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" - exit 0 - fi + # Tier 2: deploy all if any global file changed + if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then + echo "Global file(s) changed — deploying all services." + JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + exit 0 + fi - # ------------------------------------------------------- - # Tier 3: Per-service app paths (exact structure) - # ------------------------------------------------------- + # Tier 3: per-service paths + echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") + echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api") + echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") + echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") + echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") - # Freight - echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") - # echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal") - # echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice") - - # Passenger - echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api") - echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") - echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") - - # Payment - echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") - - # Deduplicate while preserving consistent order - SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u)) - - if [ ${#SERVICES[@]} -eq 0 ]; then - echo "No deployable service changes detected." - echo "matrix=[]" >> "$GITHUB_OUTPUT" - else - echo "Services to deploy: ${SERVICES[*]}" - JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -sc .) - echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" - fi + SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u)) + if [ ${#SERVICES[@]} -eq 0 ]; then + echo "No deployable service changes detected." + echo "matrix=[]" >> "$GITHUB_OUTPUT" + else + echo "Services to deploy: ${SERVICES[*]}" + JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + fi + deploy: name: Deploy ${{ matrix.service }} needs: detect-changes From 3fef8106665b6b62e82942e777e43bf39e002b4a Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 16 Jun 2026 10:27:24 +0300 Subject: [PATCH 10/14] Update deploy.yml --- .github/workflows/deploy.yml | 102 +++++++++++++++++------------------ 1 file changed, 50 insertions(+), 52 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c05e88f58..13c1f18fb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,68 +27,66 @@ jobs: fetch-depth: 2 - name: Determine changed services - id: filter - run: | - set -euo pipefail - ALL_SERVICES=( - "freight-api" - "passenger-api" - "passenger-portal" - "passenger-backoffice" - "payment-api" - ) + id: filter + run: | + set -euo pipefail - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) - echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" - exit 0 - fi + ALL_SERVICES=( + "freight-api" + "passenger-api" + "passenger-portal" + "passenger-backoffice" + "payment-api" + ) - CHANGED=$(git diff --name-only HEAD~1 HEAD) - echo "=== Changed files ===" - echo "$CHANGED" - echo "=====================" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + exit 0 + fi - SERVICES=() + CHANGED=$(git diff --name-only HEAD~1 HEAD) + echo "=== Changed files ===" + echo "$CHANGED" + echo "=====================" - NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" + SERVICES=() - GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon.*[.]ts$|^cargo.*[.]ts$|^container.*[.]ts$|^use-.*[.]ts$|^.*[.]service[.]ts$|^.*[.]entity[.]ts$|^.*-types[.]ts$" + NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" - # Tier 1: skip if only non-deployable files changed - DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) - if [ -z "$DEPLOYABLE" ]; then - echo "Only non-deployable files changed. Skipping deploy." - echo "matrix=[]" >> "$GITHUB_OUTPUT" - exit 0 - fi + GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon.*[.]ts$|^cargo.*[.]ts$|^container.*[.]ts$|^use-.*[.]ts$|^.*[.]service[.]ts$|^.*[.]entity[.]ts$|^.*-types[.]ts$" - # Tier 2: deploy all if any global file changed - if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then - echo "Global file(s) changed — deploying all services." - JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) - echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" - exit 0 - fi + DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) + if [ -z "$DEPLOYABLE" ]; then + echo "Only non-deployable files changed. Skipping deploy." + echo "matrix=[]" >> "$GITHUB_OUTPUT" + exit 0 + fi - # Tier 3: per-service paths - echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") - echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api") - echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") - echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") - echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") + if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then + echo "Global file(s) changed — deploying all services." + JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + exit 0 + fi - SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u)) + echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") + echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api") + echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") + echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") + echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") + + SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u)) + + if [ ${#SERVICES[@]} -eq 0 ]; then + echo "No deployable service changes detected." + echo "matrix=[]" >> "$GITHUB_OUTPUT" + else + echo "Services to deploy: ${SERVICES[*]}" + JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + fi - if [ ${#SERVICES[@]} -eq 0 ]; then - echo "No deployable service changes detected." - echo "matrix=[]" >> "$GITHUB_OUTPUT" - else - echo "Services to deploy: ${SERVICES[*]}" - JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -sc .) - echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" - fi - deploy: name: Deploy ${{ matrix.service }} needs: detect-changes From e5877e53392c5f0d4b7949511cbac85ca3017963 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 16 Jun 2026 10:52:36 +0300 Subject: [PATCH 11/14] 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 12/14] 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 { From 38175cbdb7c8654437c677fcaf3f2f65a0a1de71 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 16 Jun 2026 12:07:38 +0300 Subject: [PATCH 13/14] Update seed.ts --- apps/edr-passenger-api/prisma/seed.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 17283e15b..79933843b 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -422,6 +422,7 @@ async function seedPaymentMethods() { { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' }, { type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' }, { type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' }, + { type: 'WAAFI', displayName: 'Waffi', region: 'DJIBOUTI' }, { type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' }, { type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' }, ]; From ed49470249826e6236a5d15987a997d7f95e4c35 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 16 Jun 2026 12:14:10 +0300 Subject: [PATCH 14/14] Update telebirr callback page query paramaters --- apps/edr-passenger-web/portal/PAYMENT_FLOW.md | 76 +++++++++++++++++-- .../portal/src/app/booking/payment/page.tsx | 31 -------- .../booking/payment/telebirr/failure/page.tsx | 8 +- .../booking/payment/telebirr/success/page.tsx | 22 ++---- .../booking/payment/waafi/failure/page.tsx | 14 ---- .../booking/payment/waafi/success/page.tsx | 24 ------ 6 files changed, 80 insertions(+), 95 deletions(-) diff --git a/apps/edr-passenger-web/portal/PAYMENT_FLOW.md b/apps/edr-passenger-web/portal/PAYMENT_FLOW.md index 767ce2682..04e9de18a 100644 --- a/apps/edr-passenger-web/portal/PAYMENT_FLOW.md +++ b/apps/edr-passenger-web/portal/PAYMENT_FLOW.md @@ -51,6 +51,41 @@ Complete payment flow for TELEBIRR and WAAFI integration using the `/payments/in #### TELEBIRR Success Callback **URL:** `/booking/payment/telebirr/success` +**Query Parameters:** +- `merchantOrderId` - Merchant order ID (primary reference) +- `trxRef` or `outTradeNo` - Transaction reference +- `resultCode` or `code` - Result code +- `resultMsg` or `message` - Result message +- `msisdn` - Phone number (optional) +- `bookingId` - Booking UUID + +**Actions:** +1. Logs all query parameters +2. Calls `PATCH /bookings/{bookingId}/confirm` with: + ```json + { + "paymentReference": "merchantOrderId or trxRef", + "paymentMethod": "TELEBIRR" + } + ``` +3. Updates payment status to `SUCCEEDED` +4. Redirects to `/booking/confirmation` + +#### TELEBIRR Failure Callback +**URL:** `/booking/payment/telebirr/failure` + +**Query Parameters:** +- `merchantOrderId` - Merchant order ID +- `trxRef` or `outTradeNo` - Transaction reference +- `resultCode` or `code` - Error code +- `resultMsg` or `message` - Error message + +**Actions:** +1. Logs all query parameters +2. Updates payment status to `FAILED` +3. Shows error message to user +4. Provides options to retry or go back + #### WAAFI Success Callback **URL:** `/booking/payment/waafi/success` @@ -106,10 +141,24 @@ Complete payment flow for TELEBIRR and WAAFI integration using the `/payments/in 3. Updates payment status to `SUCCEEDED` 4. Redirects to `/booking/confirmation` -#### TELEBIRR Failure Callback -**URL:** `/booking/payment/telebirr/failure` +#### WAAFI Failure Callback +**URL:** `/booking/payment/waafi/failure` +**Query Parameters:** +- `referenceId` - Reference ID +- `responseCode` - Error code +- `responseMsg` - Error message +- `orderId` - Order ID +- `transactionId` - Transaction ID +- `state` - Transaction state +- `txAmount` - Transaction amount +- `currency` - Currency code +**Actions:** +1. Logs all query parameters +2. Updates payment status to `FAILED` +3. Shows error message to user +4. Provides options to retry or go back ## Console Logs @@ -139,6 +188,20 @@ Redirect URL: https://sandbox.waafipay.net/v2/hpp/token/... ======================================= ``` +## Callback URLs to Share + +### TELEBIRR Callback URLs: +- **Success:** `http://localhost:5174/booking/payment/telebirr/success` (dev) +- **Failure:** `http://localhost:5174/booking/payment/telebirr/failure` (dev) +- **Success:** `https://your-domain.com/booking/payment/telebirr/success` (prod) +- **Failure:** `https://your-domain.com/booking/payment/telebirr/failure` (prod) + +### WAAFI Callback URLs: +- **Success:** `http://localhost:5174/booking/payment/waafi/success` (dev) +- **Failure:** `http://localhost:5174/booking/payment/waafi/failure` (dev) +- **Success:** `https://your-domain.com/booking/payment/waafi/success` (prod) +- **Failure:** `https://your-domain.com/booking/payment/waafi/failure` (prod) + ## Files Modified 1. **`src/app/booking/payment/page.tsx`** @@ -153,13 +216,13 @@ Redirect URL: https://sandbox.waafipay.net/v2/hpp/token/... - Updated `PaymentMethod` interface 4. **`src/app/booking/payment/telebirr/success/page.tsx`** - - Handles TELEBIRR success callback + - Handles TELEBIRR success callback with merchantOrderId 5. **`src/app/booking/payment/telebirr/failure/page.tsx`** - - Handles TELEBIRR failure callback + - Handles TELEBIRR failure callback with merchantOrderId 6. **`src/app/booking/payment/waafi/success/page.tsx`** - - Handles WAAFI success callback + - Handles WAAFI success callback with full transaction details 7. **`src/app/booking/payment/waafi/failure/page.tsx`** - Handles WAAFI failure callback @@ -183,4 +246,5 @@ Redirect URL: https://sandbox.waafipay.net/v2/hpp/token/... - Other payment methods use `/payments/intent` endpoint - Payment store supports `REQUIRES_ACTION` status - All callback query parameters are logged for debugging -- Both payment methods use same response structure +- TELEBIRR uses `merchantOrderId` as primary reference +- WAAFI uses `referenceId` or `transactionId` as primary reference diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 0a6f2da3e..2efe9b5d4 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -37,10 +37,6 @@ export default function PaymentPage() { }, }); - console.log('Payment methods:', paymentMethods); - console.log('Loading methods:', loadingMethods); - console.log('Error:', error); - // Calculate total amount const baseFare = passengers.reduce( (sum) => sum + (selectedSchedule?.baseFareAdult || 0), @@ -52,14 +48,6 @@ export default function PaymentPage() { mutationFn: async (data: any) => { // For TELEBIRR and WAAFI, use the initiate endpoint if (data.method === 'TELEBIRR' || data.method === 'WAAFI') { - console.log(`=== ${data.method} PAYMENT INITIATION ===`); - console.log('Request payload:', { - bookingId: data.bookingId, - method: data.method, - paymentMethodId: data.paymentMethodId, - platform: 'web' - }); - const response = await apiClient.post('/payments/initiate', { bookingId: data.bookingId, method: data.method, @@ -67,15 +55,6 @@ export default function PaymentPage() { platform: 'web' }); - console.log(`=== ${data.method} PAYMENT RESPONSE ===`); - console.log('Full response:', response); - console.log('Intent ID:', response?.intentId); - console.log('Status:', response?.status); - console.log('Client Action:', response?.clientAction); - console.log('Redirect URL:', response?.clientAction?.url); - console.log('Merchant Order ID:', response?.merchantOrderId); - console.log('===================================='); - return response; } @@ -95,17 +74,9 @@ export default function PaymentPage() { } }, onSuccess: async (data: any) => { - console.log('Payment success response:', data); - // Handle TELEBIRR/WAAFI redirect response if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') { const redirectUrl = data.clientAction.url; - console.log(`=== REDIRECTING TO ${selectedMethod} PAYMENT GATEWAY ===`); - console.log('Intent ID:', data.intentId); - console.log('Status:', data.status); - console.log('Merchant Order ID:', data.merchantOrderId); - console.log('Redirect URL:', redirectUrl); - console.log('======================================='); // Store the intent ID for later verification setPaymentIntent(data.intentId); @@ -156,8 +127,6 @@ export default function PaymentPage() { return; } - console.log('Selected payment method:', selectedPaymentMethod); - paymentMutation.mutate({ bookingId, method: selectedMethod, diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx index 0f21cc4ef..53da93781 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx @@ -10,15 +10,12 @@ function TelebirrFailureContent() { const searchParams = useSearchParams(); const { updateStatus } = usePaymentStore(); + const merchantOrderId = searchParams.get('merchantOrderId') || ''; const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; const resultCode = searchParams.get('resultCode') || searchParams.get('code') || ''; const resultMsg = searchParams.get('resultMsg') || searchParams.get('message') || 'Payment was not completed.'; useEffect(() => { - console.log('[Telebirr Failure] Query params:', { - trxRef, resultCode, resultMsg, - all: Object.fromEntries(searchParams.entries()), - }); updateStatus('FAILED'); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -30,7 +27,8 @@ function TelebirrFailureContent() {

Payment Failed

{resultMsg}

{resultCode &&

Code: {resultCode}

} - {trxRef &&

Ref: {trxRef}

} + {merchantOrderId &&

Order ID: {merchantOrderId}

} + {trxRef &&

Ref: {trxRef}

}

Something went wrong

-

{error}

+

Unable to confirm payment

diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx index 4f2781fc5..19e3832fe 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx @@ -13,24 +13,10 @@ function WaafiFailureContent() { const referenceId = searchParams.get('referenceId') || ''; const responseCode = searchParams.get('responseCode') || ''; const responseMsg = searchParams.get('responseMsg') || 'Payment was not completed.'; - const orderId = searchParams.get('orderId') || ''; const transactionId = searchParams.get('transactionId') || ''; const state = searchParams.get('state') || ''; - const txAmount = searchParams.get('txAmount') || ''; - const currency = searchParams.get('currency') || ''; useEffect(() => { - console.log('[Waafi Failure] Query params:', { - referenceId, - responseCode, - responseMsg, - orderId, - transactionId, - state, - txAmount, - currency, - all: Object.fromEntries(searchParams.entries()), - }); updateStatus('FAILED'); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx index 9a631d7fe..073cd610b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx @@ -16,46 +16,23 @@ function WaafiSuccessContent() { // Waafi callback query params const accountNo = searchParams.get('accountNo') || ''; - const cardNo = searchParams.get('cardNo') || ''; const currency = searchParams.get('currency') || ''; - const orderId = searchParams.get('orderId') || ''; const referenceId = searchParams.get('referenceId') || ''; - const responseCode = searchParams.get('responseCode') || ''; - const responseMsg = searchParams.get('responseMsg') || ''; const state = searchParams.get('state') || ''; const transactionId = searchParams.get('transactionId') || ''; const txAmount = searchParams.get('txAmount') || ''; - const paymentMethod = searchParams.get('paymentMethod') || ''; const timestamp = searchParams.get('timestamp') || ''; const bookingIdQp = searchParams.get('bookingId') || bookingId || ''; useEffect(() => { const confirm = async () => { try { - console.log('[Waafi Success] Query params:', { - accountNo, - cardNo, - currency, - orderId, - referenceId, - responseCode, - responseMsg, - state, - transactionId, - txAmount, - paymentMethod, - timestamp, - bookingId: bookingIdQp, - all: Object.fromEntries(searchParams.entries()), - }); - if (bookingIdQp) { await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, { paymentReference: referenceId || transactionId, paymentMethod: 'WAAFI', transactionDetails: { transactionId, - orderId, accountNo, amount: txAmount, currency, @@ -69,7 +46,6 @@ function WaafiSuccessContent() { setStatus('done'); setTimeout(() => router.push('/booking/confirmation'), 1500); } catch (err: any) { - console.error('[Waafi Success] Confirm failed:', err); updateStatus('SUCCEEDED'); setStatus('done'); setTimeout(() => router.push('/booking/confirmation'), 1500);