mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
payments
This commit is contained in:
@@ -4,146 +4,323 @@ import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as Handlebars from "handlebars";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
// import { SchedulingStatus } from "@edr/types";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
|
||||
import {
|
||||
ClientAction,
|
||||
createMerchantOrderId,
|
||||
ProviderPaymentStatus,
|
||||
TelebirrProvider,
|
||||
} from "@edr/payment-providers";
|
||||
import { ProviderInitiationInput } from "@edr/types"
|
||||
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
|
||||
import {
|
||||
PaymentService as PaymentServiceEnum,
|
||||
PaymentReferenceType,
|
||||
PaymentIntentSnapshot,
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
|
||||
const DEFAULT_CURRENCY = "ETB";
|
||||
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
"processing": ProviderPaymentStatus.PROCESSING,
|
||||
"success": ProviderPaymentStatus.SUCCEEDED,
|
||||
"failed": ProviderPaymentStatus.FAILED,
|
||||
"canceled": ProviderPaymentStatus.CANCELLED,
|
||||
"refunded": ProviderPaymentStatus.CANCELLED,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PaymentService {
|
||||
private readonly logger = new Logger(PaymentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) { }
|
||||
|
||||
async initBookingTelebirr(
|
||||
bookingId: string,
|
||||
platform: PaymentPlatformDto,
|
||||
): Promise<{ redirectUrl: string }> {
|
||||
// const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId });
|
||||
// if (!booking) throw new NotFoundException("Booking not found");
|
||||
async getAll(filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
method?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
// const booking = new Booking()
|
||||
// booking.totalAmount = 20
|
||||
// booking.id = randomUUID
|
||||
const amount = 20
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const redirectBase = this.configService.get<string>("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL");
|
||||
const redirectUrl = `${redirectBase}/${merchantOrderId}`;
|
||||
const amountMinor = Math.round(Number(amount) * 100);
|
||||
const qb = this.paymentRepo.createQueryBuilder("payment");
|
||||
|
||||
const input: ProviderInitiationInput = {
|
||||
merchantOrderId,
|
||||
orderRef: bookingId,
|
||||
if (search) {
|
||||
qb.andWhere(
|
||||
"(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)",
|
||||
{ search: `%${search}%` },
|
||||
);
|
||||
}
|
||||
if (status) {
|
||||
qb.andWhere("payment.status = :status", { status });
|
||||
}
|
||||
if (method) {
|
||||
qb.andWhere("payment.method = :method", { method });
|
||||
}
|
||||
|
||||
const [items, total] = await qb
|
||||
.orderBy("payment.createdAt", "DESC")
|
||||
.skip(skip)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((p) => ({
|
||||
id: p.id,
|
||||
bookingId: p.refId,
|
||||
amount: p.amount,
|
||||
currency: p.currency,
|
||||
method: p.method,
|
||||
status: p.status,
|
||||
merchantOrderId: p.merchantOrderId,
|
||||
paidAt: p.paidAt,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: dto.bookingId });
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
console.log("bookingbooking",booking)
|
||||
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
|
||||
console.log("amountminor",amountMinor)
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: booking.id,
|
||||
orderRef: booking.reference,
|
||||
amountMinor,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
platform: platform || "web",
|
||||
redirectUrl,
|
||||
currency: booking.paymentCurrency,
|
||||
provider: dto.method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
|
||||
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
|
||||
});
|
||||
|
||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
private async syncIntentProjection(
|
||||
bookingId: string,
|
||||
booking: Booking,
|
||||
snapshot: PaymentIntentSnapshot,
|
||||
): Promise<PaymentEntity> {
|
||||
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
||||
|
||||
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
TELEBIRR: "telebirr",
|
||||
CBE_BIRR: "cbe-birr",
|
||||
EBIRR: "ebirr",
|
||||
WAAFI: "waafi",
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
};
|
||||
const method: PaymentEntity["method"] =
|
||||
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
||||
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? "processing"
|
||||
: this.toLocalStatus(snapshot.status);
|
||||
|
||||
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
|
||||
const data = {
|
||||
status,
|
||||
method,
|
||||
merchantOrderId: snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "",
|
||||
transactionId: snapshot.providerTxnId ?? existing?.transactionId,
|
||||
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : existing?.expiresAt,
|
||||
failerCode: snapshot.failureCode ?? undefined,
|
||||
failureMessage: snapshot.failureMessage ?? undefined,
|
||||
};
|
||||
|
||||
const result = await this.telebirrProvider.initiate(input);
|
||||
if (existing) {
|
||||
await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any);
|
||||
return { ...existing, ...data, clientAction } as PaymentEntity;
|
||||
}
|
||||
|
||||
const payment = await this.paymentRepo.create({
|
||||
amount: amount,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
method: "telebirr",
|
||||
return this.paymentRepo.create({
|
||||
refId: bookingId,
|
||||
type: "booking",
|
||||
merchantOrderId,
|
||||
rawInitiation: result.rawInitiation,
|
||||
clientAction: result.clientAction as Record<string, unknown>,
|
||||
expiresAt: result.expiresAt,
|
||||
reason: `Payment for booking`,
|
||||
});
|
||||
|
||||
return {
|
||||
redirectUrl: `${this.configService.get<string>("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}`
|
||||
}
|
||||
amount: booking.totalAmount,
|
||||
currency: booking.paymentCurrency,
|
||||
reason: `Payment for booking ${booking.reference}`,
|
||||
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
||||
...data,
|
||||
} as any);
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
||||
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
bookingId,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.formatIntentStatus(local);
|
||||
}
|
||||
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: bookingId });
|
||||
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: intent.id });
|
||||
return this.formatIntentStatus(refreshed ?? intent);
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
||||
if (!intent || intent.status !== "success") {
|
||||
throw new BadRequestException("No successful payment to refund");
|
||||
}
|
||||
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
||||
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
||||
});
|
||||
|
||||
return { refunded: true, bookingId: dto.bookingId };
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
bookingId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === "success") return { alreadyFinalized: true };
|
||||
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
PaymentEntity,
|
||||
{ id: intent.id },
|
||||
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
||||
);
|
||||
await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"});
|
||||
});
|
||||
|
||||
try {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
async markPaymentFailed(input: {
|
||||
intentId: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<void> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === "success" || intent.status === "canceled") return;
|
||||
|
||||
await this.paymentRepo.update(
|
||||
{ id: intent.id },
|
||||
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
|
||||
);
|
||||
}
|
||||
|
||||
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method)
|
||||
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method);
|
||||
}
|
||||
|
||||
|
||||
async genReceiptHtml(orderId: string) {
|
||||
const payment = await this.paymentRepo.findOneBy({
|
||||
merchantOrderId: orderId,
|
||||
status: "success"
|
||||
})
|
||||
if (!payment) {
|
||||
throw new BadRequestException()
|
||||
}
|
||||
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: orderId, status: "success" });
|
||||
if (!payment) throw new BadRequestException("No successful payment found for this order");
|
||||
|
||||
const filePath = path.join(__dirname, "templates", "receipt.hbs");
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new InternalServerErrorException()
|
||||
}
|
||||
if (!fs.existsSync(filePath)) throw new InternalServerErrorException();
|
||||
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const template = Handlebars.compile(source);
|
||||
|
||||
const html = template({
|
||||
vendorName: "Ethio Djibouti Railway Ticket Booking",
|
||||
return template({
|
||||
vendorName: "Ethio Djibouti Railway Freight Booking",
|
||||
vendorAddress: "Addis Ababa",
|
||||
receiptDate: payment.paidAt,
|
||||
paymentMethod: payment?.method,
|
||||
subtotal: payment?.amount.toString(),
|
||||
total: payment?.amount.toString(),
|
||||
currency: payment?.currency,
|
||||
reason: payment?.reason
|
||||
paymentMethod: payment.method,
|
||||
subtotal: payment.amount.toString(),
|
||||
total: payment.amount.toString(),
|
||||
currency: payment.currency,
|
||||
reason: payment.reason,
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
async checkStatusAndUpdate(orderId: string) {
|
||||
const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId })
|
||||
if (!resp) {
|
||||
throw new NotFoundException("order id not found")
|
||||
}
|
||||
const result = await this.telebirrProvider.queryStatus(resp.merchantOrderId)
|
||||
|
||||
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
|
||||
await mg.update(Booking, { id: resp.refId }, { paymentStatus: "PAID" })
|
||||
})
|
||||
if (resp.type === "booking") {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(resp.refId)
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: result.status
|
||||
}
|
||||
}
|
||||
|
||||
findBookingById(id: string) {
|
||||
return this.paymentRepo.findOneBy({ refId: id, type: "booking" })
|
||||
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
|
||||
}
|
||||
|
||||
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
||||
@@ -151,19 +328,70 @@ export class PaymentService {
|
||||
intent.clientAction && typeof intent.clientAction === "object"
|
||||
? (intent.clientAction as unknown as ClientAction)
|
||||
: undefined;
|
||||
const statusMap: Record<string, ProviderPaymentStatus> = {
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
"processing": ProviderPaymentStatus.PROCESSING,
|
||||
"success": ProviderPaymentStatus.SUCCEEDED,
|
||||
"failed": ProviderPaymentStatus.FAILED,
|
||||
"canceled": ProviderPaymentStatus.CANCELLED,
|
||||
"refunded": ProviderPaymentStatus.CANCELLED,
|
||||
};
|
||||
return {
|
||||
intentId: intent.id,
|
||||
status: statusMap[intent.status] ?? ProviderPaymentStatus.PROCESSING,
|
||||
status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING,
|
||||
clientAction,
|
||||
merchantOrderId: intent.merchantOrderId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private formatIntentStatus(intent: PaymentEntity): IntentStatusDto {
|
||||
return {
|
||||
...this.formatIntentResponse(intent),
|
||||
paidAt: intent.paidAt?.toISOString(),
|
||||
failureCode: intent.failerCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async handlePaymentEvent(event: {
|
||||
eventType: string;
|
||||
eventId: string;
|
||||
referenceId: string;
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
||||
if (event.eventType === "payment.succeeded") {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
if (!intent) {
|
||||
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
}
|
||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: event.referenceId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
});
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
if (event.eventType === "payment.failed") {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
if (!intent) {
|
||||
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
}
|
||||
await this.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: event.failureCode,
|
||||
failureMessage: event.failureMessage,
|
||||
});
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
return { processed: false, reason: `Unknown event type: ${event.eventType}` };
|
||||
}
|
||||
|
||||
private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] {
|
||||
switch (status) {
|
||||
case ProviderPaymentStatus.SUCCEEDED: return "success";
|
||||
case ProviderPaymentStatus.FAILED: return "failed";
|
||||
case ProviderPaymentStatus.CANCELLED: return "canceled";
|
||||
case ProviderPaymentStatus.PROCESSING: return "processing";
|
||||
default: return "action-required";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user