This commit is contained in:
Tria
2026-06-05 12:54:35 +03:00
parent 7c124d9845
commit c0dc9f5fc5
9 changed files with 2163 additions and 93 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

@@ -1,8 +1,9 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
import { Body, Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import { Public } from "@edr/api-common";
import { randomUUID } from "crypto";
import { Response } from "express"
import { UpdatePaymentStatusDto } from "./dto/update-payment-status.dto";
@Public()
@Controller("payments")
export class PaymentController {
@@ -49,9 +50,5 @@ export class PaymentController {
}
@Post("web-hooks/telebirr")
paymentWebhook() {
}
}

View File

@@ -5,10 +5,12 @@ 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";
@Module({
imports: [HttpModule, ConfigModule],
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService],
controllers: [PaymentController]
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
controllers: [PaymentController, WebhookController]
})
export class PaymentModule { }

View File

@@ -35,4 +35,5 @@ export class PaymentRepository {
}
}

View File

@@ -1,11 +1,12 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DataSource, QueryRunner } from "typeorm";
import { DataSource, QueryRunner } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentStrategy } from "./strategies/payment.strategy";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentRepository } from "./payment.repository";
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
import * as crypto from 'crypto';
import { PaymentStatus, UpdatePaymentStatusDto } from "./dto/update-payment-status.dto";
type PaymentMethod = PaymentEntity["method"]
type CurrencyType = PaymentEntity["currency"]
@@ -88,4 +89,25 @@ export class PaymentService {
}
async handleTelebirrPaymentCb(dto: UpdatePaymentStatusDto): Promise<void> {
switch (dto.status) {
case PaymentStatus.SUCCEEDED:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "success" })
break;
case PaymentStatus.FAILED:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "failed", failureMessage: dto.failureMessage })
break;
case PaymentStatus.CANCELLED:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
break;
case PaymentStatus.PROCESSING:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
break;
case PaymentStatus.REFUNDED:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
break;
}
}
}

View File

@@ -0,0 +1,13 @@
export class TelebirrDto {
merch_order_id!: string;
payment_order_id!: string;
trade_status!: string;
trans_id?: string;
total_amount?: string;
trans_currency?: string;
notify_time?: string;
trans_end_time?: string;
sign!: string;
sign_type?: string;
[key: string]: unknown;
}

View File

@@ -0,0 +1,49 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from "crypto"
import { TelebirrDto } from '../dto/telebirr.dto';
@Injectable()
export class TelebirrWebhookService {
// private readonly logger = new Logger(TelebirrWebhookService.name);
constructor(
private readonly config: ConfigService
) { }
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;
}
async handle(payload: TelebirrDto): Promise<void> {
}
}

View File

@@ -0,0 +1,41 @@
import { All, Body, Controller, HttpCode, HttpStatus, Logger, } from '@nestjs/common';
import { TelebirrWebhookService } from './providers/telebirr.service';
import { ApiOperation } from '@nestjs/swagger';
import { TelebirrDto } from './dto/telebirr.dto';
@Controller("payments/webhooks")
export class WebhookController {
constructor(private readonly telebirr: TelebirrWebhookService) { }
private readonly logger = new Logger(WebhookController.name);
@All('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("not valid")
}
const merchantOrderId = payload.merch_order_id;
if (merchantOrderId.startsWith("freight")) {
await this.telebirr.handle(payload);
} else if (merchantOrderId.startsWith("passagner")) {
//todo: handle else where
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Telebirr webhook handler threw: ${message}`);
}
return { code: '0', message: 'OK' };
}
}

2092
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff