merge conflict

This commit is contained in:
marshal
2026-06-10 16:45:45 +03:00
451 changed files with 47783 additions and 6194 deletions

View File

@@ -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`);

View File

@@ -1,6 +0,0 @@
import { IsString } from "class-validator";
export class InitiateBookingPayment {
@IsString()
bookingId!: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}

View File

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

View File

@@ -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') ?? '';
}
}

View File

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

View File

@@ -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')}`;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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