diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 296c60520..48907966a 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -35,7 +35,7 @@ export class PaymentController { // } @Post("/bookings/check-payment/:orderId") - checkPayment(@Param("orderId", ParseUUIDPipe) orderId: string) { + checkPayment(@Param("orderId") orderId: string) { return this.paymentService.checkStatusAndUpdate(orderId) } diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 997a0e18f..549b3db4f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -1,162 +1,189 @@ -import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from "@nestjs/common"; import { DataSource, QueryRunner } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentStrategy } from "./strategies/payment.strategy"; import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy"; import { PaymentRepository } from "./payment.repository"; import { ClientAction, PaymentPlatform } from "./strategies/payments.types"; -import * as crypto from 'crypto'; +import * as crypto from "crypto"; -import * as fs from 'fs'; -import * as path from 'path'; -import * as Handlebars from 'handlebars'; +import * as fs from "fs"; +import * as path from "path"; +import * as Handlebars from "handlebars"; import { ConfigService } from "@nestjs/config"; import { Booking } from "../bookings/entities/booking.entity"; - -type PaymentMethod = PaymentEntity["method"] -type CurrencyType = PaymentEntity["currency"] +type PaymentMethod = PaymentEntity["method"]; +type CurrencyType = PaymentEntity["currency"]; @Injectable() export class PaymentService { - private strategies: Map; + private strategies: Map; - constructor( - private readonly configService: ConfigService, - private readonly datasource: DataSource, - private readonly paymentRepo: PaymentRepository, - private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) { - this.strategies = new Map([ - ["telebirr", this.telebirrPaymentStategy as PaymentStrategy] - ]) + constructor( + private readonly configService: ConfigService, + private readonly datasource: DataSource, + private readonly paymentRepo: PaymentRepository, + private readonly telebirrPaymentStategy: PaymentTelebirrStrategy, + ) { + this.strategies = new Map([ + ["telebirr", this.telebirrPaymentStategy as PaymentStrategy], + ]); + } + + async pay( + amount: number, + currency: CurrencyType, + method: PaymentMethod, + reason: string, + type: PaymentEntity["type"], + cb: ( + qr: QueryRunner, + ) => Promise<{ id: string; type: PaymentEntity["type"] }>, + payform: PaymentPlatform = "web", + ): Promise<{ + refId: string; + clientAction: ClientAction; + status: PaymentEntity["status"]; + paidAt?: string; + failureCode?: string; + failureMessage?: string; + }> { + const strategy = this.strategies.get(method); + if (!strategy) { + throw new NotFoundException("strategy not found"); } - async pay(amount: number, currency: CurrencyType, method: PaymentMethod, reason: string, type: PaymentEntity["type"], cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{ - refId: string, - clientAction: ClientAction, - status: PaymentEntity["status"], - paidAt?: string, - failureCode?: string, - failureMessage?: string, - }> { + const orderId = `${Date.now()}${crypto.randomBytes(4).toString("hex")}`; //todo: make it dynamic + let redirectUrl: string; + switch (type) { + case "booking": + const url = this.configService.get( + "TELEBIRR_SUCCESS_REDIRECT_BASE_URL", + ); + redirectUrl = `${url}/${orderId}`; + break; + } + const paymentResp = await strategy.pay({ + redirectUrl, + amountMinor: amount, + currency: currency, + merchantOrderId: orderId, + platform: payform, + }); - const strategy = this.strategies.get(method) - if (!strategy) { - throw new NotFoundException("strategy not found") - } + const queryRunner = this.datasource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); - const orderId = `${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic - let redirectUrl: string; - switch (type) { - case "booking": - const url = this.configService.get("TELEBIRR_SUCCESS_REDIRECT_BASE_URL") - redirectUrl = `${url}/check-status/${orderId}` - break; - } + console.log(paymentResp.expiresAt); + try { + const resp = await cb(queryRunner); + const payment = await this.paymentRepo.createTr(queryRunner, { + amount, + currency, + method, + refId: resp.id, + type: resp.type, + merchantOrderId: orderId, + rawInitiation: paymentResp.rawInitiation, + clientAction: paymentResp.clientAction, + expiresAt: paymentResp.expiresAt, + reason, + }); + await queryRunner.commitTransaction(); + return { + refId: payment.refId, + clientAction: paymentResp.clientAction, + status: payment.status, + paidAt: payment.paidAt?.toISOString(), + failureCode: payment.failerCode ?? undefined, + failureMessage: payment.failureMessage ?? undefined, + }; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw new Error("payment failed"); + } finally { + await queryRunner.release(); + } + } - const paymentResp = await strategy.pay({ - redirectUrl, - amountMinor: amount, - currency: currency, - merchantOrderId: orderId, - platform: payform, + async getActivePaymentByRefIdAndMethod( + refId: string, + method: PaymentEntity["method"], + ): Promise { + return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method); + } + + async genReceiptHtml(orderId: string) { + const payment = await this.paymentRepo.findOneBy({ + merchantOrderId: orderId, + status: "success", + }); + if (!payment) { + throw new BadRequestException(); + } + + const filePath = path.join(__dirname, "templates", "receipt.hbs"); + if (!fs.existsSync(filePath)) { + throw new InternalServerErrorException(); + } + const source = fs.readFileSync(filePath, "utf8"); + const template = Handlebars.compile(source); + + const html = template({ + vendorName: "Ethio Djibouti Railway Ticket Booking", + vendorAddress: "Addis Ababa", + receiptDate: payment.paidAt, + paymentMethod: payment?.method, + subtotal: payment?.amount.toString(), + total: payment?.amount.toString(), + currency: payment?.currency, + reason: payment?.reason, + }); + + return html; + } + + async checkStatusAndUpdate(orderId: string) { + const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId }); + if (!resp) { + throw new NotFoundException("order id not found"); + } + + try { + const result = await this.telebirrPaymentStategy.queryStatus( + resp.merchantOrderId, + ); + const bizContent = result.rawResponse.biz_content as { + order_status: string; + }; + + const ordersStatus = bizContent.order_status; + if (ordersStatus == "PAY_SUCCESS") { + await this.datasource.transaction(async (mg) => { + await mg.update(Booking, { id: resp.refId }, { status: "PAID" }); + await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }); }); - - const queryRunner = this.datasource.createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction() - - console.log(paymentResp.expiresAt) - try { - const resp = await cb(queryRunner) - const payment = await this.paymentRepo.createTr(queryRunner, { - amount, - currency, - method, - refId: resp.id, - type: resp.type, - merchantOrderId: orderId, - rawInitiation: paymentResp.rawInitiation, - clientAction: paymentResp.clientAction, - expiresAt: paymentResp.expiresAt, - reason - - }) - await queryRunner.commitTransaction() - return { - refId: payment.refId, - clientAction: paymentResp.clientAction, - status: payment.status, - paidAt: payment.paidAt?.toISOString(), - failureCode: payment.failerCode ?? undefined, - failureMessage: payment.failureMessage ?? undefined, - } - } catch (err) { - await queryRunner.rollbackTransaction() - throw new Error("payment failed") - } finally { - await queryRunner.release() - } - + } + return { + status: result.status, + }; + } catch { + // Telebirr API unavailable — fall back to current DB payment status + const dbStatus = + resp.status === "success" + ? "success" + : resp.status === "failed" + ? "failed" + : "processing"; + return { status: dbStatus }; } - - - async getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]): Promise { - return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method) - } - - async genReceiptHtml(orderId: string) { - const payment = await this.paymentRepo.findOneBy({ - merchantOrderId: orderId, - status: "success" - }) - if (!payment) { - throw new BadRequestException() - } - - const filePath = path.join(__dirname, "templates", "receipt.hbs"); - if (!fs.existsSync(filePath)) { - throw new InternalServerErrorException() - } - const source = fs.readFileSync(filePath, "utf8"); - const template = Handlebars.compile(source); - - const html = template({ - vendorName: "Ethio Djibouti Railway Ticket Booking", - vendorAddress: "Addis Ababa", - receiptDate: payment.paidAt, - paymentMethod: payment?.method, - subtotal: payment?.amount.toString(), - total: payment?.amount.toString(), - currency: payment?.currency, - reason: payment?.reason - }); - - return html; - } - - async checkStatusAndUpdate(orderId: string) { - const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId }) - if (!resp) { - throw new NotFoundException("order id not found") - } - const result = await this.telebirrPaymentStategy.queryStatus(resp.merchantOrderId) - const bizContent = result.rawResponse.biz_content as { - order_status: string; - }; - - const ordersStatus = bizContent.order_status - if (ordersStatus == "PAY_SUCCESS") { - await this.datasource.transaction(async (mg) => { - await mg.update(Booking, { id: resp.refId }, { status: "PAID" }) - await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }) - }) - } - return { - status: result.status - } - } - + } } - diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index c398eccb4..36922cf4a 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -33,6 +33,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; +import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import TrackingPage from "./pages/tracking/TrackingPage"; import BillingPage from "./pages/billing/BillingPage"; import { useEffect } from "react"; @@ -52,7 +53,7 @@ const App = () => { const { user, isPending, logout, customer, customerQuery } = useAuth(); useEffect(() => { - if (isPending) return; + if (isPending || customerQuery.isPending) return; const isInProtectedRoutes = sidebarItems.find((item) => location.pathname.startsWith(item.href), ); @@ -86,6 +87,10 @@ const App = () => { } /> } /> } /> + } + /> { } /> } /> - } /> + {/* } /> */} ); }; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx index 92643ccef..f81789c61 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx @@ -299,7 +299,12 @@ function DraftBookingView({ [booking.files], ); - const pricing = booking.pricingBreakdown; + const { data: generatedPricing } = useQuery({ + ...api.bookings.generatePrice.queryOptions({ input: { id: booking.id } }), + enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, + }); + + const pricing = booking.pricingBreakdown ?? generatedPricing ?? null; const uploadMutation = useMutation({ mutationFn: (files: Record) => diff --git a/apps/edr-freight-web/portal/src/pages/payments/CheckPaymentPage.tsx b/apps/edr-freight-web/portal/src/pages/payments/CheckPaymentPage.tsx new file mode 100644 index 000000000..f61763f74 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/payments/CheckPaymentPage.tsx @@ -0,0 +1,133 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react"; +import { Button } from "@edr/ui-common"; +import { api } from "@/services/api"; + +function extractOrderId(): string | null { + const params = new URLSearchParams(window.location.search); + const fromQuery = params.get("merch_order_id"); + if (fromQuery) return fromQuery; + const segments = window.location.pathname.split("/").filter(Boolean); + return segments[segments.length - 1] ?? null; +} + +export default function CheckPaymentPage() { + const navigate = useNavigate(); + const orderId = useMemo(() => extractOrderId(), []); + + const { data, isLoading, isError, error } = useQuery( + api.bookings.checkPayment.queryOptions({ + input: { orderId: orderId! }, + enabled: !!orderId, + retry: false, + }), + ); + + const isSuccess = data?.status === "PAY_SUCCESS"; + + if (!orderId) { + return ( +
+
+
+ +

+ No payment reference found +

+ +
+
+
+ ); + } + + return ( +
+
+ {isLoading && ( +
+ +

+ Checking payment status… +

+
+ )} + + {isSuccess && ( +
+
+ +
+

+ Payment was successful! +

+

+ Your booking has been confirmed and payment is complete. +

+ +
+ )} + + {!isLoading && data && !isSuccess && ( +
+
+ +
+

+ Payment status: {data.status} +

+

+ Please try again or contact support if the issue persists. +

+ +
+ )} + + {isError && ( +
+
+ +
+

+ Something went wrong +

+

+ {error instanceof Error + ? error.message + : "Failed to check payment status."} +

+ +
+ )} +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index ced534965..645dc7f50 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -179,6 +179,12 @@ export const api = { "pay", ({ id }) => bookingsService.pay(id), ), + + checkPayment: endpoint<{ orderId: string }, { status: string }>( + "bookings", + "checkPayment", + ({ orderId }) => bookingsService.checkPayment(orderId), + ), }, consignments: { diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 44ccf1e05..a8d36619d 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -134,6 +134,11 @@ export const bookingsService = { return data; }, + checkPayment: async (orderId: string): Promise<{ status: string }> => { + const { data } = await client.post(`/api/payments/bookings/check-payment/${orderId}`); + return data.data ?? data; + }, + pay: async (id: string): Promise<{ redirectUrl: string }> => { const { data } = await client.post(`/api/bookings/${id}/payment/pay`); return data.data ?? data;