This commit is contained in:
Tria
2026-06-05 15:56:29 +03:00
parent 02018c339d
commit 21bc6948e8
8 changed files with 322 additions and 28 deletions

View File

@@ -1,19 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddFanNumberToCompanies1749300000000 implements MigrationInterface {
name = 'AddFanNumberToCompanies1749300000000';
// export class AddFanNumberToCompanies1749300000000 implements MigrationInterface {
// name = 'AddFanNumberToCompanies1749300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN fan_number varchar(16) NULL;
`);
}
// public async up(queryRunner: QueryRunner): Promise<void> {
// await queryRunner.query(`
// ALTER TABLE freight.companies
// ADD COLUMN fan_number varchar(16) NULL;
// `);
// }
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN fan_number;
`);
}
}
// public async down(queryRunner: QueryRunner): Promise<void> {
// await queryRunner.query(`
// ALTER TABLE freight.companies
// DROP COLUMN fan_number;
// `);
// }
// }

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddReasonToPayment1780662035273 implements MigrationInterface {
name = "AddReasonToPayment1780662035273";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ADD COLUMN reason varchar(255)
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ADD CONSTRAINT UQ_payments_reason UNIQUE (reason)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
DROP CONSTRAINT UQ_payments_reason
`);
await queryRunner.query(`
ALTER TABLE freight.payments
DROP COLUMN reason
`);
}
}

View File

@@ -26,6 +26,9 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: "numeric" })
amount!: number
@Column({ type: "varchar", length: 255, unique: true, name: "reason", })
reason?: string;
@Column({ type: "jsonb", default: {}, name: "raw_initiation" })
rawInitiation?: Record<string, unknown>

View File

@@ -1,18 +1,28 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } 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) { }
@Get("/receipts/:orderId/html")
async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
const filled = await this.paymentService.genReceiptHtml(orderId);
return res.send(filled)
}
@Post("/initiate")
async initiatePayment() {
//Only for testing..
const data = await this.paymentService.pay(20, "ETB", "telebirr", (_) => {
const data = await this.paymentService.pay("http://localhost:3004/payment", 20, "ETB", "telebirr", "booking", (_) => {
return new Promise((resp, _) => {
resp({
id: randomUUID(),

View File

@@ -9,7 +9,7 @@ export class PaymentRepository {
this.paymentRepo = this.dataSource.getRepository(PaymentEntity)
}
async createTr(qr: QueryRunner, data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt">): Promise<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)
}
@@ -35,5 +35,5 @@ export class PaymentRepository {
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { DataSource, QueryRunner } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentStrategy } from "./strategies/payment.strategy";
@@ -8,6 +8,11 @@ import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
import * as crypto from 'crypto';
import { PaymentStatus, UpdatePaymentStatusDto } from "./dto/update-payment-status.dto";
// import * as fs from 'fs';
// import * as path from 'path';
// import * as Handlebars from 'handlebars';
type PaymentMethod = PaymentEntity["method"]
type CurrencyType = PaymentEntity["currency"]
@@ -24,7 +29,7 @@ export class PaymentService {
])
}
async pay(amount: number, currency: CurrencyType, method: PaymentMethod, cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{
async pay(redirectBaseURL: string, amount: number, currency: CurrencyType, method: PaymentMethod, reason: string, cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{
refId: string,
clientAction: ClientAction,
status: PaymentEntity["status"],
@@ -33,6 +38,7 @@ export class PaymentService {
failureMessage?: string,
}> {
const strategy = this.strategies.get(method)
if (!strategy) {
throw new NotFoundException("strategy not found")
@@ -40,6 +46,7 @@ export class PaymentService {
const orderId = `freigh${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic
const paymentResp = await strategy.pay({
redirectBaseURL,
amountMinor: amount,
currency: currency,
merchantOrderId: orderId,
@@ -62,7 +69,8 @@ export class PaymentService {
merchantOrderId: orderId,
rawInitiation: paymentResp.rawInitiation,
clientAction: paymentResp.clientAction,
expiresAt: paymentResp.expiresAt
expiresAt: paymentResp.expiresAt,
reason
})
await queryRunner.commitTransaction()
@@ -93,6 +101,7 @@ export class PaymentService {
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 })
@@ -110,4 +119,241 @@ export class PaymentService {
}
}
getReceiptTemplate(data: {
vendorName: string;
vendorAddress: string;
receiptDate: string;
paymentMethod: string;
subtotal?: string;
total?: string;
currency: string;
reason?: string;
}) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt - ${data.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>${data.vendorName}</h1>
<p>${data.vendorAddress}</p>
</div>
<table class="details-table">
<tr>
<td class="label">Date</td>
<td class="value">${data.receiptDate}</td>
</tr>
<tr>
<td class="label">Payment Method</td>
<td class="value" style="text-transform: capitalize;">
${data.paymentMethod}
</td>
</tr>
<tr>
<td class="label">Description</td>
<td class="value">${data.reason ?? "-"}</td>
</tr>
</table>
<div class="totals-section">
<table class="details-table">
<tr>
<td class="label">Subtotal</td>
<td class="value">
${data.currency} ${data.subtotal ?? "0.00"}
</td>
</tr>
<tr class="total-row">
<td class="label" style="color:#333">
Total Paid
</td>
<td class="value" style="color:#0056b3">
${data.currency} ${data.total ?? "0.00"}
</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>
`;
}
async genReceiptHtml(orderId: string) {
const payment = await this.paymentRepo.findOneBy({
merchantOrderId: orderId,
status: "success"
})
if (!payment) {
throw new BadRequestException()
}
// const templatePath = path.join(
// process.cwd(),
// 'src/modules/payment/templates/receipt.hbs',
// );
// console.log(templatePath)
// const source = fs.readFileSync(templatePath, 'utf8');
// const template = Handlebars.compile(source);
return this.getReceiptTemplate({
vendorName: "Ethio Djibouti Railway Ticket Booking",
vendorAddress: "Addis Ababa",
receiptDate: new Date().toLocaleDateString(),
paymentMethod: payment?.method,
subtotal: payment?.amount.toString(),
total: payment?.amount.toString(),
currency: payment?.currency,
reason: payment?.reason
});
}
}

View File

@@ -22,7 +22,8 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
async pay(data: ProviderInitiationInput): Promise<any> {
// const refId = randomUUID()
// const orderId = createMerchantOrderId()
const resp = await this.initiate(data)
const redirectURL = `${data.redirectBaseURL}/${data.merchantOrderId}`
const resp = await this.initiate(redirectURL, data)
return resp;
}
@@ -44,9 +45,9 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
});
}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
async initiate(redirectURL: string, input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildCreateOrderRequest(input);
const requestBody = this.buildCreateOrderRequest(redirectURL, input);
const response = await this.requestCreateOrder(fabricToken, requestBody);
const prepayId = response.biz_content?.prepay_id;
@@ -176,8 +177,9 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
);
}
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
const totalAmount = String(input.amountMinor / 100);
private buildCreateOrderRequest(redirectURL: string, input: ProviderInitiationInput): CreateOrderRequest {
// const totalAmount = String(input.amountMinor / 100);
const totalAmount = String(input.amountMinor)
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
@@ -186,6 +188,7 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
biz_content: {
notify_url: this.notifyUrl,
appid: this.merchantAppId,
redirect_url: redirectURL,
merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const,

View File

@@ -10,6 +10,7 @@ export type ClientAction =
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
export interface ProviderInitiationInput {
redirectBaseURL: string;
merchantOrderId: string;
// bookingRef: string;
amountMinor: number;