telebirr configured

This commit is contained in:
Tria
2026-06-05 11:01:01 +03:00
parent 93020b3153
commit 7c124d9845
11 changed files with 328 additions and 54 deletions

View File

@@ -0,0 +1,96 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreatePaymentTable1780639311366 implements MigrationInterface {
name = "CreatePaymentTable1780639311366";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE freight.payments_type_enum AS ENUM ('booking');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_currency_enum AS ENUM ('ETB', 'USD');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_status_enum AS ENUM (
'action-required',
'processing',
'success',
'failed',
'canceled',
'refunded'
);
`);
await queryRunner.query(`
CREATE TABLE freight.payments (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
ref_id varchar(255) NOT NULL,
type freight.payments_type_enum NOT NULL,
method freight.payments_method_enum NOT NULL,
currency freight.payments_currency_enum NOT NULL,
amount numeric NOT NULL,
raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb,
client_action json,
merchant_order_id varchar(255) NOT NULL,
transaction_id varchar(255),
status freight.payments_status_enum NOT NULL DEFAULT 'action-required',
paid_at date,
refunded_at date,
expires_at date,
failer_code varchar(30),
failer_message varchar(255),
created_at TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT PK_payments PRIMARY KEY (id),
CONSTRAINT UQ_payments_merchant_order_id UNIQUE (merchant_order_id),
CONSTRAINT UQ_payments_transaction_id UNIQUE (transaction_id)
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TABLE IF EXISTS freight.payments;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_status_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_currency_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_method_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_type_enum;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AlterClientActionToJsonb1780639978834 implements MigrationInterface {
name = "AlterClientActionToJsonb1780639978834";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action TYPE jsonb
USING client_action::jsonb;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action DROP DEFAULT;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action TYPE json
USING client_action::json;
`);
}
}

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdatePaymentTimestamp1780644945086 implements MigrationInterface {
name = "UpdatePaymentTimestamp1780644945086";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN refunded_at TYPE timestamp
USING refunded_at::timestamp;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN expires_at TYPE timestamp
USING expires_at::timestamp;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN refunded_at TYPE timestamptz
USING refunded_at::timestamptz;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN expires_at TYPE timestamptz
USING expires_at::timestamptz;
`);
}
}

View File

@@ -3,7 +3,7 @@ import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn }
type PaymentType = "booking"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
type Currency = "etb" | "usd"
type Currency = "ETB" | "USD"
type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@Entity({ schema: 'freight', name: 'payments' })
@@ -14,26 +14,26 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: 'varchar', length: 255, name: "ref_id" })
refId!: string
@Column({ type: 'varchar', length: 255, name: "table_id" })
tableId!: string
@Column({ type: "enum", enum: ["booking"] })
type!: PaymentType;
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] })
method!: PaymentMethod
@Column({ type: "enum", enum: ["etb", "usd"] })
@Column({ type: "enum", enum: ["ETB", "USD"] })
currency!: Currency
@Column({ type: "numeric" })
amount!: number
@Column({ type: "json", name: "client_action" })
clientAction?: 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
merchantOrderId!: string
@Column({ type: "varchar", length: 255, unique: true, name: "transaction_id", })
transactionId?: string
@@ -44,9 +44,12 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: "date", nullable: true, name: "paid_at" })
paidAt?: Date
@Column({ type: "date", nullable: true, name: "refunded_at" })
@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

View File

@@ -1,24 +1,54 @@
import { Controller, Post } from "@nestjs/common";
import { 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"
@Public()
@Controller("payments")
export class PaymentController {
constructor(private readonly paymentService: PaymentService) { }
@Post("/initiate")
async initiatePayment() {
const tableId = randomUUID()
const resp = await this.paymentService.create({
amount: 20,
currency: "etb",
method: "telebirr",
type: "booking",
tableId: tableId
//Only for testing..
const data = await this.paymentService.pay(20, "ETB", "telebirr", (_) => {
return new Promise((resp, _) => {
resp({
id: randomUUID(),
type: "booking"
})
});
})
return resp
return data
}
@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(`
<!DOCTYPE html>
<html>
<head>
<title>Redirecting...</title>
</head>
<body>
<p>Redirecting...</p>
<script>
window.location.href = "${payment.clientAction?.url}";
</script>
</body>
</html>
`);
}
@Post("web-hooks/telebirr")
paymentWebhook() {

View File

@@ -3,10 +3,12 @@ import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy"
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";
@Module({
imports: [HttpModule,],
providers: [PaymentTelebirrStrategy, PaymentService],
imports: [HttpModule, ConfigModule],
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService],
controllers: [PaymentController]
})
export class PaymentModule { }

View File

@@ -0,0 +1,38 @@
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">): Promise<PaymentEntity> {
const payment = qr.manager.create(PaymentEntity, data)
return qr.manager.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();
}
}

View File

@@ -1,41 +1,90 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DataSource, Repository } 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';
type PaymentMethod = PaymentEntity["method"]
type CurrencyType = PaymentEntity["currency"]
@Injectable()
export class PaymentService {
private strategies: Map<PaymentMethod, PaymentStrategy>;
private readonly paymentRepo: Repository<PaymentEntity>;
constructor(
private readonly dataSource: DataSource,
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) {
this.strategies = new Map([
["telebirr", this.telebirrPaymentStategy as PaymentStrategy]
])
this.paymentRepo = this.dataSource.getRepository(PaymentEntity)
}
async create(data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "tableId">): Promise<any> {
const strategy = this.strategies.get(data.method)
if (!strategy) throw new NotFoundException("strategy not found")
return strategy.pay(data.amount, data.currency, "")
// const payment = this.paymentRepo.create({
// amount: data.amount,
// method: data.method,
// type: data.type,
// currency: data.currency,
// tableId: data.tableId,
// })
// await this.paymentRepo.save(payment)
// return link;
async pay(amount: number, currency: CurrencyType, method: PaymentMethod, 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 orderId = `freigh${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic
const paymentResp = await strategy.pay({
amountMinor: amount,
currency: currency,
merchantOrderId: orderId,
platform: payform,
});
const queryRunner = this.datasource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
console.log(paymentResp.expiresAt)
try {
const resp = await cb(queryRunner)
const payment = await this.paymentRepo.createTr(queryRunner, {
amount,
currency,
method,
refId: resp.id,
type: resp.type,
merchantOrderId: orderId,
rawInitiation: paymentResp.rawInitiation,
clientAction: paymentResp.clientAction,
expiresAt: paymentResp.expiresAt
})
await queryRunner.commitTransaction()
return {
refId: payment.refId,
clientAction: paymentResp.clientAction,
status: payment.status,
paidAt: payment.paidAt?.toISOString(),
failureCode: payment.failerCode ?? undefined,
failureMessage: payment.failureMessage ?? undefined,
}
} catch (err) {
await queryRunner.rollbackTransaction()
throw new Error("payment failed")
} finally {
await queryRunner.release()
}
}
getPaymentByReferenceIdAndMethod(data: Pick<PaymentEntity, "method" | "refId">): Promise<PaymentEntity | null> {
return this.paymentRepo.findOneBy(data)
async getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method)
}

View File

@@ -1,9 +1,8 @@
import { Injectable } from "@nestjs/common";
import { PaymentEntity } from "../entities/payment.entity";
import { ProviderInitiationInput, ProviderInitiationResult } from "./payments.types";
type PaymentCurrency = PaymentEntity["currency"]
@Injectable()
export abstract class PaymentStrategy {
abstract pay(amount: number, currency: PaymentCurrency, refId: string): Promise<string>
abstract pay(data: ProviderInitiationInput): Promise<ProviderInitiationResult>
}

View File

@@ -9,25 +9,20 @@ 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";
import { randomUUID } from "node:crypto";
// type PaymentCurrency = PaymentEntity["currency"]
type PaymentIntentStatus = PaymentEntity["status"]
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
@Injectable()
export class PaymentTelebirrStrategy implements PaymentStrategy {
async pay(amount: number): Promise<any> {
const refId = randomUUID()
const resp = await this.initiate({
amountMinor: amount,
bookingRef: refId,
currency: "etb",
merchantOrderId: "",
platform: "web"
})
async pay(data: ProviderInitiationInput): Promise<any> {
// const refId = randomUUID()
// const orderId = createMerchantOrderId()
const resp = await this.initiate(data)
return resp;
}
@@ -151,6 +146,7 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
}
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 },

View File

@@ -11,7 +11,7 @@ export type ClientAction =
export interface ProviderInitiationInput {
merchantOrderId: string;
bookingRef: string;
// bookingRef: string;
amountMinor: number;
currency: string;
platform?: PaymentPlatform;