Merge pull request #169 from Tria-plc/alpha

Update telebirr payment method callback page
This commit is contained in:
Eyob T.
2026-06-16 12:23:35 +03:00
committed by GitHub
12 changed files with 855 additions and 150 deletions

View File

@@ -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<string>("rabbitmq.url") as string,
exchanges: [
{
name: PAYMENT_EVENTS_EXCHANGE,
type: "topic",
options: { durable: true },
},
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
],
queues: [
{
name: PASSENGER_QUEUE.dlq,
exchange: PAYMENT_EVENTS_DLX,
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER),
options: { durable: true },
},
],
prefetchCount: config.get<number>("rabbitmq.prefetch") ?? 10,
connectionInitOptions: { wait: false },
}),
}),
],
controllers: [PaymentsController, InternalPaymentsController],
providers: [
PaymentsService,
PaymentClientService,
PaymentEventsConsumer,
ServiceAuthGuard,
],
})

View File

@@ -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')

View File

@@ -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 },

View File

@@ -0,0 +1,250 @@
# 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`
**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`
**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
&currency=DJF
&orderId=1209631
&referenceId=17815888579838ddc23b3
&responseCode=0
&responseMsg=Approved+(sandbox+mode)
&state=APPROVED
&transactionId=1318559
&txAmount=367.50
&paymentMethod=MWALLET_ACCOUNT
&timestamp=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`
#### 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
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/...
=======================================
```
## 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`**
- 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 with merchantOrderId
5. **`src/app/booking/payment/telebirr/failure/page.tsx`**
- Handles TELEBIRR failure callback with merchantOrderId
6. **`src/app/booking/payment/waafi/success/page.tsx`**
- Handles WAAFI success callback with full transaction details
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
- TELEBIRR uses `merchantOrderId` as primary reference
- WAAFI uses `referenceId` or `transactionId` as primary reference

View File

@@ -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

View File

@@ -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,14 @@ export default function PaymentPage() {
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
queryKey: ['paymentMethods'],
queryFn: async () => {
const response = await apiClient.get<PaymentMethod[]>('/payments/methods');
return Array.isArray(response) ? response : [];
},
});
// Calculate total amount
const baseFare = passengers.reduce(
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
@@ -70,7 +46,19 @@ 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') {
const response = await apiClient.post('/payments/initiate', {
bookingId: data.bookingId,
method: data.method,
paymentMethodId: data.paymentMethodId,
platform: 'web'
});
return response;
}
// For other payment methods, try the regular payment intent API
try {
return await apiClient.post("/payments/intent", data);
} catch (error) {
@@ -86,23 +74,27 @@ export default function PaymentPage() {
}
},
onSuccess: async (data: any) => {
setPaymentIntent(data.paymentIntentId);
// Handle TELEBIRR/WAAFI redirect response
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
const redirectUrl = data.clientAction.url;
// 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 +108,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 +118,19 @@ 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;
}
paymentMutation.mutate({
bookingId,
method: selectedMethod,
paymentMethodId: selectedPaymentMethod.id,
currency: selectedCurrency,
amountMinor: totalAmount,
});
@@ -197,7 +186,7 @@ export default function PaymentPage() {
Payment successful!
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Generating your tickets...
Redirecting to confirmation...
</p>
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
</>
@@ -271,51 +260,70 @@ export default function PaymentPage() {
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
Select payment method
</h2>
<div className="space-y-3">
{paymentMethods.map((method) => {
const Icon = method.icon;
const isSelected = selectedMethod === method.id;
return (
<button
key={method.id}
onClick={() => setSelectedMethod(method.id)}
disabled={isProcessing}
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
isSelected
? "border-primary bg-primary/10 dark:bg-primary/20 shadow-md"
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary dark:hover:border-primary"
} ${isProcessing ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="flex items-center gap-3">
<div
className={`w-12 h-12 rounded-lg flex items-center justify-center ${
isSelected
? "bg-primary"
: "bg-gray-100 dark:bg-gray-700"
}`}
>
<Icon
className={`w-6 h-6 ${isSelected ? "text-white" : "text-primary"}`}
/>
</div>
<div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-gray-100">
{method.name}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{method.description}
</p>
</div>
{isSelected && (
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-white" />
{loadingMethods ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
<p className="ml-2 text-gray-600 dark:text-gray-400">Loading payment methods...</p>
</div>
) : error ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-200 text-sm">
Failed to load payment methods. Please refresh the page.
</p>
</div>
) : paymentMethods.length === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-yellow-800 dark:text-yellow-200 text-sm">
No payment methods available at the moment.
</p>
</div>
) : (
<div className="space-y-3">
{paymentMethods.map((method) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
<button
key={method.id}
onClick={() => setSelectedMethod(method.type)}
disabled={isProcessing || !method.enabled}
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
isSelected
? "border-primary bg-primary/10 dark:bg-primary/20 shadow-md"
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary dark:hover:border-primary"
} ${isProcessing || !method.enabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="flex items-center gap-3">
<div
className={`w-12 h-12 rounded-lg flex items-center justify-center ${
isSelected
? "bg-primary"
: "bg-gray-100 dark:bg-gray-700"
}`}
>
<Icon
className={`w-6 h-6 ${isSelected ? "text-white" : "text-primary"}`}
/>
</div>
)}
</div>
</button>
);
})}
</div>
<div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-gray-100">
{method.displayName}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{method.region} · {method.currency}
</p>
</div>
{isSelected && (
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-white" />
</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
{/* Action Buttons */}

View File

@@ -0,0 +1,54 @@
'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 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(() => {
updateStatus('FAILED');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{resultMsg}</p>
{resultCode && <p className="text-xs text-gray-400 mb-1">Code: {resultCode}</p>}
{merchantOrderId && <p className="text-xs text-gray-400 mb-1">Order ID: {merchantOrderId}</p>}
{trxRef && <p className="text-xs text-gray-400 mb-4">Ref: {trxRef}</p>}
<div className="flex flex-col gap-3 mt-4">
<button onClick={() => router.push('/booking/payment')}
className="btn-primary w-full flex items-center justify-center gap-2">
<RefreshCw className="w-4 h-4" />
Try Again
</button>
<button onClick={() => router.push('/booking/review')}
className="btn-secondary w-full">
Back to Review
</button>
</div>
</div>
</div>
);
}
export default function TelebirrFailurePage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><Loader2 className="w-10 h-10 animate-spin text-primary" /></div>}>
<TelebirrFailureContent />
</Suspense>
);
}

View File

@@ -0,0 +1,85 @@
'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');
// Telebirr callback query params
const merchantOrderId = searchParams.get('merchantOrderId') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
useEffect(() => {
const confirm = async () => {
try {
if (bookingIdQp) {
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
paymentReference: merchantOrderId || trxRef,
paymentMethod: 'TELEBIRR',
});
}
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
} catch (err: any) {
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
}
};
confirm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
{status === 'processing' && (
<>
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your Telebirr payment.</p>
</>
)}
{status === 'done' && (
<>
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Your Telebirr payment was received.</p>
{merchantOrderId && <p className="text-xs text-gray-400">Order ID: {merchantOrderId}</p>}
{trxRef && <p className="text-xs text-gray-400">Transaction Ref: {trxRef}</p>}
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation</p>
</>
)}
{status === 'error' && (
<>
<div className="w-14 h-14 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<span className="text-3xl"></span>
</div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Something went wrong</h1>
<p className="text-sm text-red-500 mb-4">Unable to confirm payment</p>
<button onClick={() => router.push('/booking/confirmation')}
className="btn-primary w-full">Go to confirmation</button>
</>
)}
</div>
</div>
);
}
export default function TelebirrSuccessPage() {
return <Suspense fallback={<div className="min-h-screen flex items-center justify-center"><Loader2 className="w-10 h-10 animate-spin text-primary" /></div>}><TelebirrSuccessContent /></Suspense>;
}

View File

@@ -0,0 +1,57 @@
'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 transactionId = searchParams.get('transactionId') || '';
const state = searchParams.get('state') || '';
useEffect(() => {
updateStatus('FAILED');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{responseMsg}</p>
{responseCode && <p className="text-xs text-gray-400 mb-1">Code: {responseCode}</p>}
{state && <p className="text-xs text-gray-400 mb-1">State: {state}</p>}
{(referenceId || transactionId) && (
<p className="text-xs text-gray-400 mb-4">Ref: {referenceId || transactionId}</p>
)}
<div className="flex flex-col gap-3 mt-4">
<button onClick={() => router.push('/booking/payment')}
className="btn-primary w-full flex items-center justify-center gap-2">
<RefreshCw className="w-4 h-4" />
Try Again
</button>
<button onClick={() => router.push('/booking/review')}
className="btn-secondary w-full">
Back to Review
</button>
</div>
</div>
</div>
);
}
export default function WaafiFailurePage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><Loader2 className="w-10 h-10 animate-spin text-primary" /></div>}>
<WaafiFailureContent />
</Suspense>
);
}

View File

@@ -0,0 +1,93 @@
'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 currency = searchParams.get('currency') || '';
const referenceId = searchParams.get('referenceId') || '';
const state = searchParams.get('state') || '';
const transactionId = searchParams.get('transactionId') || '';
const txAmount = searchParams.get('txAmount') || '';
const timestamp = searchParams.get('timestamp') || '';
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
useEffect(() => {
const confirm = async () => {
try {
if (bookingIdQp) {
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
paymentReference: referenceId || transactionId,
paymentMethod: 'WAAFI',
transactionDetails: {
transactionId,
accountNo,
amount: txAmount,
currency,
state,
timestamp,
},
});
}
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
} catch (err: any) {
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
}
};
confirm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
{status === 'processing' && (
<>
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your Waafi payment.</p>
</>
)}
{status === 'done' && (
<>
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Your Waafi payment was received.</p>
{transactionId && <p className="text-xs text-gray-400">Transaction ID: {transactionId}</p>}
{referenceId && <p className="text-xs text-gray-400">Reference: {referenceId}</p>}
{txAmount && currency && (
<p className="text-xs text-gray-400">Amount: {txAmount} {currency}</p>
)}
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation</p>
</>
)}
</div>
</div>
);
}
export default function WaafiSuccessPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><Loader2 className="w-10 h-10 animate-spin text-primary" /></div>}>
<WaafiSuccessContent />
</Suspense>
);
}

View File

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

View File

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