mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
telebirr configured
This commit is contained in:
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -11,7 +11,7 @@ export type ClientAction =
|
||||
|
||||
export interface ProviderInitiationInput {
|
||||
merchantOrderId: string;
|
||||
bookingRef: string;
|
||||
// bookingRef: string;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
platform?: PaymentPlatform;
|
||||
|
||||
Reference in New Issue
Block a user