From 7c124d9845aff37236566aa846e3a92fa396fa26 Mon Sep 17 00:00:00 2001 From: Tria Date: Fri, 5 Jun 2026 11:01:01 +0300 Subject: [PATCH] telebirr configured --- .../1780639311366-CreatePaymentTable.ts | 96 +++++++++++++++++++ .../1780639978834-AlterClientActionToJsonb.ts | 28 ++++++ .../1780644945086-UpdatePaymentTimestamp.ts | 33 +++++++ .../payment/entities/payment.entity.ts | 21 ++-- .../src/modules/payment/payment.controller.ts | 50 ++++++++-- .../src/modules/payment/payment.module.ts | 6 +- .../src/modules/payment/payment.repository.ts | 38 ++++++++ .../src/modules/payment/payment.service.ts | 87 +++++++++++++---- .../payment/strategies/payment.strategy.ts | 5 +- .../strategies/payment.telebirr.strategy.ts | 16 ++-- .../payment/strategies/payments.types.ts | 2 +- 11 files changed, 328 insertions(+), 54 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1780639311366-CreatePaymentTable.ts create mode 100644 apps/edr-freight-api/src/migrations/1780639978834-AlterClientActionToJsonb.ts create mode 100644 apps/edr-freight-api/src/migrations/1780644945086-UpdatePaymentTimestamp.ts create mode 100644 apps/edr-freight-api/src/modules/payment/payment.repository.ts diff --git a/apps/edr-freight-api/src/migrations/1780639311366-CreatePaymentTable.ts b/apps/edr-freight-api/src/migrations/1780639311366-CreatePaymentTable.ts new file mode 100644 index 000000000..cbb60d5d4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1780639311366-CreatePaymentTable.ts @@ -0,0 +1,96 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreatePaymentTable1780639311366 implements MigrationInterface { + name = "CreatePaymentTable1780639311366"; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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; + `); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/migrations/1780639978834-AlterClientActionToJsonb.ts b/apps/edr-freight-api/src/migrations/1780639978834-AlterClientActionToJsonb.ts new file mode 100644 index 000000000..723331ab3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1780639978834-AlterClientActionToJsonb.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AlterClientActionToJsonb1780639978834 implements MigrationInterface { + name = "AlterClientActionToJsonb1780639978834"; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN client_action TYPE json + USING client_action::json; + `); + } + + +} diff --git a/apps/edr-freight-api/src/migrations/1780644945086-UpdatePaymentTimestamp.ts b/apps/edr-freight-api/src/migrations/1780644945086-UpdatePaymentTimestamp.ts new file mode 100644 index 000000000..6cfc7fc8f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1780644945086-UpdatePaymentTimestamp.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UpdatePaymentTimestamp1780644945086 implements MigrationInterface { + name = "UpdatePaymentTimestamp1780644945086"; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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; + `); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 4858aa979..f4b784a62 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -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 + + @Column({ type: "jsonb", name: "client_action" }) + clientAction?: Record; @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 diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 7cd453f4c..dda0e4af8 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -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(` + + + + Redirecting... + + +

Redirecting...

+ + + + + `); + } + + @Post("web-hooks/telebirr") paymentWebhook() { diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 120effaf7..b20c331b3 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -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 { } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts new file mode 100644 index 000000000..229ea7b58 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -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; + constructor(private readonly dataSource: DataSource) { + this.paymentRepo = this.dataSource.getRepository(PaymentEntity) + } + + async createTr(qr: QueryRunner, data: Pick): Promise { + const payment = qr.manager.create(PaymentEntity, data) + return qr.manager.save(payment) + } + + findOneBy(options: FindOptionsWhere | FindOptionsWhere[]): Promise { + return this.paymentRepo.findOneBy(options); + } + + update(where: FindOptionsWhere, data: QueryDeepPartialEntity) { + 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(); + } + + +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1dab7ffd7..f02d2502c 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -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; - private readonly paymentRepo: Repository; 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): Promise { - 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): Promise { - return this.paymentRepo.findOneBy(data) + + async getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]): Promise { + return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method) } diff --git a/apps/edr-freight-api/src/modules/payment/strategies/payment.strategy.ts b/apps/edr-freight-api/src/modules/payment/strategies/payment.strategy.ts index a74fe9782..b1daa2770 100644 --- a/apps/edr-freight-api/src/modules/payment/strategies/payment.strategy.ts +++ b/apps/edr-freight-api/src/modules/payment/strategies/payment.strategy.ts @@ -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 + abstract pay(data: ProviderInitiationInput): Promise } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts b/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts index 521b6ee9b..3fa5fdc75 100644 --- a/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts +++ b/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts @@ -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 { - const refId = randomUUID() - const resp = await this.initiate({ - amountMinor: amount, - bookingRef: refId, - currency: "etb", - merchantOrderId: "", - platform: "web" - }) + async pay(data: ProviderInitiationInput): Promise { + // 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 { + console.log(this.baseUrl, "base url") const response = await this.postJson( `${this.baseUrl}/payment/v1/token`, { appSecret: this.appSecret }, diff --git a/apps/edr-freight-api/src/modules/payment/strategies/payments.types.ts b/apps/edr-freight-api/src/modules/payment/strategies/payments.types.ts index bf803c3e2..dae192db9 100644 --- a/apps/edr-freight-api/src/modules/payment/strategies/payments.types.ts +++ b/apps/edr-freight-api/src/modules/payment/strategies/payments.types.ts @@ -11,7 +11,7 @@ export type ClientAction = export interface ProviderInitiationInput { merchantOrderId: string; - bookingRef: string; + // bookingRef: string; amountMinor: number; currency: string; platform?: PaymentPlatform;