mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import {
|
|
Injectable,
|
|
NotFoundException,
|
|
BadRequestException,
|
|
Logger,
|
|
} from '@nestjs/common';
|
|
import { PrismaService } from '../../common/prisma.service';
|
|
import { AuditService } from '../../common/audit.service';
|
|
import { PaymentClientService } from '../payments/payment-client.service';
|
|
import { NotificationsService } from '../notifications/notifications.service';
|
|
import { SmsClientService } from '../notifications/sms-client.service';
|
|
import { EmailClientService } from '../notifications/email-client.service';
|
|
import {
|
|
LogExcessBaggageDto,
|
|
WaiveChargeDto,
|
|
InitiateExcessPaymentDto,
|
|
} from './excess-baggage.dto';
|
|
import {
|
|
PaymentService as PaymentServiceEnum,
|
|
PaymentReferenceType,
|
|
ProviderMethod,
|
|
ProviderPaymentStatus,
|
|
} from '@edr/types';
|
|
import { PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
|
|
|
const CHARGE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
|
@Injectable()
|
|
export class ExcessBaggageService {
|
|
private readonly logger = new Logger(ExcessBaggageService.name);
|
|
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private auditService: AuditService,
|
|
private paymentClient: PaymentClientService,
|
|
private notifications: NotificationsService,
|
|
private smsClient: SmsClientService,
|
|
private emailClient: EmailClientService,
|
|
) {}
|
|
|
|
async logCharge(dto: LogExcessBaggageDto) {
|
|
const booking = await this.prisma.booking.findUnique({
|
|
where: { id: dto.bookingId },
|
|
include: {
|
|
passenger: { include: { user: true } },
|
|
},
|
|
});
|
|
if (!booking) throw new NotFoundException('Booking not found');
|
|
if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) {
|
|
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage');
|
|
}
|
|
|
|
const allowance = await this.prisma.baggageAllowance.findFirst({ orderBy: { createdAt: 'asc' } });
|
|
if (!allowance) throw new BadRequestException('No excess baggage rate configured. Please set a rate in Tariff Rates.');
|
|
const feePerKgMinor = allowance.excessFeePerKg;
|
|
|
|
const totalMinor = feePerKgMinor * dto.excessWeightKg;
|
|
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
|
|
const contactPhone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
|
|
const contactEmail = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
|
|
|
|
const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING';
|
|
const paidAt = dto.collectCash ? new Date() : null;
|
|
|
|
const charge = await this.prisma.excessBaggageCharge.create({
|
|
data: {
|
|
bookingId: dto.bookingId,
|
|
agentId: dto.agentId ?? '',
|
|
excessWeightKg: dto.excessWeightKg,
|
|
feePerKgMinor,
|
|
totalMinor,
|
|
status,
|
|
expiresAt,
|
|
paidAt,
|
|
contactPhone,
|
|
contactEmail,
|
|
},
|
|
});
|
|
|
|
if (!dto.collectCash) {
|
|
await this.sendPaymentLink(charge, booking, contactPhone, contactEmail);
|
|
}
|
|
|
|
await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: dto.bookingId, excessWeightKg: dto.excessWeightKg, totalMinor, status } });
|
|
return charge;
|
|
}
|
|
|
|
private async sendPaymentLink(
|
|
charge: any,
|
|
booking: any,
|
|
phone: string | null,
|
|
email: string | null,
|
|
) {
|
|
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
|
|
const payUrl = `${portalUrl}/excess-baggage/pay/${charge.paymentToken}`;
|
|
const amountStr = (charge.totalMinor / 100).toFixed(2);
|
|
const msg = `EDR: Excess baggage charge of ${amountStr} ETB for booking ${booking.bookingRef}. Pay here: ${payUrl} (valid 30 min)`;
|
|
|
|
if (phone) {
|
|
try {
|
|
await this.smsClient.sendSms({ to: phone, message: msg });
|
|
} catch (err) {
|
|
this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`);
|
|
}
|
|
}
|
|
if (email) {
|
|
try {
|
|
await this.emailClient.sendEmail({
|
|
to: email,
|
|
subject: `EDR — Excess baggage payment required (${booking.bookingRef})`,
|
|
text: msg,
|
|
});
|
|
} catch (err) {
|
|
this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`);
|
|
}
|
|
}
|
|
if (!phone && !email) {
|
|
this.logger.warn(`No contact info to send excess baggage payment link for charge ${charge.id}`);
|
|
}
|
|
}
|
|
|
|
async getCharge(id: string) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({
|
|
where: { id },
|
|
include: { booking: { select: { bookingRef: true, status: true } } },
|
|
});
|
|
if (!charge) throw new NotFoundException('Charge not found');
|
|
return charge;
|
|
}
|
|
|
|
async getByToken(token: string) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({
|
|
where: { paymentToken: token },
|
|
include: { booking: { select: { bookingRef: true, scheduleId: true } } },
|
|
});
|
|
if (!charge) throw new NotFoundException('Payment link not found');
|
|
if (charge.status === 'EXPIRED' || new Date() > charge.expiresAt) {
|
|
if (charge.status === 'PENDING') {
|
|
await this.prisma.excessBaggageCharge.update({
|
|
where: { id: charge.id },
|
|
data: { status: 'EXPIRED' },
|
|
});
|
|
}
|
|
throw new BadRequestException('This payment link has expired');
|
|
}
|
|
if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') {
|
|
throw new BadRequestException('This charge has already been paid');
|
|
}
|
|
if (charge.status === 'WAIVED') {
|
|
throw new BadRequestException('This charge has been waived');
|
|
}
|
|
return charge;
|
|
}
|
|
|
|
async initiatePayment(token: string, dto: InitiateExcessPaymentDto) {
|
|
const charge = await this.getByToken(token);
|
|
|
|
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
|
|
const returnUrl = `${portalUrl}/excess-baggage/pay/${token}/result`;
|
|
|
|
const snapshot = await this.paymentClient.initiate({
|
|
service: PaymentServiceEnum.PASSENGER,
|
|
referenceType: 'EXCESS_BAGGAGE' as PaymentReferenceType,
|
|
referenceId: charge.id,
|
|
orderRef: `EXB-${charge.id.substring(0, 8).toUpperCase()}`,
|
|
amountMinor: charge.totalMinor / 100,
|
|
currency: charge.currency,
|
|
provider: dto.method as unknown as ProviderMethod,
|
|
platform: dto.platform as any,
|
|
returnUrl,
|
|
failureUrl: returnUrl,
|
|
});
|
|
|
|
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
|
await this.markPaid(charge.id, snapshot.providerTxnId);
|
|
}
|
|
|
|
return {
|
|
chargeId: charge.id,
|
|
status: snapshot.status,
|
|
clientAction: snapshot.clientAction,
|
|
merchantOrderId: snapshot.merchantOrderId,
|
|
};
|
|
}
|
|
|
|
async markPaid(chargeId: string, providerTxnId?: string) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } });
|
|
if (!charge) throw new NotFoundException('Charge not found');
|
|
if (charge.status === 'PAID') return charge;
|
|
return this.prisma.excessBaggageCharge.update({
|
|
where: { id: chargeId },
|
|
data: { status: 'PAID', paidAt: new Date() },
|
|
});
|
|
}
|
|
|
|
async waiveCharge(id: string, dto: WaiveChargeDto) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } });
|
|
if (!charge) throw new NotFoundException('Charge not found');
|
|
if (['PAID', 'CASH_COLLECTED'].includes(charge.status)) {
|
|
throw new BadRequestException('Cannot waive a charge that has already been paid');
|
|
}
|
|
const waived = await this.prisma.excessBaggageCharge.update({
|
|
where: { id },
|
|
data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason },
|
|
});
|
|
await this.auditService.log({ action: 'UPDATE', entityType: 'ExcessBaggageCharge', entityId: id, newData: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason } });
|
|
return waived;
|
|
}
|
|
|
|
async resendLink(id: string) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({
|
|
where: { id },
|
|
include: { booking: { select: { bookingRef: true, passengerId: true } } },
|
|
});
|
|
if (!charge) throw new NotFoundException('Charge not found');
|
|
if (charge.status !== 'PENDING') {
|
|
throw new BadRequestException('Can only resend link for PENDING charges');
|
|
}
|
|
// Extend expiry by 30 minutes from now
|
|
const updatedCharge = await this.prisma.excessBaggageCharge.update({
|
|
where: { id },
|
|
data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) },
|
|
});
|
|
await this.sendPaymentLink(updatedCharge, charge.booking, charge.contactPhone, charge.contactEmail);
|
|
return { sent: true };
|
|
}
|
|
|
|
async getAll(filters: {
|
|
status?: string;
|
|
bookingRef?: string;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
}) {
|
|
const { status, bookingRef, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
|
const skip = (page - 1) * pageSize;
|
|
const where: any = {};
|
|
if (status) where.status = status;
|
|
if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } };
|
|
if (dateFrom || dateTo) {
|
|
where.createdAt = {
|
|
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
|
|
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
|
|
};
|
|
}
|
|
|
|
// Mark expired charges before fetching
|
|
await this.prisma.excessBaggageCharge.updateMany({
|
|
where: {
|
|
status: 'PENDING',
|
|
expiresAt: { lt: new Date() },
|
|
},
|
|
data: { status: 'EXPIRED' },
|
|
});
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.excessBaggageCharge.findMany({
|
|
where,
|
|
include: { booking: { select: { bookingRef: true, status: true } } },
|
|
orderBy: { createdAt: 'desc' },
|
|
skip,
|
|
take: pageSize,
|
|
}),
|
|
this.prisma.excessBaggageCharge.count({ where }),
|
|
]);
|
|
|
|
return { items, total, page, pageSize };
|
|
}
|
|
|
|
async getAllowances() {
|
|
const [allowances, seatClasses] = await Promise.all([
|
|
this.prisma.baggageAllowance.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
this.prisma.seatClass.findMany({ select: { id: true, name: true } }),
|
|
]);
|
|
const scMap = new Map(seatClasses.map(s => [s.id, s]));
|
|
return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null }));
|
|
}
|
|
|
|
async upsertAllowance(dto: { seatClassId: string; maxWeightKg?: number; maxPiecesCount?: number; excessFeePerKg: number }) {
|
|
const existing = await this.prisma.baggageAllowance.findFirst({ where: { seatClassId: dto.seatClassId } });
|
|
if (existing) {
|
|
return this.prisma.baggageAllowance.update({
|
|
where: { id: existing.id },
|
|
data: { maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
|
|
});
|
|
}
|
|
return this.prisma.baggageAllowance.create({
|
|
data: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
|
|
});
|
|
}
|
|
|
|
async updateAllowance(id: string, dto: Partial<{ maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }>) {
|
|
return this.prisma.baggageAllowance.update({ where: { id }, data: dto });
|
|
}
|
|
|
|
async deleteAllowance(id: string) {
|
|
await this.prisma.baggageAllowance.deleteMany({ where: { id } });
|
|
return { deleted: true };
|
|
}
|
|
|
|
async deleteCharge(id: string) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } });
|
|
if (!charge) throw new NotFoundException('Charge not found');
|
|
|
|
await this.prisma.excessBaggageCharge.delete({ where: { id } });
|
|
return { deleted: true };
|
|
}
|
|
}
|