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; +}