mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 23:40:56 +00:00
receipt
This commit is contained in:
@@ -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;
|
||||
// `);
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user