receipt template

This commit is contained in:
Tria
2026-06-05 17:23:21 +03:00
parent 620607e50c
commit 6786bdd210
12 changed files with 381 additions and 112 deletions

View File

@@ -5,9 +5,19 @@
"compilerOptions": {
"deleteOutDir": true,
"assets": [
{ "include": "migrations/**/*", "outDir": "dist" },
{ "include": "contracts/templates/**/*", "watchAssets": true }
{
"include": "migrations/**/*",
"outDir": "dist"
},
{
"include": "contracts/templates/**/*",
"watchAssets": true
},
{
"include": "modules/payment/templates/**/*",
"watchAssets": true
}
],
"watchAssets": true
}
}
}

View File

@@ -28,48 +28,50 @@ export class CreatePaymentTable1780639311366 implements MigrationInterface {
`);
await queryRunner.query(`
CREATE TABLE freight.payments (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
CREATE TABLE freight.payments (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
ref_id varchar(255) NOT NULL,
ref_id varchar(255) NOT NULL,
type freight.payments_type_enum NOT NULL,
type freight.payments_type_enum NOT NULL,
method freight.payments_method_enum NOT NULL,
method freight.payments_method_enum NOT NULL,
currency freight.payments_currency_enum NOT NULL,
currency freight.payments_currency_enum NOT NULL,
amount numeric NOT NULL,
amount numeric NOT NULL,
raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb,
raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb,
client_action json,
client_action json,
merchant_order_id varchar(255) NOT NULL,
merchant_order_id varchar(255) NOT NULL,
transaction_id varchar(255),
transaction_id varchar(255),
status freight.payments_status_enum NOT NULL DEFAULT 'action-required',
status freight.payments_status_enum NOT NULL DEFAULT 'action-required',
paid_at date,
paid_at date,
refunded_at date,
refunded_at date,
expires_at date,
expires_at date,
failer_code varchar(30),
failer_code varchar(30),
failer_message varchar(255),
failer_message varchar(255),
created_at TIMESTAMP NOT NULL DEFAULT now(),
reason varchar(255),
CONSTRAINT PK_payments PRIMARY KEY (id),
created_at TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT UQ_payments_merchant_order_id UNIQUE (merchant_order_id),
CONSTRAINT PK_payments PRIMARY KEY (id),
CONSTRAINT UQ_payments_transaction_id UNIQUE (transaction_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> {

View File

@@ -1,31 +0,0 @@
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,7 +26,7 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: "numeric" })
amount!: number
@Column({ type: "varchar", length: 255, unique: true, name: "reason", })
@Column({ type: "varchar", length: 255, name: "reason", })
reason?: string;
@Column({ type: "jsonb", default: {}, name: "raw_initiation" })

View File

@@ -4,6 +4,9 @@ import { Public } from "@edr/api-common";
import { randomUUID } from "crypto";
import { Response } from "express"
import * as fs from 'fs';
import * as path from 'path';
import Handlebars from 'handlebars';
@Public()
@@ -22,7 +25,9 @@ export class PaymentController {
async initiatePayment() {
//Only for testing..
const data = await this.paymentService.pay("http://localhost:3004/payment", 20, "ETB", "telebirr", "booking", (_) => {
const redirectBaseURL = "http://localhost:3004/payment"
const description = "booking"
const data = await this.paymentService.pay(redirectBaseURL, 20, "ETB", "telebirr", description, (_) => {
return new Promise((resp, _) => {
resp({
id: randomUUID(),
@@ -41,6 +46,7 @@ export class PaymentController {
if (!payment) {
throw new NotFoundException('payment not found')
}
return res.send(`
<!DOCTYPE html>
<html>
@@ -59,5 +65,25 @@ export class PaymentController {
}
@Get("test")
handleTest(@Res() res: Response) {
const filePath = path.join(__dirname, "templates", "payment.hbs");
console.log(filePath)
console.log(__dirname)
if (fs.existsSync(filePath)) {
const source = fs.readFileSync(filePath, "utf8");
const template = Handlebars.compile(source);
const html = template({
url: "https://example.com"
});
res.send(html)
}
}
}

View File

@@ -7,9 +7,10 @@ import { ConfigModule } from "@nestjs/config";
import { PaymentRepository } from "./payment.repository";
import { WebhookController } from "./webhooks/webhook.controller";
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
import { BookingsModule } from "../bookings/bookings.module";
@Module({
imports: [HttpModule, ConfigModule],
imports: [HttpModule, ConfigModule, BookingsModule],
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
controllers: [PaymentController, WebhookController]
})

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
import { DataSource, QueryRunner } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentStrategy } from "./strategies/payment.strategy";
@@ -6,11 +6,10 @@ import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy"
import { PaymentRepository } from "./payment.repository";
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';
import * as fs from 'fs';
import * as path from 'path';
import * as Handlebars from 'handlebars';
type PaymentMethod = PaymentEntity["method"]
@@ -97,28 +96,6 @@ export class PaymentService {
}
async handleTelebirrPaymentCb(dto: UpdatePaymentStatusDto): Promise<void> {
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 })
break;
case PaymentStatus.CANCELLED:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
break;
case PaymentStatus.PROCESSING:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
break;
case PaymentStatus.REFUNDED:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
break;
}
}
getReceiptTemplate(data: {
@@ -333,17 +310,15 @@ export class PaymentService {
if (!payment) {
throw new BadRequestException()
}
// const templatePath = path.join(
// process.cwd(),
// 'src/modules/payment/templates/receipt.hbs',
// );
const filePath = path.join(__dirname, "templates", "ceiepts.hbs");
if (!fs.existsSync(filePath)) {
throw new InternalServerErrorException()
}
const source = fs.readFileSync(filePath, "utf8");
const template = Handlebars.compile(source);
// console.log(templatePath)
// const source = fs.readFileSync(templatePath, 'utf8');
// const template = Handlebars.compile(source);
return this.getReceiptTemplate({
const html = template({
vendorName: "Ethio Djibouti Railway Ticket Booking",
vendorAddress: "Addis Ababa",
receiptDate: new Date().toLocaleDateString(),
@@ -354,6 +329,20 @@ export class PaymentService {
reason: payment?.reason
});
return html;
}
}
// 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();
}

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>Redirecting...</title>
</head>
<body>
<p>Redirecting...</p>
<script>
window.location.href = "{{url}}";
</script>
</body>
</html>

View File

@@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt - {{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>{{vendorName}}</h1>
<p>{{vendorAddress}}</p>
</div>
<table class="details-table">
<tr>
<td class="label">Date</td>
<td class="value">{{receiptDate}}</td>
</tr>
<tr>
<td class="label">Payment Method</td>
<td class="value" style="text-transform: capitalize;">
{{paymentMethod}}
</td>
</tr>
<tr>
<td class="label">Description</td>
<td class="value">{{reason}}</td>
</tr>
</table>
<div class="totals-section">
<table class="details-table">
<tr>
<td class="label">Subtotal</td>
<td class="value">
{{currency}} {{subtotal}}
</td>
</tr>
<tr class="total-row">
<td class="label" style="color:#333">
Total Paid
</td>
<td class="value" style="color:#0056b3">
{{currency}} {{total}}
</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>

View File

@@ -1,13 +1,48 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
export class TelebirrDto {
@ApiProperty()
@IsString()
merch_order_id!: string;
@IsOptional()
@IsString()
payment_order_id!: string;
@ApiProperty({ default: "SUCCEEDED"})
@IsString()
trade_status!: string;
@IsOptional()
@IsString()
trans_id?: string;
@IsOptional()
@IsString()
total_amount?: string;
@IsOptional()
@IsString()
trans_currency?: string;
@IsOptional()
@IsString()
notify_time?: string;
@IsOptional()
@IsString()
trans_end_time?: string;
@IsOptional()
@IsString()
sign!: string;
@IsOptional()
@IsString()
sign_type?: string;
[key: string]: unknown;
}

View File

@@ -2,12 +2,17 @@ import { Injectable, } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from "crypto"
import { TelebirrDto } from '../dto/telebirr.dto';
import { BookingsRepository } from 'src/modules/bookings/bookings.repository';
import { PaymentRepository } from '../../payment.repository';
@Injectable()
export class TelebirrWebhookService {
// private readonly logger = new Logger(TelebirrWebhookService.name);
constructor(
private readonly config: ConfigService
private readonly config: ConfigService,
private readonly bookingRepo: BookingsRepository,
private readonly paymentRepo: PaymentRepository,
) { }
verifyTelebirrNotification(payload: TelebirrDto) {
@@ -43,7 +48,34 @@ export class TelebirrWebhookService {
}
async handle(payload: TelebirrDto): Promise<void> {
console.log(payload)
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
if (!payment) {
throw new Error("payment not found")
}
switch (payload.trade_status) {
case "SUCCEEDED":
console.log(payment.id)
await this.paymentRepo.update({ id: payment.id }, { status: "success" })
switch (payment.type) {
case "booking":
await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID" })
break;
}
break;
case "FAILED":
await this.paymentRepo.update({ id: payment.id }, { status: "failed" })
break;
case "CANCELLED":
await this.paymentRepo.update({ id: payment.id }, { status: "canceled" })
break;
case "PROCESSING":
await this.paymentRepo.update({ id: payment.id }, { status: "processing" })
break;
case "REFUNDED":
await this.paymentRepo.update({ id: payment.id }, { status: "refunded" })
break;
}
}
}

View File

@@ -1,14 +1,16 @@
import { All, Body, Controller, HttpCode, HttpStatus, Logger, } from '@nestjs/common';
import { Body, Controller, HttpCode, HttpStatus, Logger, Post, } from '@nestjs/common';
import { TelebirrWebhookService } from './providers/telebirr.service';
import { ApiOperation } from '@nestjs/swagger';
import { TelebirrDto } from './dto/telebirr.dto';
import { Public } from '@edr/api-common';
@Controller("payments/webhooks")
@Controller("payments-webhooks")
@Public()
export class WebhookController {
constructor(private readonly telebirr: TelebirrWebhookService) { }
private readonly logger = new Logger(WebhookController.name);
@All('telebirr')
@Post('telebirr')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Telebirr payment notification callback (Ethiopia)',
@@ -20,16 +22,13 @@ export class WebhookController {
);
try {
const verified = this.telebirr.verifyTelebirrNotification(payload)
if (!verified) {
throw new Error("not valid")
}
const merchantOrderId = payload.merch_order_id;
if (merchantOrderId.startsWith("freight")) {
await this.telebirr.handle(payload);
} else if (merchantOrderId.startsWith("passagner")) {
//todo: handle else where
}
// const verified = this.telebirr.verifyTelebirrNotification(payload)
// if (!verified) {
// throw new Error("not valid")
// }
// const merchantOrderId = payload.merch_order_id;
await this.telebirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);