mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
124
.github/workflows/deploy.yml
vendored
124
.github/workflows/deploy.yml
vendored
@@ -1,5 +1,4 @@
|
||||
name: Deploy Stacks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -8,50 +7,125 @@ on:
|
||||
- staging
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
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"
|
||||
"passenger-api"
|
||||
"passenger-portal"
|
||||
"passenger-backoffice"
|
||||
"payment-api"
|
||||
)
|
||||
|
||||
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=()
|
||||
|
||||
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$"
|
||||
|
||||
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$"
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -496,6 +496,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' },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,64 +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 }),
|
||||
...(process.env.PAYMENT_RABBITMQ_URL
|
||||
? [
|
||||
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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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<InitiateResponseDto> {
|
||||
// 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 },
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -161,6 +161,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 },
|
||||
|
||||
250
apps/edr-passenger-web/portal/PAYMENT_FLOW.md
Normal file
250
apps/edr-passenger-web/portal/PAYMENT_FLOW.md
Normal 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
|
||||
¤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`
|
||||
|
||||
#### 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
|
||||
148
apps/edr-passenger-web/portal/TELEBIRR_PAYMENT_FLOW.md
Normal file
148
apps/edr-passenger-web/portal/TELEBIRR_PAYMENT_FLOW.md
Normal 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
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -13,22 +13,36 @@ export class DMoneyWebhookService {
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
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<string, unknown>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<boolean>("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<ProviderInitiationResult> {
|
||||
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<DMoneyInitiateResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/initiate`,
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildPreOrderRequest(input);
|
||||
const response = await this.postJson<DMoneyPreOrderResponse>(
|
||||
`${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<ProviderStatus> {
|
||||
const token = await this.getFabricToken();
|
||||
const timestamp = new Date().toISOString();
|
||||
const signature = this.signRequest({
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const response = await this.postJson<DMoneyQueryResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/query`,
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
|
||||
const response = await this.postJson<DMoneyQueryOrderResponse>(
|
||||
`${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<string, unknown>,
|
||||
mapped === ProviderPaymentStatus.FAILED && orderStatus
|
||||
? orderStatus
|
||||
: undefined,
|
||||
rawResponse: response as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): 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<string> {
|
||||
const response = await this.postJson<DMoneyAuthResponse>(
|
||||
/** 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<string, unknown>): 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<string> {
|
||||
const response = await this.postJson<DMoneyFabricTokenResponse>(
|
||||
`${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, unknown>): 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).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<string, unknown>,
|
||||
this.privateKey,
|
||||
);
|
||||
return { ...req, sign, sign_type: "SHA256WithRSA" };
|
||||
}
|
||||
|
||||
private buildQueryOrderRequest(
|
||||
merchantOrderId: string,
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown>,
|
||||
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<string, string> = {
|
||||
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<T>(
|
||||
url: string,
|
||||
body: unknown,
|
||||
token?: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"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<T>(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<string, unknown> {
|
||||
const { signature: _signature, ...rest } = body;
|
||||
private sanitize(body: DMoneyPreOrderRequest): Record<string, unknown> {
|
||||
const { sign: _sign, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>("dmoney.baseUrl") ?? "";
|
||||
}
|
||||
private get merchantId(): string {
|
||||
return this.config.get<string>("dmoney.merchantId") ?? "";
|
||||
private get webBaseUrl(): string {
|
||||
return this.config.get<string>("dmoney.webBaseUrl") ?? "";
|
||||
}
|
||||
private get fabricAppId(): string {
|
||||
return this.config.get<string>("dmoney.fabricAppId") ?? "";
|
||||
}
|
||||
private get appSecret(): string {
|
||||
return this.config.get<string>("dmoney.appSecret") ?? "";
|
||||
}
|
||||
private get secretKey(): string {
|
||||
return this.config.get<string>("dmoney.secretKey") ?? "";
|
||||
private get merchantAppId(): string {
|
||||
return this.config.get<string>("dmoney.merchantAppId") ?? "";
|
||||
}
|
||||
private get merchantCode(): string {
|
||||
return this.config.get<string>("dmoney.merchantCode") ?? "";
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>("dmoney.notifyUrl") ?? "";
|
||||
@@ -263,4 +337,19 @@ export class DMoneyProvider implements PaymentProvider {
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>("dmoney.returnUrl") ?? "";
|
||||
}
|
||||
private get timeoutExpress(): string {
|
||||
return this.config.get<string>("dmoney.timeoutExpress") ?? "120m";
|
||||
}
|
||||
private get language(): string {
|
||||
return this.config.get<string>("dmoney.language") ?? "en";
|
||||
}
|
||||
private get currency(): string {
|
||||
return this.config.get<string>("dmoney.currency") ?? "FDJ";
|
||||
}
|
||||
private get privateKey(): string {
|
||||
return this.config.get<string>("dmoney.privateKey") ?? "";
|
||||
}
|
||||
private get publicKey(): string {
|
||||
return this.config.get<string>("dmoney.publicKey") ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function buildCanonicalString(requestObject: Record<string, unknown>): 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<string, unknown>): st
|
||||
if (biz && typeof biz === 'object') {
|
||||
for (const key of Object.keys(biz as Record<string, unknown>)) {
|
||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
||||
fieldMap[key] = (biz as Record<string, unknown>)[key];
|
||||
const value = (biz as Record<string, unknown>)[key];
|
||||
if (value === undefined) continue;
|
||||
fieldMap[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
};
|
||||
@@ -195,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(),
|
||||
@@ -211,7 +214,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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user