From 787da1ccc08d009fba9fa79bbf7736d161c7e6f6 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 2 Jul 2026 12:35:43 +0000 Subject: [PATCH 1/5] chore: fix the invoice event --- .../src/modules/billing/billing.service.ts | 8 +++++++- .../src/modules/bookings/booking-invoice.service.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 278d4cca9..b68db5974 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -691,7 +691,13 @@ export class BillingService { status: invoice.status, paymentId: invoice.paymentId ?? null, }; - this.events.emit(`${invoice.source}.invoice.${event}`, payload); + this.events + .emitAsync(`${invoice.source}.invoice.${event}`, payload) + .catch((err) => + this.logger.error( + `Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`, + ), + ); } // ── Payment reconciliation (by source) ─────────────────────────────────────── diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 3bfb838b4..30f41813b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -143,9 +143,16 @@ export class BookingInvoiceService { { id: bookingId }, { paymentStatus: "PAID", status: "PAID" }, ); - await this.firstMile.acceptBooking(bookingId); }); + try { + await this.firstMile.acceptBooking(bookingId); + } catch (err) { + this.logger.error( + `Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`, + ); + } + try { await this.bookingBatch.ensurePaidBookingAllocated(bookingId); } catch (err) { From 4098b5476fd39652c479fbfb569662ae2415fb0e Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 2 Jul 2026 15:37:49 +0300 Subject: [PATCH 2/5] fix(temp): invoice no for the telebirr --- .../edr-freight-api/src/modules/billing/billing.service.ts | 7 +++++-- .../src/modules/bookings/booking-invoice.service.ts | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index b68db5974..baf3fa3f3 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -829,7 +829,10 @@ export class BillingService { ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { - where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) }, + where: { + id: invoiceId, + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), + }, }); if (!invoice) return; await mg.update( @@ -882,7 +885,7 @@ export class BillingService { // in the domain via `${source}.invoice.paid`. Neither billing nor the payment // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, - orderRef: invoice.invoiceNumber, + orderRef: invoice.invoiceNumber.replace("-", "_"), amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 30f41813b..1b8eddeca 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -5,7 +5,6 @@ import { Injectable, Logger, } from "@nestjs/common"; -import { OnEvent } from "@nestjs/event-emitter"; import { Freight } from "@edr/types"; import { DataSource, EntityManager } from "typeorm"; @@ -95,8 +94,10 @@ export class BookingInvoiceService { * reactions live here (not in the payment process): each invoice type advances * the booking its own way. Only PREPAID exists today. */ - @OnEvent("booking.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + this.logger.log( + `onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`, + ); switch (payload.type) { case "PREPAID": await this.advanceBookingOnPayment(payload.sourceId); From 79c3293a72b2a97f4ea351fc866ed39ae4829d49 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 2 Jul 2026 16:18:46 +0300 Subject: [PATCH 3/5] fix: booking paid trigger --- .../bookings/booking-invoice.service.ts | 2 + .../src/modules/payment/payment.service.ts | 77 ++++++++++--------- 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 1b8eddeca..21fef08ea 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -5,6 +5,7 @@ import { Injectable, Logger, } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; import { Freight } from "@edr/types"; import { DataSource, EntityManager } from "typeorm"; @@ -94,6 +95,7 @@ export class BookingInvoiceService { * reactions live here (not in the payment process): each invoice type advances * the booking its own way. Only PREPAID exists today. */ + @OnEvent("booking.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { this.logger.log( `onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index d92af7a3e..d773ebe1f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -189,48 +189,53 @@ export class PaymentService { * has stored the intent id, avoiding a settle-before-correlation race. */ async initiate(input: InitiateIntentInput): Promise { - const snapshot = await this.paymentClient.initiate({ - service: PaymentServiceEnum.FREIGHT, - referenceType: PaymentReferenceType.SHIPMENT, - referenceId: input.referenceId, - orderRef: input.orderRef, - amountMinor: input.amountMinor, - currency: input.currency, - provider: input.method as ProviderMethod, - platform: input.platform, - payerAccount: input.payerAccount, - returnUrl: - input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", - failureUrl: - input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", - }); + try { + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + orderRef: input.orderRef, + amountMinor: input.amountMinor, + currency: input.currency, + provider: input.method as ProviderMethod, + platform: input.platform, + payerAccount: input.payerAccount, + returnUrl: + input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + failureUrl: + input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", + }); - const immediateSuccess = - snapshot.status === ProviderPaymentStatus.SUCCEEDED; - const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; + const immediateSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED; + const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; - const intent = await this.upsertIntent(input, snapshot); + const intent = await this.upsertIntent(input, snapshot); - if (immediateSuccess) { - // Settle the projection but DO NOT notify billing — billing settles - // inline once it has stored intentId on the invoice (see payInvoice), - // avoiding a settle-before-correlation race. - await this.markIntentSucceeded(intent.id, { + if (immediateSuccess) { + // Settle the projection but DO NOT notify billing — billing settles + // inline once it has stored intentId on the invoice (see payInvoice), + // avoiding a settle-before-correlation race. + await this.markIntentSucceeded(intent.id, { + providerTxnId: snapshot.providerTxnId, + paidAt, + notify: false, + }); + } + + return { + intentId: intent.id, + // `intent` still reflects the projection status ("processing" on immediate + // success — settlement is applied by the caller, not shown synchronously). + response: this.formatIntentResponse(intent), + immediateSuccess, providerTxnId: snapshot.providerTxnId, paidAt, - notify: false, - }); + }; + } catch (err) { + console.log(err); + throw err; } - - return { - intentId: intent.id, - // `intent` still reflects the projection status ("processing" on immediate - // success — settlement is applied by the caller, not shown synchronously). - response: this.formatIntentResponse(intent), - immediateSuccess, - providerTxnId: snapshot.providerTxnId, - paidAt, - }; } /** Create or update the local intent projection from a provider snapshot. */ From b75a3ab54b24e4c94726d0e71c998768fe811b0a Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 2 Jul 2026 16:25:45 +0300 Subject: [PATCH 4/5] fix: first-mile error --- .../modules/first-mile/first-mile.service.ts | 169 ++++++++++++------ 1 file changed, 116 insertions(+), 53 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index ae0ada831..3ba1e4e75 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,19 +1,25 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { FindOptionsWhere } from "typeorm"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; -import { BookingsRepository } from '../bookings/bookings.repository'; -import { DriversService } from '../drivers/drivers.service'; -import { SmsClientService } from '../notifications/sms-client.service'; -import { VehiclesService } from '../vehicles/vehicles.service'; -import { CreateFirstMileDto } from './dto/create-first-mile.dto'; -import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; -import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; -import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; -import { FirstMileRepository } from './first-mile.repository'; -import { OnEvent } from '@nestjs/event-emitter'; -import { InvoiceEventPayload } from '../billing/billing.service'; +import { BookingsRepository } from "../bookings/bookings.repository"; +import { DriversService } from "../drivers/drivers.service"; +import { SmsClientService } from "../notifications/sms-client.service"; +import { VehiclesService } from "../vehicles/vehicles.service"; +import { CreateFirstMileDto } from "./dto/create-first-mile.dto"; +import { UpdateFirstMileDto } from "./dto/update-first-mile.dto"; +import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity"; +import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity"; +import { FirstMileRepository } from "./first-mile.repository"; +import { OnEvent } from "@nestjs/event-emitter"; +import { InvoiceEventPayload } from "../billing/billing.service"; type FirstMileListFilter = { status?: FirstMileStatus; @@ -26,10 +32,10 @@ type FirstMileListFilter = { }; const SORTABLE_FIELDS: (keyof FirstMile)[] = [ - 'status', - 'advancedPayment', - 'remainingPayment', - 'createdAt', + "status", + "advancedPayment", + "remainingPayment", + "createdAt", ]; @Injectable() @@ -43,26 +49,28 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, - ) {} + ) { } /** * Look up a booking by its human-readable reference and confirm it has been * paid before any first-mile work proceeds. Throws if the reference is * unknown or the booking has not reached PAID status. */ - async acceptBooking(bookingId: string): Promise { + async acceptBooking(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId, { relations: { serviceType: true }, }); if (!booking) { - throw new NotFoundException(`Booking ${bookingId} not found`); + return null; } return this.acceptEligibleBooking(booking); } - async acceptBookingByReference(bookingReference: string): Promise { + async acceptBookingByReference( + bookingReference: string, + ): Promise { const [booking] = await this.bookingsRepository.findAll({ where: { reference: bookingReference }, relations: { serviceType: true }, @@ -89,20 +97,20 @@ export class FirstMileService { tradeDirection?: string | null; firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; - }): Promise { + }): Promise { const label = booking.reference ?? booking.id; - if (booking.paymentStatus !== 'PAID') { - throw new BadRequestException(`Booking ${label} is not paid`); + if (booking.paymentStatus !== "PAID") { + return null; } if (!this.bookingRequestsFirstMile(booking)) { - throw new BadRequestException(`Booking ${label} does not require a first mile`); + return null; } const existing = await this.findByBookingId(booking.id); if (existing) { - throw new ConflictException(`Booking ${label} already has a first-mile assignment`); + return null; } return this.create({ @@ -118,8 +126,9 @@ export class FirstMileService { const pageSize = filter.pageSize ?? 50; const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof FirstMile) ? (filter.sortBy as keyof FirstMile) - : 'createdAt'; - const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + : "createdAt"; + const sortOrder = + filter.sortOrder?.toUpperCase() === "ASC" ? "ASC" : "DESC"; const where: FindOptionsWhere = {}; if (filter.status) where.status = filter.status; @@ -129,7 +138,13 @@ export class FirstMileService { const [data, total] = await this.firstMileRepository.findAndCount({ where, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + }, vehicle: true, }, order: { [sortBy]: sortOrder }, @@ -151,8 +166,12 @@ export class FirstMileService { @OnEvent("firstmile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { - await this.firstMileRepository.update(payload.sourceId, { paid: true } as any); - this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + await this.firstMileRepository.update(payload.sourceId, { + paid: true, + } as any); + this.logger.log( + `Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`, + ); } catch (err) { this.logger.error( `Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`, @@ -163,7 +182,13 @@ export class FirstMileService { async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + }, vehicle: true, }, }); @@ -183,7 +208,7 @@ export class FirstMileService { return this.firstMileRepository.create({ bookingId: dto.bookingId, - status: dto.status ?? 'READY_TO_TRANSIT', + status: dto.status ?? "READY_TO_TRANSIT", advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? null, @@ -197,7 +222,13 @@ export class FirstMileService { const [records] = await this.firstMileRepository.findAndCount({ where: { bookingId }, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + }, vehicle: true, }, take: 1, @@ -213,9 +244,9 @@ export class FirstMileService { // Export bookings always need a first mile (pickup → origin yard); the // pickup address is captured at assignment time, not required upfront. return Boolean( - booking.tradeDirection === 'EXPORT' || - booking.firstMilePickupAddress?.trim() || - booking.serviceType?.includesFirstMile, + booking.tradeDirection === "EXPORT" || + booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile, ); } @@ -226,9 +257,15 @@ export class FirstMileService { const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), - ...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}), - ...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}), - ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), + ...(dto.advancedPayment !== undefined + ? { advancedPayment: dto.advancedPayment } + : {}), + ...(dto.remainingPayment !== undefined + ? { remainingPayment: dto.remainingPayment } + : {}), + ...(dto.estimatedKm !== undefined + ? { estimatedKm: dto.estimatedKm } + : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), @@ -256,37 +293,63 @@ export class FirstMileService { return updated; } - private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { + private async notifyDriverAssignment( + vehicleId: string, + record: FirstMile, + ): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); if (!vehicle.assignedDriverId) { - this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`); + this.logger.warn( + `Vehicle ${vehicleId} has no assigned driver — skipping SMS`, + ); return; } - const driver = await this.driversService.findById(vehicle.assignedDriverId); + const driver = await this.driversService.findById( + vehicle.assignedDriverId, + ); if (!driver.phoneNumber) { - this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`); + this.logger.warn( + `Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`, + ); return; } - const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking; + const booking = ( + record as FirstMile & { + booking?: { + reference?: string; + firstMilePickupAddress?: string | null; + originYard?: { label?: string } | null; + }; + } + ).booking; - const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const driverName = + `${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim(); const message = `Dear ${driverName}, you have been assigned to a first-mile pickup. ` + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + - (booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') + - (booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : ''); + (booking?.firstMilePickupAddress + ? `Pickup: ${booking.firstMilePickupAddress}. ` + : "") + + (booking?.originYard?.label + ? `Destination: ${booking.originYard.label}.` + : ""); void this.smsClient.sendSms({ to: driver.phoneNumber, message, }); - this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log( + `SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`, + ); } catch (err) { - this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); + this.logger.error( + `Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`, + ); } } @@ -314,7 +377,7 @@ export class FirstMileService { firstMileId, containerId: allocation.containerId, vehicleId: allocation.vehicleId, - containerType: 'CONTAINER', + containerType: "CONTAINER", quantity: 1, }); } From df60c4750e93247f016502f780eeb32479810fd5 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 2 Jul 2026 16:32:35 +0300 Subject: [PATCH 5/5] fix: warehouse query --- .../modules/first-mile/first-mile.service.ts | 10 +- .../warehouses/warehouse-invoice.service.ts | 370 ++++++++++++------ 2 files changed, 250 insertions(+), 130 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 3ba1e4e75..d05964878 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,10 +1,4 @@ -import { - BadRequestException, - ConflictException, - Injectable, - Logger, - NotFoundException, -} from "@nestjs/common"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { FindOptionsWhere } from "typeorm"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; @@ -98,8 +92,6 @@ export class FirstMileService { firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): Promise { - const label = booking.reference ?? booking.id; - if (booking.paymentStatus !== "PAID") { return null; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 6f7219781..c5a75a8c5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,29 +1,39 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { Freight } from '@edr/types'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { Freight } from "@edr/types"; +import { DataSource } from "typeorm"; -import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; -import { Invoice } from '../billing/entities/invoice.entity'; -import { InvoiceLine } from '../billing/entities/invoice-line.entity'; +import { + BillingService, + InvoiceEventPayload, + InvoiceLineInput, +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { InvoiceLine } from "../billing/entities/invoice-line.entity"; import { InvoiceDocumentModel, InvoiceDocumentService, -} from '../billing/documents/invoice-document.service'; -import { NotificationsService } from '../notifications/notifications.service'; -import { WarehouseFeeService } from './warehouse-fee.service'; +} from "../billing/documents/invoice-document.service"; +import { NotificationsService } from "../notifications/notifications.service"; +import { WarehouseFeeService } from "./warehouse-fee.service"; import { WarehouseFeeInvoiceView, WarehouseFeeType, WarehouseInvoiceItemView, WarehouseInvoiceStatus, WarehouseInvoiceType, -} from './warehouse-invoice.types'; +} from "./warehouse-invoice.types"; interface GenerateOptions { confirmZero?: boolean; performedBy?: string; - billingCurrency?: 'ETB' | 'USD'; + billingCurrency?: "ETB" | "USD"; } export interface PayInvoiceDto { @@ -45,7 +55,10 @@ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ Freight.InvoiceStatus.Overdue, ]; /** Global statuses considered an "active" invoice for per-inventory dedup. */ -const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid]; +const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [ + ...BLOCKING_STATUSES, + Freight.InvoiceStatus.Paid, +]; export interface InvoiceDocumentDetails { bookingReference: string | null; @@ -120,10 +133,13 @@ export class WarehouseInvoiceService { private readonly invoiceDocuments: InvoiceDocumentService, private readonly feeService: WarehouseFeeService, private readonly notifications: NotificationsService, - ) {} + ) { } // ── Generation ─────────────────────────────────────────────────────────── - async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { + async generateForInventory( + inventoryId: string, + opts: GenerateOptions = {}, + ): Promise { const [item] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt", @@ -136,43 +152,47 @@ export class WarehouseInvoiceService { WHERE inv.id = $1 AND inv.deleted_at IS NULL`, [inventoryId], ); - if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + if (!item) + throw new NotFoundException(`Inventory item ${inventoryId} not found`); // Routing through the global invoice requires a billable company + profile, // both of which come from the inventory's booking. if (!item.companyId || !item.companyProfileId) { throw new BadRequestException( - 'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).', + "Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).", ); } // Dedup: only one active (non-cancelled) invoice per inventory item. if (await this.hasActiveInvoice(inventoryId)) { throw new ConflictException( - 'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.', + "An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.", ); } - const billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD'; - const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency); - const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; + const billingCurrency = opts.billingCurrency === "ETB" ? "ETB" : "USD"; + const previews = await this.feeService.previewForInventory( + inventoryId, + billingCurrency, + ); + const isContainer = (item.freightType ?? "").toUpperCase() === "CONTAINER"; const items = previews .filter((p) => p.amount > 0) .map((p) => { const feeType: WarehouseFeeType = - p.ruleType === 'STORAGE_FEE' - ? 'STORAGE_FEE' + p.ruleType === "STORAGE_FEE" + ? "STORAGE_FEE" : isContainer - ? 'CONTAINER_DEMURRAGE' - : 'BULK_DEMURRAGE'; + ? "CONTAINER_DEMURRAGE" + : "BULK_DEMURRAGE"; return { feeRuleId: p.ruleId, feeType, description: - p.ruleType === 'STORAGE_FEE' + p.ruleType === "STORAGE_FEE" ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free` - : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, + : `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, quantity: p.billableUnits, unitRate: p.ratePerDay, amount: p.amount, @@ -184,13 +204,19 @@ export class WarehouseInvoiceService { const total = items.reduce((s, i) => s + i.amount, 0); if (total <= 0 && !opts.confirmZero) { - throw new BadRequestException('No payable warehouse fee found for this item.'); + throw new BadRequestException( + "No payable warehouse fee found for this item.", + ); } - const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE'); - const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE'); + const hasDemurrage = items.some((i) => i.feeType !== "STORAGE_FEE"); + const hasStorage = items.some((i) => i.feeType === "STORAGE_FEE"); const invoiceType: WarehouseInvoiceType = - hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; + hasDemurrage && hasStorage + ? "MIXED_WAREHOUSE_FEES" + : hasStorage + ? "STORAGE_FEE" + : "DEMURRAGE"; const lines: InvoiceLineInput[] = items.map((it) => ({ chargeType: it.feeType, @@ -232,18 +258,23 @@ export class WarehouseInvoiceService { } listForInventory(inventoryId: string): Promise { - return this.queryViews('AND i.source_id = $1', [inventoryId]); + return this.queryViews("AND i.source_id = $1", [inventoryId]); } listForBooking(bookingId: string): Promise { - return this.queryViews('AND inv.booking_id = $1', [bookingId]); + return this.queryViews("AND inv.booking_id = $1", [bookingId]); } async findAll( filter: Partial< Pick< WarehouseFeeInvoiceView, - 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId' + | "status" + | "invoiceType" + | "warehouseId" + | "facilityId" + | "customerId" + | "bookingId" > >, ): Promise { @@ -254,41 +285,56 @@ export class WarehouseInvoiceService { conditions.push(sql(`$${params.length}`)); }; - if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus)); + if (filter.status) + add( + (p) => `i.status::text = ${p}`, + this.toGlobalStatus(filter.status as WarehouseInvoiceStatus), + ); if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType); if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId); - if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId); - if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId); + if (filter.warehouseId) + add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId); + if (filter.facilityId) + add((p) => `w.facility_id = ${p}`, filter.facilityId); if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId); - return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params); + return this.queryViews(conditions.map((c) => `AND ${c}`).join(" "), params); } async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE')); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "INVOICE"), + ); } async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); if (Number(invoice.paidAmount) <= 0) { - throw new BadRequestException('A receipt is available only after payment is recorded.'); + throw new BadRequestException( + "A receipt is available only after payment is recorded.", + ); } - return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT')); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "RECEIPT"), + ); } // ── State changes ──────────────────────────────────────────────────────── async cancel(id: string): Promise { const invoice = await this.loadWarehouseInvoice(id); if (invoice.status === Freight.InvoiceStatus.Paid) { - throw new BadRequestException('A paid invoice cannot be cancelled.'); + throw new BadRequestException("A paid invoice cannot be cancelled."); } await this.billing.cancelInvoice(id); return this.findById(id); } /** Record a payment against the invoice (delegates settlement to billing). */ - async pay(id: string, dto: PayInvoiceDto): Promise { + async pay( + id: string, + dto: PayInvoiceDto, + ): Promise { // Guard that this is a warehouse invoice before recording (404 otherwise). await this.loadWarehouseInvoice(id); await this.billing.recordPayment(id, { @@ -297,7 +343,10 @@ export class WarehouseInvoiceService { reference: dto.reference ?? null, metadata: dto.driverName || dto.driverPhone - ? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null } + ? { + driverName: dto.driverName ?? null, + driverPhone: dto.driverPhone ?? null, + } : null, }); const detail = await this.findById(id); @@ -313,16 +362,20 @@ export class WarehouseInvoiceService { * counter settlement leaves it null. Skipping null-`paymentId` events avoids * double-notifying a counter payment that already sent its SMS. */ - @OnEvent('warehouse.invoice.paid') + @OnEvent("warehouse.invoice.paid") async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise { if (!payload.paymentId) return; const detail = await this.findById(payload.invoiceId); - await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) }); + await this.notifyWarehouseFeePayment(detail, { + amount: Number(detail.totalAmount), + }); } // ── Release blocking ────────────────────────────────────────────────────── /** Returns the first unpaid invoice that blocks terminal release, or null. */ - async findBlockingInvoice(inventoryId: string): Promise { + async findBlockingInvoice( + inventoryId: string, + ): Promise { const blocking = await this.queryViews( `AND i.source_id = $1 AND i.status::text = ANY($2::text[])`, [inventoryId, BLOCKING_STATUSES], @@ -331,21 +384,31 @@ export class WarehouseInvoiceService { } async assertClearanceAllowed(inventoryId: string): Promise { - const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]); - const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); + const invoices = await this.queryViews("AND i.source_id = $1", [ + inventoryId, + ]); + const blocking = invoices.find( + (inv) => inv.status === "ISSUED" || inv.status === "PARTIALLY_PAID", + ); if (blocking) { throw new BadRequestException( `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, ); } - if (invoices.some((inv) => inv.status === 'PAID')) return; + if (invoices.some((inv) => inv.status === "PAID")) return; - const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); - const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); + const previews = await this.feeService.previewForInventory( + inventoryId, + "USD", + ); + const payableAmount = previews.reduce( + (sum, fee) => sum + Number(fee.amount || 0), + 0, + ); if (payableAmount > 0) { throw new BadRequestException( - 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', + "Generate and fully pay the warehouse demurrage/storage invoice before terminal release.", ); } } @@ -353,7 +416,9 @@ export class WarehouseInvoiceService { // ── Internal: loading & projection ───────────────────────────────────────── /** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */ - private async loadWarehouseInvoice(id: string): Promise { + private async loadWarehouseInvoice( + id: string, + ): Promise { const invoice = await this.billing.findById(id); if (invoice.source !== SOURCE) { throw new NotFoundException(`Invoice ${id} not found`); @@ -376,7 +441,10 @@ export class WarehouseInvoiceService { * Project warehouse-source global invoices into the historical view, joined to * their inventory item for the typed FKs. Powers every list/filter read. */ - private async queryViews(extraWhere: string, params: unknown[]): Promise { + private async queryViews( + extraWhere: string, + params: unknown[], + ): Promise { const rows = await this.dataSource.query( `SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId", i.source_id AS "sourceId", i.type, i.status, @@ -389,7 +457,7 @@ export class WarehouseInvoiceService { inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", w.facility_id AS "facilityId" FROM freight.invoices i - LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouse_inventory inv ON inv.id::text = i.source_id AND inv.deleted_at IS NULL LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere} ORDER BY i.created_at DESC`, @@ -409,7 +477,10 @@ export class WarehouseInvoiceService { } /** Reshape a global invoice (+ derived inventory context) into the warehouse view. */ - private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView { + private buildView( + inv: ViewSource, + ctx: InventoryContext, + ): WarehouseFeeInvoiceView { const status = this.toWarehouseStatus(inv.status); return { id: inv.id, @@ -436,7 +507,7 @@ export class WarehouseInvoiceService { issuedAt: inv.issuedAt ?? null, dueDate: inv.dueAt ?? null, paidAt: inv.paidAt ?? null, - cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null, + cancelledAt: status === "CANCELLED" ? inv.updatedAt : null, payments: (inv.payments ?? []).map((p) => ({ amount: Number(p.amount), method: p.method ?? null, @@ -458,7 +529,7 @@ export class WarehouseInvoiceService { return { feeRuleId: meta.feeRuleId ?? null, feeType: line.chargeType as WarehouseFeeType, - description: line.description ?? '', + description: line.description ?? "", quantity: Number(line.quantity), unitRate: Number(line.unitRate), amount: Number(line.amount), @@ -468,32 +539,36 @@ export class WarehouseInvoiceService { }; } - private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus { + private toWarehouseStatus( + status: Freight.InvoiceStatus | string, + ): WarehouseInvoiceStatus { switch (status) { case Freight.InvoiceStatus.Draft: - return 'DRAFT'; + return "DRAFT"; case Freight.InvoiceStatus.PartiallyPaid: - return 'PARTIALLY_PAID'; + return "PARTIALLY_PAID"; case Freight.InvoiceStatus.Paid: - return 'PAID'; + return "PAID"; case Freight.InvoiceStatus.Cancelled: case Freight.InvoiceStatus.Refunded: - return 'CANCELLED'; + return "CANCELLED"; default: // Issued / Pending / Overdue → an issued, still-owed invoice. - return 'ISSUED'; + return "ISSUED"; } } - private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus { + private toGlobalStatus( + status: WarehouseInvoiceStatus, + ): Freight.InvoiceStatus { switch (status) { - case 'DRAFT': + case "DRAFT": return Freight.InvoiceStatus.Draft; - case 'PARTIALLY_PAID': + case "PARTIALLY_PAID": return Freight.InvoiceStatus.PartiallyPaid; - case 'PAID': + case "PAID": return Freight.InvoiceStatus.Paid; - case 'CANCELLED': + case "CANCELLED": return Freight.InvoiceStatus.Cancelled; default: return Freight.InvoiceStatus.Issued; @@ -503,39 +578,54 @@ export class WarehouseInvoiceService { /** Map a warehouse fee invoice view onto the shared document model. */ private toDocumentModel( invoice: WarehouseFeeInvoiceDetail, - kind: 'INVOICE' | 'RECEIPT', + kind: "INVOICE" | "RECEIPT", ): InvoiceDocumentModel { const lastPayment = [...(invoice.payments ?? [])].pop(); const date = (value: unknown) => - value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null; + value + ? new Date(value as string | Date).toLocaleDateString("en-GB") + : null; return { kind, - title: 'Warehouse Fee', + title: "Warehouse Fee", documentNumber: invoice.invoiceNumber, issuedAt: invoice.issuedAt ?? invoice.createdAt, status: invoice.status, currency: invoice.currency, summary: [ - { label: 'Status', value: invoice.status.replace(/_/g, ' ') }, - { label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') }, - { label: 'Booking reference', value: invoice.bookingReference ?? null }, - { label: 'Customer', value: invoice.customerName ?? null }, - { label: 'Inventory reference', value: invoice.inventoryReference ?? null }, - { label: 'Inventory info', value: invoice.inventoryInfo ?? null }, - { label: 'Clearance', value: invoice.clearanceStatus ?? null }, - { label: 'Warehouse', value: invoice.warehouseName ?? null }, + { label: "Status", value: invoice.status.replace(/_/g, " ") }, { - label: 'Yard / Zone', - value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null, + label: "Invoice type", + value: invoice.invoiceType.replace(/_/g, " "), }, - { label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` }, + { label: "Booking reference", value: invoice.bookingReference ?? null }, + { label: "Customer", value: invoice.customerName ?? null }, { - label: 'Payment', - value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null, + label: "Inventory reference", + value: invoice.inventoryReference ?? null, + }, + { label: "Inventory info", value: invoice.inventoryInfo ?? null }, + { label: "Clearance", value: invoice.clearanceStatus ?? null }, + { label: "Warehouse", value: invoice.warehouseName ?? null }, + { + label: "Yard / Zone", + value: + [invoice.yardName, invoice.zoneName].filter(Boolean).join(" / ") || + null, + }, + { + label: "Period", + value: `${date(invoice.periodStart) ?? "-"} - ${date(invoice.periodEnd) ?? "-"}`, + }, + { + label: "Payment", + value: lastPayment + ? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}` + : null, }, ], - categoryHeader: 'Fee type', + categoryHeader: "Fee type", lines: invoice.items.map((item) => ({ description: item.description ?? null, category: item.feeType ?? null, @@ -545,17 +635,19 @@ export class WarehouseInvoiceService { currency: item.currency ?? invoice.currency, })), totals: [ - { label: 'Subtotal', amount: Number(invoice.subtotalAmount) }, - { label: 'Tax', amount: Number(invoice.taxAmount) }, - { label: 'Total', amount: Number(invoice.totalAmount), grand: true }, - { label: 'Paid', amount: Number(invoice.paidAmount) }, - { label: 'Balance', amount: Number(invoice.balanceAmount) }, + { label: "Subtotal", amount: Number(invoice.subtotalAmount) }, + { label: "Tax", amount: Number(invoice.taxAmount) }, + { label: "Total", amount: Number(invoice.totalAmount), grand: true }, + { label: "Paid", amount: Number(invoice.paidAmount) }, + { label: "Balance", amount: Number(invoice.balanceAmount) }, ], }; } /** Warehouse-specific display details, derived from the linked inventory item. */ - private async getInvoiceDocumentDetails(invoice: ViewSource): Promise { + private async getInvoiceDocumentDetails( + invoice: ViewSource, + ): Promise { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", @@ -591,12 +683,12 @@ export class WarehouseInvoiceService { [invoice.sourceId], ); - const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID'; + const fullyPaid = this.toWarehouseStatus(invoice.status) === "PAID"; const clearanceStatus = row?.releaseDate - ? 'RELEASE ISSUED' + ? "RELEASE ISSUED" : fullyPaid - ? 'FEE PAID - READY FOR RELEASE' - : 'PENDING PAYMENT'; + ? "FEE PAID - READY FOR RELEASE" + : "PENDING PAYMENT"; return { bookingReference: row?.bookingReference ?? null, @@ -613,7 +705,9 @@ export class WarehouseInvoiceService { }; } - private async getInventoryContext(inventoryId: string): Promise { + private async getInventoryContext( + inventoryId: string, + ): Promise { const [row] = await this.dataSource.query( `SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", @@ -701,55 +795,89 @@ export class WarehouseInvoiceService { }; } - private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise { + private async sendSms( + recipient: string | null | undefined, + message: string, + context: string, + ): Promise { const phone = recipient?.trim(); if (!phone) return; try { - await this.notifications.directSend('sms', phone, message); + await this.notifications.directSend("sms", phone, message); } catch (error) { - this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`); + this.logger.error( + `Failed to send ${context} SMS to ${phone}: ${String(error)}`, + ); } } - private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); - const customerName = contacts.customerName?.trim() || 'Customer'; - const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + private async notifyWarehouseFeeIssued( + invoice: WarehouseFeeInvoiceView, + ): Promise { + const contacts = await this.getInvoiceNotificationContacts( + invoice.inventoryId, + ); + const customerName = contacts.customerName?.trim() || "Customer"; + const bookingReference = contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : ""; const cargo = contacts.containerNumber || contacts.cargoDescription; - const cargoText = cargo ? ` Cargo: ${cargo}.` : ''; + const cargoText = cargo ? ` Cargo: ${cargo}.` : ""; const message = - `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` + + `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ` + `${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` + `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`; - await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`); + await this.sendSms( + contacts.customerPhone, + message, + `warehouse fee invoice ${invoice.invoiceNumber}`, + ); } - private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); - const customerName = contacts.customerName?.trim() || 'Customer'; - const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + private async notifyWarehouseFeePayment( + invoice: WarehouseFeeInvoiceView, + dto: PayInvoiceDto, + ): Promise { + const contacts = await this.getInvoiceNotificationContacts( + invoice.inventoryId, + ); + const customerName = contacts.customerName?.trim() || "Customer"; + const bookingReference = contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : ""; const statusText = - invoice.status === 'PAID' - ? 'fully paid and ready for pickup release' + invoice.status === "PAID" + ? "fully paid and ready for pickup release" : `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`; const customerMessage = `Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` + `was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`; - await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`); + await this.sendSms( + contacts.customerPhone, + customerMessage, + `warehouse fee payment ${invoice.invoiceNumber}`, + ); - if (invoice.status !== 'PAID') return; + if (invoice.status !== "PAID") return; const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone; - const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver'; + const driverName = + dto.driverName?.trim() || contacts.driverName || "Driver"; const cargo = contacts.containerNumber || contacts.cargoDescription; const driverMessage = `Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` + - (contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') + - (cargo ? ` Cargo: ${cargo}.` : '') + - ' Proceed with pickup after gate verification.'; + (contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : "") + + (cargo ? ` Cargo: ${cargo}.` : "") + + " Proceed with pickup after gate verification."; - await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`); + await this.sendSms( + driverPhone, + driverMessage, + `warehouse pickup driver ${invoice.invoiceNumber}`, + ); } }