telebirr out in the payment

This commit is contained in:
Eyosiyas
2026-06-08 11:49:50 +03:00
769 changed files with 87305 additions and 2232 deletions

View File

@@ -0,0 +1,23 @@
import { IsEnum, IsOptional, IsString } from "class-validator";
export enum PaymentStatus {
REQUIRES_ACTION,
PROCESSING,
SUCCEEDED,
FAILED,
CANCELLED,
REFUNDED,
}
export class UpdatePaymentStatusDto {
@IsString()
orderId!: string;
@IsEnum(PaymentStatus)
status!: PaymentStatus
@IsOptional()
@IsString()
failureMessage?: string
}

View File

@@ -0,0 +1,65 @@
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
type PaymentType = "booking"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
type Currency = "ETB" | "USD"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@Entity({ schema: 'freight', name: 'payments' })
export class PaymentEntity extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id!: string
@Column({ type: 'varchar', length: 255, name: "ref_id" })
refId!: string
@Column({ type: "enum", enum: ["booking"] })
type!: PaymentType;
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] })
method!: PaymentMethod
@Column({ type: "enum", enum: ["ETB", "USD"] })
currency!: Currency
@Column({ type: "numeric" })
amount!: number
@Column({ type: "varchar", length: 255, name: "reason", })
reason?: string;
@Column({ type: "jsonb", default: {}, name: "raw_initiation" })
rawInitiation?: Record<string, unknown>
@Column({ type: "jsonb", name: "client_action" })
clientAction?: Record<string, unknown>;
@Column({ type: "varchar", length: 255, unique: true, name: "merchant_order_id", })
merchantOrderId!: string
@Column({ type: "varchar", length: 255, unique: true, name: "transaction_id", })
transactionId?: string
@Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" })
status!: PaymentStatus
@Column({ type: "date", nullable: true, name: "paid_at" })
paidAt?: Date
@Column({ type: "timestamp", nullable: true, name: "refunded_at" })
refundedAt?: Date
@Column({ type: "timestamp", nullable: true, name: "expires_at" })
expiresAt?: Date
@Column({ type: "varchar", length: 30, nullable: true, name: "failer_code" })
failerCode?: string
@Column({ type: "varchar", length: 255, nullable: true, name: "failer_message" })
failureMessage?: string
@CreateDateColumn({ name: "created_at" })
createdAt!: Date
}

View File

@@ -0,0 +1,44 @@
import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import { Public } from "@edr/api-common";
import { Response } from "express"
@Public()
@Controller("payments")
export class PaymentController {
constructor(private readonly paymentService: PaymentService,) { }
@Post("/initiate")
initiate() {
return this.paymentService.initBookingTelebirr("123", "web")
}
@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')
}
return res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Redirecting...</title>
</head>
<body>
<p>Redirecting...</p>
<script>
window.location.href = "${payment.clientAction?.url}";
</script>
</body>
</html>
`);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import { HttpModule } from "@nestjs/axios";
import { PaymentController } from "./payment.controller";
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, PaymentService, TelebirrWebhookService, TelebirrProvider],
controllers: [PaymentController, WebhookController],
exports: [PaymentService]
})
export class PaymentModule { }

View File

@@ -0,0 +1,62 @@
import { Injectable } from "@nestjs/common";
import { DataSource, FindOptionsWhere, QueryDeepPartialEntity, QueryRunner, Repository } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
@Injectable()
export class PaymentRepository {
private readonly paymentRepo: Repository<PaymentEntity>;
constructor(private readonly dataSource: DataSource) {
this.paymentRepo = this.dataSource.getRepository(PaymentEntity)
}
async createTr(qr: QueryRunner, data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt" | "reason">): Promise<PaymentEntity> {
const payment = qr.manager.create(PaymentEntity, data)
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);
}
update(where: FindOptionsWhere<PaymentEntity>, data: QueryDeepPartialEntity<PaymentEntity>) {
return this.paymentRepo.update(where, data)
}
getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]) {
return this.paymentRepo
.createQueryBuilder('payment')
.where('payment.method = :method', { method })
.andWhere('payment.refId = :refId', { refId })
.andWhere('payment.status IN (:...statuses)', {
statuses: ['action-required'],
})
.andWhere('payment.expiresAt > :now', { now: new Date() })
.getOne();
}
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

@@ -0,0 +1,163 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
import * as fs from 'fs';
import * as path from 'path';
import * as Handlebars from 'handlebars';
import { ConfigService } from "@nestjs/config";
import { Booking } from "../bookings/entities/booking.entity";
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 {
constructor(
private readonly configService: ConfigService,
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
) { }
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");
// 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 input: ProviderInitiationInput = {
merchantOrderId,
orderRef: bookingId,
amountMinor,
currency: DEFAULT_CURRENCY,
platform: platform || "web",
redirectUrl,
};
const result = await this.telebirrProvider.initiate(input);
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`,
});
// const formatterd = this.formatIntentResponse(payment);
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

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>Redirecting...</title>
</head>
<body>
<p>Redirecting...</p>
<script>
window.location.href = "{{url}}";
</script>
</body>
</html>

View File

@@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt - {{vendorName}}</title>
<style>
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f9f9f9;
margin: 0;
padding: 20px;
}
.receipt-box {
max-width: 550px;
margin: auto;
padding: 30px;
border: 1px solid #eee;
background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
border-radius: 8px;
}
.header {
text-align: center;
border-bottom: 2px dashed #eee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
font-size: 22px;
margin: 0 0 5px;
color: #0056b3;
}
.header p {
font-size: 14px;
color: #777;
margin: 0;
}
.details-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.details-table td {
padding: 10px 0;
font-size: 14px;
}
.label {
color: #777;
font-weight: 600;
}
.value {
text-align: right;
}
.totals-section {
border-top: 1px solid #eee;
padding-top: 15px;
}
.total-row {
font-size: 16px;
font-weight: bold;
}
.footer {
text-align: center;
margin-top: 30px;
font-size: 12px;
color: #999;
border-top: 1px solid #eee;
padding-top: 15px;
}
.download-container {
text-align: center;
margin-top: 25px;
}
.download-btn {
background: #0056b3;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
.download-btn:hover {
background: #004494;
}
@media print {
body {
background: white;
padding: 0;
}
.receipt-box {
box-shadow: none;
border: none;
max-width: 100%;
}
.download-container {
display: none;
}
}
</style>
</head>
<body>
<div class="receipt-box">
<div class="header">
<h1>{{vendorName}}</h1>
<p>{{vendorAddress}}</p>
</div>
<table class="details-table">
<tr>
<td class="label">Date</td>
<td class="value">{{receiptDate}}</td>
</tr>
<tr>
<td class="label">Payment Method</td>
<td class="value" style="text-transform: capitalize;">
{{paymentMethod}}
</td>
</tr>
<tr>
<td class="label">Description</td>
<td class="value">{{reason}}</td>
</tr>
</table>
<div class="totals-section">
<table class="details-table">
<tr>
<td class="label">Subtotal</td>
<td class="value">
{{currency}} {{subtotal}}
</td>
</tr>
<tr class="total-row">
<td class="label" style="color:#333">
Total Paid
</td>
<td class="value" style="color:#0056b3">
{{currency}} {{total}}
</td>
</tr>
</table>
</div>
<div class="footer">
<p>Thank you for traveling with us!</p>
<p>Have a safe journey.</p>
</div>
<div class="download-container">
<button class="download-btn" onclick="downloadPdf()">
Download PDF
</button>
</div>
</div>
<script>
function downloadPdf() {
window.print();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,48 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
export class TelebirrDto {
@ApiProperty()
@IsString()
merch_order_id!: string;
@IsOptional()
@IsString()
payment_order_id!: string;
@ApiProperty({ default: "SUCCEEDED"})
@IsString()
trade_status!: string;
@IsOptional()
@IsString()
trans_id?: string;
@IsOptional()
@IsString()
total_amount?: string;
@IsOptional()
@IsString()
trans_currency?: string;
@IsOptional()
@IsString()
notify_time?: string;
@IsOptional()
@IsString()
trans_end_time?: string;
@IsOptional()
@IsString()
sign!: string;
@IsOptional()
@IsString()
sign_type?: string;
[key: string]: unknown;
}

View File

@@ -0,0 +1,53 @@
import { Injectable, Logger } from '@nestjs/common';
import { TelebirrDto } from '../dto/telebirr.dto';
import { PaymentRepository } from '../../payment.repository';
import { DataSource } from 'typeorm';
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);
constructor(
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
) { }
verifyTelebirrNotification(payload: TelebirrDto) {
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) {
this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`);
return;
}
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 ProviderPaymentStatus.FAILED:
await this.paymentRepo.update({ id: payment.id }, { status: "failed" });
break;
case ProviderPaymentStatus.PROCESSING:
await this.paymentRepo.update({ id: payment.id }, { status: "processing" });
break;
}
}
}

View File

@@ -0,0 +1,38 @@
import { Body, Controller, HttpCode, HttpStatus, Logger, Post, } from '@nestjs/common';
import { TelebirrWebhookService } from './providers/telebirr.service';
import { ApiOperation } from '@nestjs/swagger';
import { TelebirrDto } from './dto/telebirr.dto';
import { Public } from '@edr/api-common';
@Controller("payments-webhooks")
@Public()
export class WebhookController {
constructor(private readonly telebirr: TelebirrWebhookService) { }
private readonly logger = new Logger(WebhookController.name);
@Post('telebirr')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Telebirr payment notification callback (Ethiopia)',
description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.'
})
async receiveTelebirr(@Body() payload: TelebirrDto) {
this.logger.log(
`Telebirr webhook Called`,
);
try {
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}`);
}
return { code: '0', message: 'OK' };
}
}