Merge pull request #116 from Tria-plc/freight/feat/payment

freight/feat/payment
This commit is contained in:
Nathnael Wondisha
2026-06-08 08:52:39 +03:00
committed by GitHub
20 changed files with 2608 additions and 772 deletions

View File

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

View File

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