import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { DataSource, EntityManager } from 'typeorm'; import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; import { FilesService } from '../files/files.service'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BookingsService } from './bookings.service'; import { BookingsRepository } from './bookings.repository'; import { AdditionalChargeRepository } from './additional-charge.repository'; import { AdditionalCharge } from './entities/additional-charge.entity'; import { CreateAdditionalChargeDto } from './dto/additional-charge.dto'; const FILE_RESOURCE = 'additional_charges'; /** * Ad-hoc extra charges finance raises against a booking, independent of * `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS * row per booking). Any number per booking, free-text reason. DRAFT until * sent; sending issues the payable invoice and notifies the customer * (in-app + SMS + email). Settles via `additional_charge.invoice.paid`, * same event-driven pattern as every other invoice source. */ @Injectable() export class AdditionalChargeService { private readonly logger = new Logger(AdditionalChargeService.name); constructor( private readonly dataSource: DataSource, private readonly repository: AdditionalChargeRepository, private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, private readonly exchangeService: ExchangeService, private readonly billing: BillingService, private readonly bookingsService: BookingsService, private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, ) {} private async findOwned(bookingId: string, chargeId: string): Promise { const charge = await this.repository.findById(chargeId); if (!charge || charge.bookingId !== bookingId) { throw new NotFoundException('Additional charge not found'); } return charge; } async list(bookingId: string): Promise { const rows = await this.repository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' }, }); return this.toDtoList(rows); } async create( bookingId: string, dto: CreateAdditionalChargeDto, staffId: string, file?: Express.Multer.File, ): Promise { const booking = await this.bookingsService.findById(bookingId); const shouldSend = dto.action === 'send'; const chargeId = await this.dataSource.transaction(async (manager) => { const repo = manager.getRepository(AdditionalCharge); let saved = await repo.save( repo.create({ bookingId, reason: dto.reason.trim(), amount: dto.amount.toFixed(2), currency: dto.currency.trim().toUpperCase(), dueAt: dto.dueDate ? new Date(dto.dueDate) : null, status: 'DRAFT', createdByStaffId: staffId, }), ); if (file) { const record = await this.filesService.upload({ resourceId: saved.id, resource: FILE_RESOURCE, code: FILE_RESOURCE, file, uploadedByUserId: staffId, }); await repo.update(saved.id, { fileRecordId: record.id }); } if (shouldSend) { saved = await this.issueInvoice(manager, saved.id, booking, staffId); } return saved.id; }); if (shouldSend) await this.notifyCustomerSent(chargeId); return this.list(bookingId); } async send(bookingId: string, chargeId: string, staffId: string): Promise { const charge = await this.findOwned(bookingId, chargeId); if (charge.status !== 'DRAFT') { throw new ConflictException('Only a draft charge can be sent.'); } const booking = await this.bookingsService.findById(bookingId); await this.dataSource.transaction((manager) => this.issueInvoice(manager, charge.id, booking, staffId), ); await this.notifyCustomerSent(charge.id); return this.list(bookingId); } /** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */ private async issueInvoice( manager: EntityManager, chargeId: string, booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null }, staffId: string, ): Promise { const repo = manager.getRepository(AdditionalCharge); const charge = await repo.findOneByOrFail({ id: chargeId }); const invoice = await this.billing.generateInvoice( { source: Freight.InvoiceSource.AdditionalCharge, sourceId: charge.id, type: 'ADDITIONAL_CHARGE', companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency: charge.currency, // Unset falls through to BillingService's own DEFAULT_DUE_DAYS (14). dueAt: charge.dueAt ?? undefined, lines: [ { chargeType: 'ADDITIONAL_CHARGE', description: `${charge.reason} — ${booking.reference ?? booking.id}`, amount: Number(charge.amount), }, ], }, manager, ); await repo.update(charge.id, { status: 'SENT', invoiceId: invoice.id, sentByStaffId: staffId, sentAt: new Date(), }); this.logger.log( `Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`, ); return repo.findOneByOrFail({ id: charge.id }); } private async notifyCustomerSent(chargeId: string): Promise { try { const charge = await this.repository.findById(chargeId); if (!charge) return; const booking = await this.bookingsService.findById(charge.bookingId); if (!booking.companyId) return; const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`; await this.inbox.notify({ recipients: { companyId: booking.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.INVOICE_ISSUED, title: 'New charge on your booking', body, link: `/bookings/${charge.bookingId}`, data: { bookingId: charge.bookingId, chargeId: charge.id, amount: Number(charge.amount), currency: charge.currency, }, }); await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); } catch (err) { this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`); } } async cancel( bookingId: string, chargeId: string, staffId: string, reason?: string, ): Promise { const charge = await this.findOwned(bookingId, chargeId); if (charge.status !== 'DRAFT' && charge.status !== 'SENT') { throw new ConflictException('Only a draft or unpaid charge can be cancelled.'); } if (charge.status === 'SENT' && charge.invoiceId) { await this.billing.cancelInvoice(charge.invoiceId); } await this.repository.update(charge.id, { status: 'CANCELLED', cancelledByStaffId: staffId, cancelledAt: new Date(), cancelReason: reason ?? null, }); return this.list(bookingId); } /** Gateway and manual settlements both land here (`${source}.invoice.paid`). */ @OnEvent('additional_charge.invoice.paid') async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise { const charge = await this.repository.findById(payload.sourceId); if (!charge || charge.status === 'PAID') return; await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() }); try { const booking = await this.bookingsService.findById(charge.bookingId); if (!booking.companyId) return; const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`; await this.inbox.notify({ recipients: { companyId: booking.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.PAYMENT_RECEIVED, title: 'Charge payment received', body, link: `/bookings/${charge.bookingId}`, data: { bookingId: charge.bookingId, chargeId: charge.id }, }); await this.inbox.notify({ recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.PAYMENT_RECEIVED, title: 'Additional charge paid', body, link: `/bookings/${charge.bookingId}`, data: { bookingId: charge.bookingId, chargeId: charge.id }, }); } catch (err) { this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`); } } private async toDtoList(rows: AdditionalCharge[]): Promise { if (!rows.length) return []; const filesByCharge = await this.filesService.findByResourceIdsGrouped( rows.map((r) => r.id), FILE_RESOURCE, ); const names = await this.bookingsRepository.resolveStaffNames( rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]), ); const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id)); const invoices = invoiceIds.length ? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) }) : []; const invoiceById = new Map(invoices.map((i) => [i.id, i])); const converted = await Promise.all(rows.map((r) => this.convertAmount(r))); const convertedById = new Map(rows.map((r, i) => [r.id, converted[i]])); return rows.map((r) => { const file = filesByCharge.get(r.id)?.[0]; const fx = convertedById.get(r.id) ?? null; return { id: r.id, bookingId: r.bookingId, reason: r.reason, status: r.status, amount: Number(r.amount), currency: r.currency, convertedAmount: fx?.amount ?? null, convertedCurrency: fx?.currency ?? null, dueAt: r.dueAt?.toISOString() ?? null, file: file ? { id: file.id, name: file.name, url: file.url } : null, invoiceId: r.invoiceId ?? null, invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null, paymentReference: r.paymentReference ?? null, createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null, createdAt: r.createdAt.toISOString(), sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null, sentAt: r.sentAt?.toISOString() ?? null, paidAt: r.paidAt?.toISOString() ?? null, cancelledAt: r.cancelledAt?.toISOString() ?? null, cancelReason: r.cancelReason ?? null, }; }); } /** * Amount converted to the other of ETB/USD, via the existing shared * `ExchangeService` (CBE rate, falls back to the stored `exchange_settings` * rate) — same mechanism `booking-wagon-cancellation.service.ts` and * warehouse fee pricing already use. Null on anything but ETB/USD, or if * the rate feed is down — this is a display convenience, not the payable * amount, so a failure here must never break the charge list. */ private async convertAmount( charge: AdditionalCharge, ): Promise<{ amount: number; currency: string } | null> { if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null; const target = charge.currency === 'ETB' ? 'USD' : 'ETB'; try { const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target); return { amount: Math.round(amount * 100) / 100, currency: target }; } catch (err) { this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`); return null; } } }