mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fix(api): improve payment status API handling and frontend user experience
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PaymentMethod, PaymentStrategy>;
|
||||
private strategies: Map<PaymentMethod, 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]
|
||||
])
|
||||
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<string>(
|
||||
"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<string>("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<PaymentEntity | null> {
|
||||
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<PaymentEntity | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
<Route path="/otp" element={<VerificationOtpPage />} />
|
||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
<Route
|
||||
path="/booking/check-status/:orderId"
|
||||
element={<CheckPaymentPage />}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
element={
|
||||
@@ -117,7 +122,7 @@ const App = () => {
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
{/* <Route path="*" element={<Navigate to="/" replace />} /> */}
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string, File | File[] | null>) =>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<XCircle className="size-10 text-destructive" />
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
No payment reference found
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/bookings")}
|
||||
>
|
||||
Back to My Bookings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoaderCircle className="size-10 animate-spin text-primary" />
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
Checking payment status…
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-primary/10">
|
||||
<CheckCircle2 className="size-8 text-primary" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
Payment was successful!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your booking has been confirmed and payment is complete.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate("/bookings")}
|
||||
className="mt-2"
|
||||
>
|
||||
Go to My Bookings
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && data && !isSuccess && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XCircle className="size-8 text-destructive" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
Payment status: {data.status}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please try again or contact support if the issue persists.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/bookings")}
|
||||
className="mt-2"
|
||||
>
|
||||
Back to My Bookings
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XCircle className="size-8 text-destructive" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
Something went wrong
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Failed to check payment status."}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/bookings")}
|
||||
className="mt-2"
|
||||
>
|
||||
Back to My Bookings
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user