mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
485 lines
18 KiB
TypeScript
485 lines
18 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
forwardRef,
|
|
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 { Booking } from "../bookings/entities/booking.entity";
|
|
|
|
import {
|
|
ClientAction,
|
|
ProviderPaymentStatus,
|
|
} from "@edr/payment-providers";
|
|
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";
|
|
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
|
|
|
|
/** Setting code holding the global ordering window (months) for general contracts. */
|
|
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
|
|
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
|
|
|
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 datasource: DataSource,
|
|
private readonly paymentRepo: PaymentRepository,
|
|
private readonly paymentClient: PaymentClientService,
|
|
@Inject(forwardRef(() => BookingBatchService))
|
|
private readonly bookingBatchService: BookingBatchService,
|
|
private readonly dropdownSettings: DropdownSettingsService,
|
|
) { }
|
|
|
|
/** Configured general-contract ordering window in months (defaults to 3). */
|
|
private async contractPeriodMonths(): Promise<number> {
|
|
try {
|
|
const setting = await this.dropdownSettings.getByCode(
|
|
CONTRACT_PERIOD_SETTING_CODE,
|
|
);
|
|
const months = Number(setting.children?.[0]?.value);
|
|
if (Number.isFinite(months) && months > 0) return months;
|
|
} catch {
|
|
// Setting not seeded — fall back to the default.
|
|
}
|
|
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
|
}
|
|
|
|
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 qb = this.paymentRepo.createQueryBuilder("payment");
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** Aggregate counts across ALL payments for the dashboard summary cards. */
|
|
async getSummary() {
|
|
const rows = await this.paymentRepo
|
|
.createQueryBuilder("payment")
|
|
.select("payment.status", "status")
|
|
.addSelect("COUNT(*)::int", "count")
|
|
.groupBy("payment.status")
|
|
.getRawMany<{ status: string; count: number }>();
|
|
|
|
const byStatus: Record<string, number> = {};
|
|
let total = 0;
|
|
for (const row of rows) {
|
|
byStatus[row.status] = row.count;
|
|
total += row.count;
|
|
}
|
|
|
|
// Sum of successfully collected amounts.
|
|
const paidAgg = await this.paymentRepo
|
|
.createQueryBuilder("payment")
|
|
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
|
.where("payment.status = :status", { status: "success" })
|
|
.getRawOne<{ sum: string }>();
|
|
|
|
return {
|
|
total,
|
|
success: byStatus["success"] ?? 0,
|
|
processing:
|
|
(byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0),
|
|
failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0),
|
|
refunded: byStatus["refunded"] ?? 0,
|
|
paidAmount: Number(paidAgg?.sum ?? 0),
|
|
};
|
|
}
|
|
|
|
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");
|
|
|
|
const amountMinor = Math.round(Number(booking.totalAmount));
|
|
|
|
const snapshot = await this.paymentClient.initiate({
|
|
service: PaymentServiceEnum.FREIGHT,
|
|
referenceType: PaymentReferenceType.SHIPMENT,
|
|
referenceId: booking.id,
|
|
orderRef: booking.reference,
|
|
amountMinor,
|
|
currency: booking.paymentCurrency,
|
|
provider: dto.method as unknown as ProviderMethod,
|
|
platform: dto.platform,
|
|
payerAccount: dto.payerAccount,
|
|
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
|
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
|
});
|
|
|
|
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",
|
|
CAC_BANK: "cac-bank",
|
|
};
|
|
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,
|
|
};
|
|
|
|
if (existing) {
|
|
await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any);
|
|
return { ...existing, ...data, clientAction } as PaymentEntity;
|
|
}
|
|
|
|
return this.paymentRepo.create({
|
|
refId: bookingId,
|
|
type: "booking",
|
|
amount: booking.totalAmount,
|
|
currency: booking.paymentCurrency,
|
|
reason: `Payment for booking ${booking.reference}`,
|
|
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
|
clientAction: clientAction ?? {},
|
|
...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();
|
|
|
|
// A general contract is paid once, up front; it does NOT enter the train
|
|
// queue (nothing has been ordered yet). Instead it becomes ACTIVE and
|
|
// opens its ordering window. Orders placed later spawn their own paid
|
|
// child bookings that go through the normal pipeline.
|
|
const booking = await this.datasource
|
|
.getRepository(Booking)
|
|
.findOne({ where: { id: input.bookingId } });
|
|
const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT";
|
|
|
|
let contractExpiresAt: Date | null = null;
|
|
if (isGeneralContract) {
|
|
const months = await this.contractPeriodMonths();
|
|
contractExpiresAt = new Date(paidAt);
|
|
contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months);
|
|
}
|
|
|
|
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 },
|
|
isGeneralContract
|
|
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
|
|
: { paymentStatus: "PAID", status: "PAID" },
|
|
);
|
|
});
|
|
|
|
if (isGeneralContract) {
|
|
this.logger.log(
|
|
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,
|
|
);
|
|
return { alreadyFinalized: false };
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async genReceiptHtml(orderId: string) {
|
|
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();
|
|
|
|
const source = fs.readFileSync(filePath, "utf8");
|
|
const template = Handlebars.compile(source);
|
|
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,
|
|
});
|
|
}
|
|
|
|
findBookingById(id: string) {
|
|
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
|
|
}
|
|
|
|
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
|
const clientAction =
|
|
intent.clientAction && typeof intent.clientAction === "object"
|
|
? (intent.clientAction as unknown as ClientAction)
|
|
: undefined;
|
|
return {
|
|
intentId: intent.id,
|
|
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";
|
|
}
|
|
}
|
|
|
|
async findByCompanyId(companyId: string) {
|
|
return this.paymentRepo.findByCompanyId(companyId);
|
|
}
|
|
}
|