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 }; } }