Files
edr-platform/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts

279 lines
9.9 KiB
TypeScript

import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { PaymentClientService } from '../payments/payment-client.service';
import { NotificationsService } from '../notifications/notifications.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 paymentClient: PaymentClientService,
private notifications: NotificationsService,
) {}
async logCharge(dto: LogExcessBaggageDto) {
const booking = await this.prisma.booking.findUnique({
where: { id: dto.bookingId },
include: {
seats: { take: 1, include: { seat: { include: { coach: { include: { coachType: true } } } } } },
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');
}
// Resolve fee per kg from BaggageAllowance via seat class
const coachTypeId = booking.seats[0]?.seat?.coach?.coachTypeId;
let feePerKgMinor = 5000; // 50 ETB default fallback (in minor)
if (coachTypeId) {
const seatClass = await this.prisma.seatClass.findFirst({
where: { coachTypeId },
});
if (seatClass) {
const allowance = await this.prisma.baggageAllowance.findFirst({
where: { seatClassId: seatClass.id },
});
if (allowance) 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);
}
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)`;
const recipient = phone ?? email ?? booking.passengerId;
try {
await this.notifications['deliverSms'](recipient, msg);
} catch (err) {
this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`);
}
if (email) {
try {
await this.notifications['deliverEmail'](
recipient,
`EDR — Excess baggage payment required (${booking.bookingRef})`,
msg,
);
} catch (err) {
this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`);
}
}
}
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');
}
return this.prisma.excessBaggageCharge.update({
where: { id },
data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason },
});
}
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;
page?: number;
pageSize?: number;
}) {
const { status, bookingRef, 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' } };
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 }) {
return this.prisma.baggageAllowance.upsert({
where: { seatClassId: dto.seatClassId } as any,
update: { maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
create: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, 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.delete({ where: { id } });
return { deleted: true };
}
}