mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
merge conflict
This commit is contained in:
@@ -8,12 +8,13 @@ import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.
|
||||
|
||||
import appConfig from "./config/app.config";
|
||||
import databaseConfig from "./config/database.config";
|
||||
import telebirrConfig from "./config/telebirr.config";
|
||||
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { FilesModule } from "./modules/files/files.module";
|
||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
|
||||
//import { TrainsModule } from "./modules/trains/trains.module";
|
||||
// import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||
@@ -55,8 +56,9 @@ import { OverviewModule } from './modules/overview/overview.module';
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig],
|
||||
load: [appConfig, databaseConfig, telebirrConfig],
|
||||
}),
|
||||
// EventEmitterModule.forRoot(),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
|
||||
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal file
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("dmoney", () => ({
|
||||
baseUrl: process.env.DMONEY_BASE_URL ?? "",
|
||||
appId: process.env.DMONEY_APP_ID ?? "",
|
||||
appSecret: process.env.DMONEY_APP_SECRET ?? "",
|
||||
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
|
||||
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
|
||||
notifyUrl: process.env.DMONEY_NOTIFY_URL ?? ""
|
||||
}));
|
||||
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("telebirr", () => ({
|
||||
baseUrl: process.env.TELEBIRR_BASE_URL ?? "",
|
||||
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "",
|
||||
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "",
|
||||
appSecret: process.env.TELEBIRR_APP_SECRET ?? "",
|
||||
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "",
|
||||
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "",
|
||||
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "",
|
||||
returnUrl: process.env.TELEBIRR_RETURN_URL ?? "",
|
||||
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m",
|
||||
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "",
|
||||
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "",
|
||||
insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true",
|
||||
}));
|
||||
@@ -4,56 +4,44 @@ import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
import { PaymentService } from '../payment/payment.service';
|
||||
|
||||
import { PaymentStatus } from '../payment/entities/payment.entity';
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
||||
"action-required",
|
||||
"processing",
|
||||
"success",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { }
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly paymentService: PaymentService,
|
||||
) { }
|
||||
|
||||
async pay(
|
||||
bookingId: string,
|
||||
): Promise<{ redirectUrl: string }> {
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
|
||||
|
||||
// const receipt = this.buildMockReceipt(booking);
|
||||
|
||||
// const updated = await this.bookingsRepository.update(bookingId, {
|
||||
// status: 'PAID',
|
||||
// paymentStatus: 'PAID',
|
||||
// } as never);
|
||||
const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => {
|
||||
return new Promise((resp, _) => {
|
||||
resp({
|
||||
id: booking.id,
|
||||
type: "booking"
|
||||
})
|
||||
});
|
||||
})
|
||||
|
||||
return {
|
||||
redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : ""
|
||||
const existing = await this.paymentService.findBookingById(bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
if (existing.clientAction) {
|
||||
const action = existing.clientAction as { type?: string; url?: string };
|
||||
if (action.type === "REDIRECT" && action.url) {
|
||||
return { redirectUrl: action.url };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await this.paymentService.initBookingTelebirr(bookingId, "web");
|
||||
|
||||
return {
|
||||
redirectUrl:
|
||||
resp.redirectUrl ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
|
||||
// const timestamp = Date.now();
|
||||
// const isEtb = booking.paymentCurrency === 'ETB';
|
||||
// const prefix = isEtb ? 'TB' : 'CARD';
|
||||
// const provider = isEtb ? 'TELEBIRR' : 'CARD';
|
||||
|
||||
// return {
|
||||
// success: true,
|
||||
// provider,
|
||||
// providerRef: `${prefix}-${booking.reference}-${timestamp}`,
|
||||
// amount: booking.totalAmount,
|
||||
// currency: booking.paymentCurrency,
|
||||
// paidAt: new Date().toISOString(),
|
||||
// };
|
||||
// }
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findById(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class InitiateBookingPayment {
|
||||
@IsString()
|
||||
bookingId!: string;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn }
|
||||
type PaymentType = "booking"
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
|
||||
type Currency = "ETB" | "USD"
|
||||
type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
|
||||
@Entity({ schema: 'freight', name: 'payments' })
|
||||
export class PaymentEntity extends BaseEntity {
|
||||
|
||||
@@ -1,53 +1,31 @@
|
||||
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
|
||||
import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { Public } from "@edr/api-common";
|
||||
// import { randomUUID } from "crypto";
|
||||
import { Response } from "express"
|
||||
|
||||
@Public()
|
||||
@Controller("payments")
|
||||
export class PaymentController {
|
||||
constructor(private readonly paymentService: PaymentService,) { }
|
||||
constructor(private readonly paymentService: PaymentService,) { }
|
||||
|
||||
@Post("/initiate")
|
||||
initiate() {
|
||||
return this.paymentService.initBookingTelebirr("123", "web")
|
||||
}
|
||||
|
||||
// @Get("/receipts/:orderId/html")
|
||||
// async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
|
||||
// const filled = await this.paymentService.genReceiptHtml(orderId);
|
||||
// return res.send(filled)
|
||||
// }
|
||||
@Post("/bookings/check-payment/:orderId")
|
||||
checkPayment(@Param("orderId") orderId: string) {
|
||||
return this.paymentService.checkStatusAndUpdate(orderId)
|
||||
}
|
||||
|
||||
// @Post("/initiate/booking")
|
||||
// async initiatePayment() {
|
||||
|
||||
// //Only for testing..
|
||||
// const description = "Booking for contact"
|
||||
// const price = 2000
|
||||
// const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => {
|
||||
// return new Promise((resp, _) => {
|
||||
// resp({
|
||||
// id: randomUUID(),
|
||||
// type: "booking"
|
||||
// })
|
||||
// });
|
||||
// })
|
||||
|
||||
// return data
|
||||
// }
|
||||
|
||||
@Post("/bookings/check-payment/:orderId")
|
||||
checkPayment(@Param("orderId") orderId: string) {
|
||||
return this.paymentService.checkStatusAndUpdate(orderId)
|
||||
@Get("/bookings/telebirr/redirect/:orderId")
|
||||
async pay(@Param("orderId") orderId: string, @Res() res: Response) {
|
||||
const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr")
|
||||
if (!payment) {
|
||||
throw new NotFoundException('payment not found')
|
||||
}
|
||||
|
||||
|
||||
@Get("/telebirr/:refId")
|
||||
async pay(@Param("refId", ParseUUIDPipe) refId: string, @Res() res: Response) {
|
||||
const payment = await this.paymentService.getActivePaymentByRefIdAndMethod(refId, "telebirr")
|
||||
if (!payment) {
|
||||
throw new NotFoundException('payment not found')
|
||||
}
|
||||
|
||||
return res.send(`
|
||||
return res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -62,6 +40,5 @@ export class PaymentController {
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { PaymentController } from "./payment.controller";
|
||||
@@ -7,10 +6,11 @@ import { ConfigModule } from "@nestjs/config";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { WebhookController } from "./webhooks/webhook.controller";
|
||||
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
|
||||
import { TelebirrProvider } from "@edr/payment-providers";
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule, ConfigModule],
|
||||
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
|
||||
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
|
||||
controllers: [PaymentController, WebhookController],
|
||||
exports: [PaymentService]
|
||||
})
|
||||
|
||||
@@ -14,6 +14,13 @@ export class PaymentRepository {
|
||||
return qr.manager.save(payment)
|
||||
}
|
||||
|
||||
async create(data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt" | "reason">): Promise<PaymentEntity> {
|
||||
const payment = this.paymentRepo.create(data)
|
||||
return this.paymentRepo.save(payment)
|
||||
}
|
||||
|
||||
|
||||
|
||||
findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> {
|
||||
return this.paymentRepo.findOneBy(options);
|
||||
}
|
||||
@@ -36,4 +43,20 @@ export class PaymentRepository {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]) {
|
||||
return this.paymentRepo
|
||||
.createQueryBuilder('payment')
|
||||
.where('payment.method = :method', { method })
|
||||
.andWhere('payment.merchantOrderId = :orderId', { orderId })
|
||||
.andWhere('payment.status IN (:...statuses)', {
|
||||
statuses: ['action-required'],
|
||||
})
|
||||
.andWhere('payment.expiresAt > :now', { now: new Date() })
|
||||
.getOne();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource, QueryRunner } from "typeorm";
|
||||
import { DataSource } 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 fs from "fs";
|
||||
import * as path from "path";
|
||||
@@ -19,66 +15,54 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { SchedulingStatus } from "@edr/types";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
|
||||
type PaymentMethod = PaymentEntity["method"];
|
||||
type CurrencyType = PaymentEntity["currency"];
|
||||
import {
|
||||
ClientAction,
|
||||
createMerchantOrderId,
|
||||
ProviderPaymentStatus,
|
||||
TelebirrProvider,
|
||||
} from "@edr/payment-providers";
|
||||
import { ProviderInitiationInput } from "@edr/types"
|
||||
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
|
||||
|
||||
const DEFAULT_CURRENCY = "ETB";
|
||||
|
||||
@Injectable()
|
||||
export class PaymentService {
|
||||
private strategies: Map<PaymentMethod, PaymentStrategy>;
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
) { }
|
||||
|
||||
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 initBookingTelebirr(
|
||||
bookingId: string,
|
||||
platform: PaymentPlatformDto,
|
||||
): Promise<{ redirectUrl: string }> {
|
||||
// const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId });
|
||||
// if (!booking) throw new NotFoundException("Booking 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 strategy = this.strategies.get(method);
|
||||
if (!strategy) {
|
||||
throw new NotFoundException("strategy not found");
|
||||
}
|
||||
// const booking = new Booking()
|
||||
// booking.totalAmount = 20
|
||||
// booking.id = randomUUID
|
||||
const amount = 20
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const redirectBase = this.configService.get<string>("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL");
|
||||
const redirectUrl = `${redirectBase}/${merchantOrderId}`;
|
||||
const amountMinor = Math.round(Number(amount) * 100);
|
||||
|
||||
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 input: ProviderInitiationInput = {
|
||||
merchantOrderId,
|
||||
orderRef: bookingId,
|
||||
amountMinor,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
platform: platform || "web",
|
||||
redirectUrl,
|
||||
};
|
||||
|
||||
const paymentResp = await strategy.pay({
|
||||
redirectUrl,
|
||||
amountMinor: amount,
|
||||
currency: currency,
|
||||
merchantOrderId: orderId,
|
||||
platform: payform,
|
||||
});
|
||||
const result = await this.telebirrProvider.initiate(input);
|
||||
|
||||
<<<<<<< HEAD
|
||||
const queryRunner = this.datasource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
@@ -178,20 +162,102 @@ export class PaymentService {
|
||||
holdExpiresAt: holdExpires,
|
||||
});
|
||||
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" });
|
||||
=======
|
||||
const payment = await this.paymentRepo.create({
|
||||
amount: amount,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
method: "telebirr",
|
||||
refId: bookingId,
|
||||
type: "booking",
|
||||
merchantOrderId,
|
||||
rawInitiation: result.rawInitiation,
|
||||
clientAction: result.clientAction as Record<string, unknown>,
|
||||
expiresAt: result.expiresAt,
|
||||
reason: `Payment for booking`,
|
||||
>>>>>>> eda21e22d872344b74c0c72308f87ce7435b299f
|
||||
});
|
||||
}
|
||||
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 };
|
||||
|
||||
return {
|
||||
redirectUrl: `${this.configService.get<string>("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, 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.telebirrProvider.queryStatus(resp.merchantOrderId)
|
||||
|
||||
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
findBookingById(id: string) {
|
||||
return this.paymentRepo.findOneBy({ refId: id, type: "booking" })
|
||||
}
|
||||
|
||||
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
||||
const clientAction =
|
||||
intent.clientAction && typeof intent.clientAction === "object"
|
||||
? (intent.clientAction as unknown as ClientAction)
|
||||
: undefined;
|
||||
const statusMap: Record<string, ProviderPaymentStatus> = {
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
"processing": ProviderPaymentStatus.PROCESSING,
|
||||
"success": ProviderPaymentStatus.SUCCEEDED,
|
||||
"failed": ProviderPaymentStatus.FAILED,
|
||||
"canceled": ProviderPaymentStatus.CANCELLED,
|
||||
"refunded": ProviderPaymentStatus.CANCELLED,
|
||||
};
|
||||
return {
|
||||
intentId: intent.id,
|
||||
status: statusMap[intent.status] ?? ProviderPaymentStatus.PROCESSING,
|
||||
clientAction,
|
||||
merchantOrderId: intent.merchantOrderId ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
62
apps/edr-freight-api/src/modules/payment/payments.dto.ts
Normal file
62
apps/edr-freight-api/src/modules/payment/payments.dto.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ProviderPaymentStatus } from "@edr/types";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export type PaymentPlatformDto = "web" | "mobile";
|
||||
|
||||
export class InitiatePaymentDto {
|
||||
@ApiProperty({ example: "booking-uuid" })
|
||||
@IsString()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" })
|
||||
@IsIn(["TELEBIRR"])
|
||||
method!: "TELEBIRR";
|
||||
|
||||
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
|
||||
@IsOptional()
|
||||
@IsIn(["web", "mobile"])
|
||||
platform?: PaymentPlatformDto;
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] })
|
||||
type!: "REDIRECT" | "LAUNCH_APP";
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||
appId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||
receiveCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||
shortCode?: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
@ApiProperty()
|
||||
intentId!: string;
|
||||
|
||||
@ApiProperty({ enum: ProviderPaymentStatus })
|
||||
status!: ProviderPaymentStatus;
|
||||
|
||||
@ApiPropertyOptional({ type: ClientActionDto })
|
||||
clientAction?: ClientActionDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
merchantOrderId?: string;
|
||||
}
|
||||
|
||||
export class IntentStatusDto extends InitiateResponseDto {
|
||||
@ApiPropertyOptional()
|
||||
paidAt?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
failureCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
failureMessage?: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ProviderInitiationInput, ProviderInitiationResult } from "./payments.types";
|
||||
|
||||
|
||||
@Injectable()
|
||||
export abstract class PaymentStrategy {
|
||||
abstract pay(data: ProviderInitiationInput): Promise<ProviderInitiationResult>
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { PaymentStrategy } from "./payment.strategy";
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as https from 'node:https';
|
||||
import { PaymentEntity } from "../entities/payment.entity";
|
||||
import { ProviderInitiationInput, ProviderInitiationResult, ProviderStatus } from "./payments.types";
|
||||
import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse } from "./telebirr/telebirr.types";
|
||||
import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject } from "./telebirr/telebirr.crypto";
|
||||
|
||||
|
||||
|
||||
// type PaymentCurrency = PaymentEntity["currency"]
|
||||
type PaymentIntentStatus = PaymentEntity["status"]
|
||||
|
||||
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
@Injectable()
|
||||
export class PaymentTelebirrStrategy implements PaymentStrategy {
|
||||
async pay(data: ProviderInitiationInput): Promise<any> {
|
||||
// const refId = randomUUID()
|
||||
// const orderId = createMerchantOrderId()
|
||||
const resp = await this.initiate(data)
|
||||
return resp;
|
||||
}
|
||||
|
||||
// readonly method = PaymentMethodType.TELEBIRR;
|
||||
private readonly logger = new Logger(PaymentTelebirrStrategy.name);
|
||||
private readonly httpsAgent: https.Agent;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {
|
||||
const insecure = this.config.get<boolean>('telebirr.insecureTls');
|
||||
if (insecure) {
|
||||
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
|
||||
}
|
||||
this.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: !insecure,
|
||||
secureProtocol: 'TLSv1_2_method',
|
||||
});
|
||||
}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildCreateOrderRequest(input);
|
||||
const response = await this.requestCreateOrder(fabricToken, requestBody);
|
||||
|
||||
const prepayId = response.biz_content?.prepay_id;
|
||||
if (!prepayId) {
|
||||
throw new Error(
|
||||
`Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
|
||||
const platform = input.platform ?? 'web';
|
||||
const clientAction =
|
||||
platform === 'mobile'
|
||||
? {
|
||||
type: 'LAUNCH_APP' as const,
|
||||
prepayId,
|
||||
receiveCode: response.biz_content?.receiveCode,
|
||||
shortCode: this.merchantCode,
|
||||
}
|
||||
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
|
||||
|
||||
return {
|
||||
providerOrderId: prepayId,
|
||||
clientAction,
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
|
||||
const response = await this.postJson<QueryOrderResponse>(
|
||||
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
|
||||
requestBody,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
Authorization: fabricToken,
|
||||
},
|
||||
);
|
||||
|
||||
const tradeStatus = response.biz_content?.trade_status;
|
||||
const providerTxnId =
|
||||
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
|
||||
const mapped = this.mapTradeStatus(tradeStatus);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId,
|
||||
failureCode:
|
||||
mapped === "failed" && tradeStatus ? tradeStatus : undefined,
|
||||
rawResponse: response as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'PAY_SUCCESS':
|
||||
return "success";
|
||||
case 'PAY_FAILED':
|
||||
case 'ORDER_CLOSED':
|
||||
return "failed";
|
||||
case 'WAIT_PAY':
|
||||
return "action-required";
|
||||
case 'PAYING':
|
||||
return "processing";
|
||||
default:
|
||||
return "processing";
|
||||
}
|
||||
}
|
||||
|
||||
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'Completed':
|
||||
return "success";
|
||||
case 'Failure':
|
||||
case 'Expired':
|
||||
return "failed";
|
||||
case 'Paying':
|
||||
case 'Pending':
|
||||
return "processing";
|
||||
default:
|
||||
return "processing";
|
||||
}
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
if (!this.publicKey) {
|
||||
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
|
||||
return false;
|
||||
}
|
||||
return verifyRequestObject(payload, this.publicKey);
|
||||
}
|
||||
|
||||
private async applyFabricToken(): Promise<string> {
|
||||
console.log(this.baseUrl, "base url")
|
||||
const response = await this.postJson<FabricTokenResponse>(
|
||||
`${this.baseUrl}/payment/v1/token`,
|
||||
{ appSecret: this.appSecret },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
},
|
||||
);
|
||||
if (!response?.token) {
|
||||
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
|
||||
}
|
||||
return response.token;
|
||||
}
|
||||
|
||||
private async requestCreateOrder(
|
||||
fabricToken: string,
|
||||
body: CreateOrderRequest,
|
||||
): Promise<CreateOrderResponse> {
|
||||
return this.postJson<CreateOrderResponse>(
|
||||
`${this.baseUrl}/payment/v1/inapp/createOrder`,
|
||||
body,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
Authorization: fabricToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
|
||||
// const totalAmount = String(input.amountMinor / 100);
|
||||
const totalAmount = String(input.amountMinor)
|
||||
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,
|
||||
redirect_url: input.redirectUrl,
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: input.merchantOrderId,
|
||||
trade_type: 'Checkout' as const,
|
||||
title: `EDR Booking`,
|
||||
total_amount: totalAmount,
|
||||
trans_currency: input.currency,
|
||||
timeout_express: this.timeoutExpress,
|
||||
},
|
||||
};
|
||||
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 {
|
||||
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 rawRequest = [
|
||||
`appid=${map.appid}`,
|
||||
`merch_code=${map.merch_code}`,
|
||||
`nonce_str=${map.nonce_str}`,
|
||||
`prepay_id=${map.prepay_id}`,
|
||||
`timestamp=${map.timestamp}`,
|
||||
'sign_type=SHA256WithRSA',
|
||||
`sign=${sign}`,
|
||||
'version=1.0',
|
||||
'trade_type=Checkout',
|
||||
].join('&');
|
||||
return `${this.webBaseUrl}${rawRequest}`;
|
||||
}
|
||||
|
||||
private computeExpiresAt(timeoutExpress: string): Date {
|
||||
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
|
||||
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
|
||||
return new Date(Date.now() + minutes * 60_000);
|
||||
}
|
||||
|
||||
private toMinutes(n: number, unit: string): number {
|
||||
switch (unit) {
|
||||
case 's': return Math.max(1, Math.round(n / 60));
|
||||
case 'm': return n;
|
||||
case 'h': return n * 60;
|
||||
case 'd': return n * 60 * 24;
|
||||
default: return 15;
|
||||
}
|
||||
}
|
||||
|
||||
private async postJson<T>(
|
||||
url: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers,
|
||||
timeout: TELEBIRR_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(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: CreateOrderRequest): Record<string, unknown> {
|
||||
const { sign: _sign, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
|
||||
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
|
||||
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
|
||||
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
|
||||
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
|
||||
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
|
||||
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
|
||||
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
|
||||
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
|
||||
private get publicKey(): string {
|
||||
return this.config.get<string>('telebirr.publicKey') ?? '';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { PaymentEntity } from "../entities/payment.entity";
|
||||
|
||||
type PaymentIntentStatus = PaymentEntity["status"]
|
||||
type PaymentMethodType = PaymentEntity["method"]
|
||||
|
||||
export type PaymentPlatform = 'web' | 'mobile';
|
||||
|
||||
export type ClientAction =
|
||||
| { type: 'REDIRECT'; url: string }
|
||||
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
|
||||
|
||||
export interface ProviderInitiationInput {
|
||||
redirectUrl: string;
|
||||
merchantOrderId: string;
|
||||
// bookingRef: string;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
platform?: PaymentPlatform;
|
||||
}
|
||||
|
||||
export interface ProviderInitiationResult {
|
||||
providerOrderId: string;
|
||||
clientAction: ClientAction;
|
||||
expiresAt: Date;
|
||||
rawInitiation: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderStatus {
|
||||
status: PaymentIntentStatus;
|
||||
providerTxnId?: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
rawResponse: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PaymentProvider {
|
||||
readonly method: PaymentMethodType;
|
||||
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
|
||||
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const EXCLUDE_FIELDS = new Set([
|
||||
'sign',
|
||||
'sign_type',
|
||||
'header',
|
||||
'refund_info',
|
||||
'openType',
|
||||
'raw_request',
|
||||
'biz_content',
|
||||
]);
|
||||
|
||||
const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
||||
export function buildCanonicalString(requestObject: Record<string, unknown>): string {
|
||||
const fieldMap: Record<string, unknown> = {};
|
||||
|
||||
for (const key of Object.keys(requestObject)) {
|
||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
||||
fieldMap[key] = requestObject[key];
|
||||
}
|
||||
|
||||
const biz = requestObject['biz_content'];
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(fieldMap)
|
||||
.sort()
|
||||
.map((k) => `${k}=${fieldMap[k]}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
export function signRequestObject(
|
||||
requestObject: Record<string, unknown>,
|
||||
privateKey: string,
|
||||
): string {
|
||||
return signString(buildCanonicalString(requestObject), privateKey);
|
||||
}
|
||||
|
||||
export function verifyRequestObject(
|
||||
requestObject: Record<string, unknown>,
|
||||
publicKey: string,
|
||||
): boolean {
|
||||
const signature = requestObject['sign'];
|
||||
if (typeof signature !== 'string' || signature.length === 0) return false;
|
||||
return verifySignature(buildCanonicalString(requestObject), signature, publicKey);
|
||||
}
|
||||
|
||||
export function signString(text: string, privateKey: string): string {
|
||||
const signature = crypto.sign('sha256', Buffer.from(text), {
|
||||
key: privateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
|
||||
});
|
||||
return signature.toString('base64');
|
||||
}
|
||||
|
||||
export function verifySignature(
|
||||
text: string,
|
||||
signatureBase64: string,
|
||||
publicKey: string,
|
||||
): boolean {
|
||||
try {
|
||||
return crypto.verify(
|
||||
'sha256',
|
||||
Buffer.from(text),
|
||||
{
|
||||
key: publicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
|
||||
},
|
||||
Buffer.from(signatureBase64, 'base64'),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTimestamp(): string {
|
||||
return Math.round(Date.now() / 1000).toString();
|
||||
}
|
||||
|
||||
export function createNonceStr(length = 32): string {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let out = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMerchantOrderId(): string {
|
||||
return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
export interface FabricTokenResponse {
|
||||
token: string;
|
||||
expires_in?: number | string;
|
||||
}
|
||||
|
||||
export interface CreateOrderBizContent {
|
||||
notify_url: string;
|
||||
appid: string;
|
||||
merch_code: string;
|
||||
merch_order_id: string;
|
||||
trade_type: 'Checkout' | 'InApp' | 'MiniApp';
|
||||
title: string;
|
||||
total_amount: string;
|
||||
trans_currency: string;
|
||||
timeout_express: string;
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
timestamp: string;
|
||||
nonce_str: string;
|
||||
method: 'payment.preorder';
|
||||
version: '1.0';
|
||||
biz_content: CreateOrderBizContent;
|
||||
sign: string;
|
||||
sign_type: 'SHA256WithRSA';
|
||||
}
|
||||
|
||||
export interface CreateOrderResponse {
|
||||
code?: string;
|
||||
msg?: string;
|
||||
biz_content?: {
|
||||
prepay_id?: string;
|
||||
receiveCode?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type TelebirrTradeStatus =
|
||||
| 'PAY_SUCCESS'
|
||||
| 'PAY_FAILED'
|
||||
| 'WAIT_PAY'
|
||||
| 'ORDER_CLOSED'
|
||||
| 'PAYING'
|
||||
| 'ACCEPTED'
|
||||
| 'REFUNDING'
|
||||
| 'REFUND_SUCCESS'
|
||||
| 'REFUND_FAILED';
|
||||
|
||||
export interface QueryOrderResponse {
|
||||
result?: 'SUCCESS' | 'FAIL';
|
||||
code?: string;
|
||||
msg?: string;
|
||||
nonce_str?: string;
|
||||
sign?: string;
|
||||
sign_type?: string;
|
||||
biz_content?: {
|
||||
merch_order_id?: string;
|
||||
order_status?: string;
|
||||
trade_status?: TelebirrTradeStatus | string;
|
||||
payment_order_id?: string;
|
||||
trans_id?: string;
|
||||
trans_time?: string;
|
||||
trans_currency?: string;
|
||||
total_amount?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -1,82 +1,53 @@
|
||||
import { Injectable, } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as crypto from "crypto"
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TelebirrDto } from '../dto/telebirr.dto';
|
||||
import { PaymentRepository } from '../../payment.repository';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Booking } from 'src/modules/bookings/entities/booking.entity';
|
||||
import { Booking } from '../../../bookings/entities/booking.entity';
|
||||
import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers';
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrWebhookService {
|
||||
// private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||
private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly datasource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
) { }
|
||||
|
||||
verifyTelebirrNotification(payload: TelebirrDto) {
|
||||
// 1. Extract the signature provided by Telebirr
|
||||
const { sign, ...bizContent } = payload;
|
||||
|
||||
if (!sign) {
|
||||
throw new Error("Missing 'sign' field from Telebirr payload");
|
||||
}
|
||||
|
||||
// 2. Sort the remaining keys alphabetically to rebuild the raw string
|
||||
const sortedKeys = Object.keys(bizContent).sort();
|
||||
const signString = sortedKeys
|
||||
.map(key => `${key}=${typeof bizContent[key] === 'object' ? JSON.stringify(bizContent[key]) : bizContent[key]}`)
|
||||
.join('&');
|
||||
|
||||
// 3. Convert Telebirr's public key into an object specifying RSA-PSS padding
|
||||
const publicKey = {
|
||||
key: this.config.get<string>("telebirr.publicKey") ?? "",
|
||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: 32 // Telebirr standard salt length
|
||||
};
|
||||
|
||||
// 4. Verify the signature against the sorted string
|
||||
const isVerified = crypto.verify(
|
||||
"sha256",
|
||||
Buffer.from(signString),
|
||||
publicKey,
|
||||
Buffer.from(sign, 'base64')
|
||||
);
|
||||
|
||||
return isVerified;
|
||||
return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async handle(payload: TelebirrDto): Promise<void> {
|
||||
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
|
||||
if (!payment) {
|
||||
throw new Error("payment not found")
|
||||
this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`);
|
||||
return;
|
||||
}
|
||||
switch (payload.trade_status) {
|
||||
case "SUCCEEDED":
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() })
|
||||
switch (payment.type) {
|
||||
case "booking":
|
||||
await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", })
|
||||
// await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", })
|
||||
break;
|
||||
|
||||
const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status);
|
||||
|
||||
switch (mapped) {
|
||||
case ProviderPaymentStatus.SUCCEEDED:
|
||||
await this.paymentRepo.update(
|
||||
{ id: payment.id },
|
||||
{ status: "success", paidAt: new Date() },
|
||||
);
|
||||
if (payment.type === "booking") {
|
||||
await this.datasource.manager.update(
|
||||
Booking,
|
||||
{ id: payment.refId },
|
||||
{ paymentStatus: "PAID" },
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "FAILED":
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "failed" })
|
||||
case ProviderPaymentStatus.FAILED:
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "failed" });
|
||||
break;
|
||||
case "CANCELLED":
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "canceled" })
|
||||
case ProviderPaymentStatus.PROCESSING:
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "processing" });
|
||||
break;
|
||||
case "PROCESSING":
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "processing" })
|
||||
break;
|
||||
case "REFUNDED":
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "refunded" })
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,14 +22,12 @@ export class WebhookController {
|
||||
);
|
||||
|
||||
try {
|
||||
// const verified = this.telebirr.verifyTelebirrNotification(payload)
|
||||
// if (!verified) {
|
||||
// throw new Error("not valid")
|
||||
// }
|
||||
// const merchantOrderId = payload.merch_order_id;
|
||||
const verified = this.telebirr.verifyTelebirrNotification(payload)
|
||||
if (!verified) {
|
||||
throw new Error("Telebirr webhook signature verification failed")
|
||||
}
|
||||
await this.telebirr.handle(payload);
|
||||
|
||||
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Telebirr webhook handler threw: ${message}`);
|
||||
|
||||
@@ -11,42 +11,60 @@ import {
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
const parseLoadTypes = (value: unknown): string[] => {
|
||||
const toNumber = ({ value }: { value: unknown }) =>
|
||||
value === '' || value == null ? value : Number(value);
|
||||
|
||||
const toOptionalNumber = ({ value }: { value: unknown }) =>
|
||||
value === '' || value == null ? undefined : Number(value);
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return value;
|
||||
};
|
||||
|
||||
const toStringArray = ({ value }: { value: unknown }) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item).trim()).filter(Boolean);
|
||||
return value.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
|
||||
if (typeof value !== 'string') return [];
|
||||
|
||||
return value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export class CreateWagonTypeDto {
|
||||
@ApiProperty({ description: 'Display name, e.g. "Flat Wagon"', maxLength: 100 })
|
||||
@ApiProperty({ maxLength: 32, example: 'NW5' })
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ maxLength: 100, example: 'Flat wagon container' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: 'Maximum payload capacity in metric tons' })
|
||||
@ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 70 })
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
@Transform(({ value }) => Number(value))
|
||||
capacityTons!: number;
|
||||
|
||||
@ApiProperty({ description: 'Wagon length in meters' })
|
||||
@ApiProperty({ description: 'Wagon length in meters', example: 14 })
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
@Transform(({ value }) => Number(value))
|
||||
lengthMeters!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train' })
|
||||
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 })
|
||||
@IsOptional()
|
||||
@Transform(toOptionalNumber)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => (value === '' || value === null || value === undefined ? undefined : Number(value)))
|
||||
maxWagonsPerTrain?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
@@ -55,13 +73,14 @@ export class CreateWagonTypeDto {
|
||||
default: [],
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Transform(({ value }) => parseLoadTypes(value))
|
||||
supportedLoadTypes?: string[];
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -28,11 +28,18 @@ export class WagonTypesController {
|
||||
@Get()
|
||||
@RuleEngineView('wagon-types')
|
||||
@ApiOperation({ summary: 'List wagon types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
return this.wagonTypesService.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
isActive:
|
||||
query.isActive === 'all'
|
||||
? undefined
|
||||
: query.isActive !== undefined
|
||||
? query.isActive === 'true'
|
||||
: true,
|
||||
page: query.page ? parseInt(query.page, 10) : undefined,
|
||||
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
|
||||
sortBy: query.sortBy,
|
||||
sortOrder: query.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { generateCode } from '../../common/utils/generate-code.util';
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsOrder } from 'typeorm';
|
||||
|
||||
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
|
||||
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
||||
import { WagonType } from './entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from './wagon-types.repository';
|
||||
|
||||
type WagonTypeListFilter = {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WagonTypesService {
|
||||
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
|
||||
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {}): Promise<{
|
||||
async findAll(filter: WagonTypeListFilter = {}): Promise<{
|
||||
data: WagonType[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filter.isActive !== undefined) {
|
||||
where.isActive = filter.isActive;
|
||||
}
|
||||
const pageSize = filter.pageSize ?? 500;
|
||||
const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes(
|
||||
filter.sortBy ?? '',
|
||||
)
|
||||
? (filter.sortBy as keyof WagonType)
|
||||
: 'code';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
const [data, total] = await this.wagonTypesRepository.findAndCount({
|
||||
where,
|
||||
order: { code: 'ASC' },
|
||||
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<WagonType>,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
@@ -50,9 +51,11 @@ export class WagonTypesService {
|
||||
|
||||
async findById(id: string): Promise<WagonType> {
|
||||
const wagonType = await this.wagonTypesRepository.findById(id);
|
||||
|
||||
if (!wagonType) {
|
||||
throw new NotFoundException(`Wagon type ${id} not found`);
|
||||
}
|
||||
|
||||
return wagonType;
|
||||
}
|
||||
|
||||
@@ -65,17 +68,16 @@ export class WagonTypesService {
|
||||
}
|
||||
|
||||
async create(dto: CreateWagonTypeDto): Promise<WagonType> {
|
||||
const code = generateCode(dto.name);
|
||||
const code = dto.code.trim().toUpperCase();
|
||||
const existing = await this.wagonTypesRepository.findByCode(code);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Wagon type with name "${dto.name}" conflicts with existing code "${code}"`,
|
||||
);
|
||||
throw new ConflictException(`Wagon type code "${code}" already exists`);
|
||||
}
|
||||
|
||||
return this.wagonTypesRepository.create({
|
||||
code,
|
||||
name: dto.name,
|
||||
name: dto.name.trim(),
|
||||
capacityTons: dto.capacityTons,
|
||||
lengthMeters: dto.lengthMeters,
|
||||
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
|
||||
@@ -85,11 +87,29 @@ export class WagonTypesService {
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWagonTypeDto): Promise<WagonType> {
|
||||
await this.findById(id);
|
||||
const updated = await this.wagonTypesRepository.update(id, dto);
|
||||
const wagonType = await this.findById(id);
|
||||
const nextCode = dto.code?.trim().toUpperCase();
|
||||
|
||||
if (nextCode && nextCode !== wagonType.code) {
|
||||
const existing = await this.wagonTypesRepository.findByCode(nextCode);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Wagon type code "${nextCode}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.wagonTypesRepository.update(id, {
|
||||
...dto,
|
||||
...(nextCode ? { code: nextCode } : {}),
|
||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||
maxWagonsPerTrain:
|
||||
dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null,
|
||||
supportedLoadTypes: dto.supportedLoadTypes ?? undefined,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Wagon type ${id} not found`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user