Merge pull request #108 from Tria-plc/freight/feature/payment

providers
This commit is contained in:
Eyosiyas Girma
2026-06-06 11:45:19 +03:00
committed by GitHub
8 changed files with 107 additions and 79 deletions

View File

@@ -3,45 +3,57 @@ import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util'; import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { PaymentService } from '../payment/payment.service';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {} export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
@Injectable() @Injectable()
export class BookingPaymentService { export class BookingPaymentService {
constructor(private readonly bookingsRepository: BookingsRepository) {} constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { }
async pay( async pay(
bookingId: string, bookingId: string,
): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> { ): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId); const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']); assertBookingStatus(booking, ['FULLY_EXECUTED']);
const receipt = this.buildMockReceipt(booking); // const receipt = this.buildMockReceipt(booking);
const updated = await this.bookingsRepository.update(bookingId, { // const updated = await this.bookingsRepository.update(bookingId, {
status: 'PAID', // status: 'PAID',
paymentStatus: 'PAID', // paymentStatus: 'PAID',
} as never); // } as never);
const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => {
return { booking: updated!, receipt }; return new Promise((resp, _) => {
} resp({
id: booking.id,
private buildMockReceipt(booking: Booking): InAppPaymentReceipt { type: "booking"
const timestamp = Date.now(); })
const isEtb = booking.paymentCurrency === 'ETB'; });
const prefix = isEtb ? 'TB' : 'CARD'; })
const provider = isEtb ? 'TELEBIRR' : 'CARD';
return { return {
success: true, redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : ""
provider, }
providerRef: `${prefix}-${booking.reference}-${timestamp}`,
amount: booking.totalAmount,
currency: booking.paymentCurrency,
paidAt: new Date().toISOString(),
};
} }
// private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
// const timestamp = Date.now();
// const isEtb = booking.paymentCurrency === 'ETB';
// const prefix = isEtb ? 'TB' : 'CARD';
// const provider = isEtb ? 'TELEBIRR' : 'CARD';
// return {
// success: true,
// provider,
// providerRef: `${prefix}-${booking.reference}-${timestamp}`,
// amount: booking.totalAmount,
// currency: booking.paymentCurrency,
// paidAt: new Date().toISOString(),
// };
// }
private async requireBooking(id: string): Promise<Booking> { private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id); const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`); if (!booking) throw new NotFoundException(`Booking ${id} not found`);

View File

@@ -28,6 +28,7 @@ import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing
import { ContractRendererService } from '../../contracts/contract-renderer.service'; import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { PaymentModule } from '../payment/payment.module';
@Module({ @Module({
imports: [ imports: [
@@ -40,6 +41,7 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
BookingReviewNote, BookingReviewNote,
BookingContractSignature, BookingContractSignature,
]), ]),
PaymentModule,
FilesModule, FilesModule,
MinioModule, MinioModule,
CompaniesModule, CompaniesModule,

View File

@@ -2,10 +2,10 @@ import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingPaymentService } from './booking-payment.service'; import { BookingPaymentService } from './booking-payment.service';
import { BookingTransitionService } from './booking-transition.service'; // import { BookingTransitionService } from './booking-transition.service';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; // import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { Booking } from './entities/booking.entity'; // import { Booking } from './entities/booking.entity';
import { BookingNextStep } from './booking-next-step.util'; // import { BookingNextStep } from './booking-next-step.util';
@ApiTags('payments') @ApiTags('payments')
@ApiBearerAuth() @ApiBearerAuth()
@@ -13,22 +13,15 @@ import { BookingNextStep } from './booking-next-step.util';
export class PayController { export class PayController {
constructor( constructor(
private readonly paymentService: BookingPaymentService, private readonly paymentService: BookingPaymentService,
private readonly transitionService: BookingTransitionService, // private readonly transitionService: BookingTransitionService,
) {} ) { }
@Post(':id/payment/pay') @Post(':id/payment/pay')
@ApiOperation({ summary: 'Complete in-app payment (mock)' }) @ApiOperation({ summary: 'Complete in-app payment (mock)' })
@ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' }) @ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' })
async pay(@Param('id', ParseUUIDPipe) id: string): Promise< async pay(@Param('id', ParseUUIDPipe) id: string) {
Booking & { return await this.paymentService.pay(id);
latestChangeRequestNote?: string | null; // const abstract = await this.transitionService.enrichBookingResponse(booking);
contractSummary?: string | null; // return { ...abstract, paymentReceipt: receipt };
nextStep: BookingNextStep | null;
paymentReceipt: InAppPaymentReceiptDto;
}
> {
const { booking, receipt } = await this.paymentService.pay(id);
const abstract = await this.transitionService.enrichBookingResponse(booking);
return { ...abstract, paymentReceipt: receipt };
} }
} }

View File

@@ -1,36 +1,42 @@
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 { PaymentService } from "./payment.service";
import { Public } from "@edr/api-common"; import { Public } from "@edr/api-common";
import { randomUUID } from "crypto"; // import { randomUUID } from "crypto";
import { Response } from "express" import { Response } from "express"
@Public() @Public()
@Controller("payments") @Controller("payments")
export class PaymentController { export class PaymentController {
constructor(private readonly paymentService: PaymentService) { } constructor(private readonly paymentService: PaymentService,) { }
@Get("/receipts/:orderId/html") // @Get("/receipts/:orderId/html")
async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) { // async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
const filled = await this.paymentService.genReceiptHtml(orderId); // const filled = await this.paymentService.genReceiptHtml(orderId);
return res.send(filled) // return res.send(filled)
} // }
@Post("/initiate/booking") // @Post("/initiate/booking")
async initiatePayment() { // async initiatePayment() {
//Only for testing.. // //Only for testing..
const description = "booking" // const description = "Booking for contact"
const data = await this.paymentService.pay(20, "ETB", "telebirr", description, "booking", (_) => { // const price = 2000
return new Promise((resp, _) => { // const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => {
resp({ // return new Promise((resp, _) => {
id: randomUUID(), // resp({
type: "booking" // id: randomUUID(),
}) // type: "booking"
}); // })
}) // });
// })
return data // return data
// }
@Post("/bookings/check-payment/:orderId")
checkPayment(@Param("orderId", ParseUUIDPipe) orderId: string) {
return this.paymentService.checkStatusAndUpdate(orderId)
} }

View File

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

View File

@@ -10,6 +10,8 @@ import * as crypto from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as Handlebars from 'handlebars'; import * as Handlebars from 'handlebars';
import { ConfigService } from "@nestjs/config";
import { Booking } from "../bookings/entities/booking.entity";
type PaymentMethod = PaymentEntity["method"] type PaymentMethod = PaymentEntity["method"]
@@ -20,6 +22,7 @@ export class PaymentService {
private strategies: Map<PaymentMethod, PaymentStrategy>; private strategies: Map<PaymentMethod, PaymentStrategy>;
constructor( constructor(
private readonly configService: ConfigService,
private readonly datasource: DataSource, private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository, private readonly paymentRepo: PaymentRepository,
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) { private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) {
@@ -47,7 +50,8 @@ export class PaymentService {
let redirectUrl: string; let redirectUrl: string;
switch (type) { switch (type) {
case "booking": case "booking":
redirectUrl = `http://localhost:3001/api/payments/receipts/${orderId}/html` const url = this.configService.get<string>("TELEBIRR_SUCCESS_REDIRECT_BASE_URL")
redirectUrl = `${url}/check-status/${orderId}`
break; break;
} }
@@ -132,17 +136,27 @@ export class PaymentService {
return html; return html;
} }
// const templatePath = path.join( async checkStatusAndUpdate(orderId: string) {
// process.cwd(), const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId })
// 'src/modules/payment/templates/receipt.hbs', if (!resp) {
// ); throw new NotFoundException("order id not found")
}
const result = await this.telebirrPaymentStategy.queryStatus(resp.merchantOrderId)
const bizContent = result.rawResponse.biz_content as {
order_status: string;
};
const ordersStatus = bizContent.order_status
// console.log(templatePath) if (ordersStatus == "PAY_SUCCESS") {
await this.datasource.transaction(async (mg) => {
// const source = fs.readFileSync(templatePath, 'utf8'); await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
// const template = Handlebars.compile(source); await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
// return this.getReceiptTemplate(); })
}
return {
status: result.status
}
}
} }

View File

@@ -301,5 +301,4 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
return this.config.get<string>('telebirr.publicKey') ?? ''; return this.config.get<string>('telebirr.publicKey') ?? '';
} }
} }

View File

@@ -2,15 +2,16 @@ import { Injectable, } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as crypto from "crypto" import * as crypto from "crypto"
import { TelebirrDto } from '../dto/telebirr.dto'; import { TelebirrDto } from '../dto/telebirr.dto';
import { BookingsRepository } from 'src/modules/bookings/bookings.repository';
import { PaymentRepository } from '../../payment.repository'; import { PaymentRepository } from '../../payment.repository';
import { DataSource } from 'typeorm';
import { Booking } from 'src/modules/bookings/entities/booking.entity';
@Injectable() @Injectable()
export class TelebirrWebhookService { export class TelebirrWebhookService {
// private readonly logger = new Logger(TelebirrWebhookService.name); // private readonly logger = new Logger(TelebirrWebhookService.name);
constructor( constructor(
private readonly datasource: DataSource,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly bookingRepo: BookingsRepository,
private readonly paymentRepo: PaymentRepository, private readonly paymentRepo: PaymentRepository,
) { } ) { }
@@ -57,7 +58,8 @@ export class TelebirrWebhookService {
await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() }) await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() })
switch (payment.type) { switch (payment.type) {
case "booking": case "booking":
await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", }) await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", })
// await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", })
break; break;
} }
break; break;