mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 18:20:57 +00:00
Merge pull request #116 from Tria-plc/freight/feat/payment
freight/feat/payment
This commit is contained in:
@@ -17,7 +17,6 @@ import {
|
||||
PositionType,
|
||||
Position,
|
||||
Project,
|
||||
UnitSetting,
|
||||
GlobalUnitConfiguration,
|
||||
Unit,
|
||||
EmployeeSignature,
|
||||
@@ -64,7 +63,6 @@ const iamEntities = [
|
||||
PositionType,
|
||||
Position,
|
||||
Project,
|
||||
UnitSetting,
|
||||
GlobalUnitConfiguration,
|
||||
Unit,
|
||||
EmployeeSignature,
|
||||
@@ -98,10 +96,8 @@ const iamMigrationsGlob = join(
|
||||
);
|
||||
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
|
||||
|
||||
export default registerAs(
|
||||
"database",
|
||||
(): TypeOrmModuleOptions => {
|
||||
return {
|
||||
export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
return {
|
||||
type: "postgres",
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: parseInt(process.env.DB_PORT ?? "5433", 10),
|
||||
@@ -124,5 +120,4 @@ export default registerAs(
|
||||
synchronize: false,
|
||||
logging: process.env.NODE_ENV === "development",
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user