From 787da1ccc08d009fba9fa79bbf7736d161c7e6f6 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 2 Jul 2026 12:35:43 +0000 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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}`, + ); } } From 60e0c269430fae98cdd4c4b864f6e97285420032 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 2 Jul 2026 16:35:54 +0300 Subject: [PATCH 6/6] feat: ( iam ) add portal auth registration, forgot/change password, Fayda setup --- .../portal/src/app/fayda-setup/page.tsx | 5 + .../portal/src/app/forgot-password/page.tsx | 104 +++++++ .../portal/src/app/login/page.tsx | 29 +- .../portal/src/app/register/page.tsx | 176 ++++++++++++ .../portal/src/app/reset-password/page.tsx | 154 +++++++++++ .../portal/src/app/set-password/page.tsx | 24 ++ .../portal/src/components/AppHeader.tsx | 94 ++++++- .../src/components/ChangePasswordModal.tsx | 157 +++++++++++ .../src/components/FaydaSetupWizard.tsx | 256 ++++++++++++++++++ .../portal/src/lib/api/auth.ts | 51 ++++ .../portal/src/lib/auth-store.ts | 14 +- 11 files changed, 1058 insertions(+), 6 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/app/fayda-setup/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/register/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/reset-password/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/set-password/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx create mode 100644 apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx create mode 100644 apps/edr-passenger-web/portal/src/lib/api/auth.ts diff --git a/apps/edr-passenger-web/portal/src/app/fayda-setup/page.tsx b/apps/edr-passenger-web/portal/src/app/fayda-setup/page.tsx new file mode 100644 index 000000000..8f36e4039 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/fayda-setup/page.tsx @@ -0,0 +1,5 @@ +import FaydaSetupWizard from '@/components/FaydaSetupWizard'; + +export default function FaydaSetupPage() { + return ; +} diff --git a/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx b/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx new file mode 100644 index 000000000..254b65eb8 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { Train, MailCheck, ArrowLeft } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [sent, setSent] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + await iamAuthApi.forgotPassword(email); + setSent(true); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError( + msg === 'user_not_found' + ? 'No account found with that email address.' + : msg || 'Failed to send the reset link. Please try again.' + ); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Reset your password

+

+ Enter your email and we'll send a reset link to the phone number on your account. +

+
+ +
+ {sent ? ( +
+
+ +

+ A password reset link has been sent via SMS. Open it to set a new password — the link expires in 30 minutes. +

+
+ + + Back to sign in + +
+ ) : ( + <> +
+ {error && ( +
+ {error} +
+ )} + +
+ + { setEmail(e.target.value); setError(''); }} + className="input-field" + placeholder="your@email.com" + autoComplete="email" + required + /> +
+ + +
+ +
+ + + Back to sign in + +
+ + )} +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/login/page.tsx b/apps/edr-passenger-web/portal/src/app/login/page.tsx index 86ac4dcce..9453c39e2 100644 --- a/apps/edr-passenger-web/portal/src/app/login/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/login/page.tsx @@ -6,7 +6,8 @@ import { z } from 'zod'; import { useRouter, useSearchParams } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { useState, Suspense } from 'react'; -import { Train } from 'lucide-react'; +import Link from 'next/link'; +import { Train, ShieldCheck } from 'lucide-react'; const loginSchema = z.object({ email: z.string().email('Invalid email address'), @@ -85,6 +86,14 @@ function LoginContent() { {errors.password && (

{errors.password.message}

)} +
+ + Forgot password? + +
-
+
+
+ Don't have an account? + + Create account + +
+ + + Already verified with Fayda? Set up your password + +
+ +
+ + +
+ + + Already verified with Fayda? Set up your password + +
+ +
+ Already have an account? + + Sign in + +
+
+
+ + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/reset-password/page.tsx b/apps/edr-passenger-web/portal/src/app/reset-password/page.tsx new file mode 100644 index 000000000..da61c9da5 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/reset-password/page.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; + +function ResetPasswordContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const email = searchParams.get('email') || ''; + const userId = searchParams.get('userId') || ''; + const verificationCode = searchParams.get('verificationCode') || ''; + const linkValid = Boolean(email && userId && verificationCode); + + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (newPassword.length < 6) { + setError('Password must be at least 6 characters.'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + setLoading(true); + try { + await iamAuthApi.resetPassword({ userId, email, verificationCode, newPassword, confirmPassword }); + setSuccess(true); + setTimeout(() => router.push('/login'), 2000); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError(msg || 'Failed to reset password. The link may have expired — request a new one.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Set a new password

+ {linkValid && !success && ( +

+ Choose a new password for {email}. +

+ )} +
+ +
+ {!linkValid ? ( +
+
+ This password reset link is invalid or incomplete. Request a new one from the sign-in page. +
+ + Request a new link + + + + Back to sign in + +
+ ) : success ? ( +
+
+ +

Password reset successfully. Redirecting to sign in…

+
+ + Go to sign in + + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ +
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ + + + + + Back to sign in + + + )} +
+
+
+ ); +} + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/set-password/page.tsx b/apps/edr-passenger-web/portal/src/app/set-password/page.tsx new file mode 100644 index 000000000..2c270f727 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/set-password/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import FaydaSetupWizard from '@/components/FaydaSetupWizard'; + +// Landing page for the IAM's Fayda set-password SMS link: +// ${FE_BASE_URL}/set-password?email=..&userId=..&verificationCode=.. +// The wizard starts at step 2 with the code prefilled; the user enters their +// phone number (verify-and-login requires it) and a new password. +function SetPasswordContent() { + const searchParams = useSearchParams(); + const verificationCode = searchParams.get('verificationCode') || ''; + + return ; +} + +export default function SetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx index 6422e12fa..c3d68365c 100644 --- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx @@ -1,18 +1,24 @@ "use client"; -import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react"; +import { Menu, X, Moon, Sun, HelpCircle, KeyRound, LogOut, ChevronDown } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useEffect, useState } from "react"; +import { useAuthStore } from "@/lib/auth-store"; +import ChangePasswordModal from "@/components/ChangePasswordModal"; export default function AppHeader() { const [isOpen, setIsOpen] = useState(false); const [isDark, setIsDark] = useState(false); + const [showUserMenu, setShowUserMenu] = useState(false); + const [showChangePassword, setShowChangePassword] = useState(false); + const { user, isAuthenticated, initialize, logout } = useAuthStore(); useEffect(() => { const isDarkMode = document.documentElement.classList.contains("dark"); setIsDark(isDarkMode); - }, []); + initialize(); + }, [initialize]); const toggleTheme = () => { const html = document.documentElement; @@ -84,6 +90,67 @@ export default function AppHeader() { )} + {/* Auth */} + {isAuthenticated && user ? ( +
+ + + {showUserMenu && ( +
+
+

{user.fullName}

+

{user.email}

+
+
+ + +
+
+ )} +
+ ) : ( +
+ + Sign in + + + Register + +
+ )} + {/* Mobile Menu Button */} + +
+ {success && ( +
+ +

Password changed successfully.

+
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + { setCurrentPassword(e.target.value); setError(''); }} + className="input-field" + autoComplete="current-password" + required + /> +
+
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + autoComplete="new-password" + minLength={6} + required + /> +
+
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + autoComplete="new-password" + minLength={6} + required + /> +
+
+ + +
+
+ + , + document.body + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx b/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx new file mode 100644 index 000000000..92f3843fc --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; + +interface FaydaSetupWizardProps { + // Prefilled OTP when landing from the SMS link (/set-password?verificationCode=...) + initialOtp?: string; +} + +type Outcome = 'success' | 'hasPassword' | null; + +export default function FaydaSetupWizard({ initialOtp }: FaydaSetupWizardProps) { + const router = useRouter(); + const [step, setStep] = useState<1 | 2>(initialOtp ? 2 : 1); + const [phone, setPhone] = useState(''); + const [otp, setOtp] = useState(initialOtp || ''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [outcome, setOutcome] = useState(null); + + const handleRequestCode = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + await iamAuthApi.faydaRequestPasswordSetup(phone); + setStep(2); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to send the code. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleSetPassword = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (newPassword.length < 6) { + setError('Password must be at least 6 characters.'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + setLoading(true); + try { + const res = await iamAuthApi.faydaVerifyAndLogin({ phoneNumber: phone, otp }); + const data = (res.data as any)?.data ?? res.data; + if (!data.requiresPassword) { + setOutcome('hasPassword'); + return; + } + await iamAuthApi.setFaydaPassword( + { userId: data.iamUserId, newPassword, confirmPassword }, + data.token, + ); + setOutcome('success'); + setTimeout(() => router.push('/login'), 2500); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError(msg || 'Verification failed. The code may be wrong or expired.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

+ + Fayda account setup +

+

+ Already verified with Fayda? Set a password to access your account online. +

+
+ +
+ {outcome === 'success' ? ( +
+
+ +

+ Your password has been set and your account is now active. Redirecting to sign in… +

+
+ + Go to sign in + + +
+ ) : outcome === 'hasPassword' ? ( +
+
+ +

+ This account already has a password. Sign in with your email or phone number, + or use forgot password if you can't remember it. +

+
+ + Sign in + + + Forgot password? + +
+ ) : step === 1 ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + { setPhone(e.target.value); setError(''); }} + className="input-field" + placeholder="+251912345678" + autoComplete="tel" + required + /> +

+ The phone number you used during Fayda verification. +

+
+ + +
+ ) : ( +
+
+ +

+ If this phone number is Fayda-verified, an SMS with a verification code has been sent. + Enter it below with your new password. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + { setPhone(e.target.value); setError(''); }} + className="input-field" + placeholder="+251912345678" + autoComplete="tel" + required + /> +
+ +
+ + { setOtp(e.target.value); setError(''); }} + className="input-field" + placeholder="6-character code from SMS" + maxLength={6} + required + /> +
+ +
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ +
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ + + + +
+ )} + + {outcome === null && ( +
+ + + Back to sign in + +
+ )} +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/lib/api/auth.ts b/apps/edr-passenger-web/portal/src/lib/api/auth.ts new file mode 100644 index 000000000..aa5272173 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/api/auth.ts @@ -0,0 +1,51 @@ +import axios from 'axios'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; + +// IAM (/v1/auth/*) and Fayda (/auth/fayda/*) endpoints use raw axios instead of +// apiClient: apiClient's response interceptor clears the token and redirects to +// /login on any 401 for non-public URLs — but the IAM returns 401 when the +// current password is wrong on change-password, and OTP failures must surface +// as inline errors, not a logout. +export const iamAuthApi = { + forgotPassword: (email: string) => + axios.post(`${API_URL}/v1/auth/forgot-password`, { email }), + + // Completes the forgot-password flow using the link sent via SMS: + // ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=.. + resetPassword: (data: { + userId: string; + email: string; + verificationCode: string; + newPassword: string; + confirmPassword: string; + }) => axios.patch(`${API_URL}/v1/auth/set-password`, data), + + changePassword: (data: { + oldPassword: string; + newPassword: string; + confirmPassword: string; + }) => + axios.patch(`${API_URL}/v1/auth/change-password`, data, { + headers: { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }, + }), + + faydaRequestPasswordSetup: (phoneNumber: string) => + axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }), + + faydaVerifyAndLogin: (data: { phoneNumber: string; otp: string }) => + axios.post<{ + success: boolean; + data: { token: string; refreshToken: string; requiresPassword: boolean; iamUserId: string }; + }>(`${API_URL}/auth/fayda/verify-and-login`, data), + + // Bearer token comes from faydaVerifyAndLogin's response, not localStorage — + // the user is not logged into the portal at this point. + setFaydaPassword: ( + data: { userId: string; newPassword: string; confirmPassword: string }, + token: string, + ) => + axios.patch(`${API_URL}/v1/auth/set-fayda-password`, data, { + headers: { Authorization: `Bearer ${token}` }, + }), +}; diff --git a/apps/edr-passenger-web/portal/src/lib/auth-store.ts b/apps/edr-passenger-web/portal/src/lib/auth-store.ts index 72a89ce87..2157cfc0a 100644 --- a/apps/edr-passenger-web/portal/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/auth-store.ts @@ -40,10 +40,11 @@ interface AuthState { } interface RegisterData { + fullName: string; email: string; phone: string; - fullName: string; password: string; + confirmPassword: string; } export const useAuthStore = create((set, get) => ({ @@ -118,7 +119,16 @@ export const useAuthStore = create((set, get) => ({ }, register: async (data: RegisterData) => { - const response: any = await apiClient.post('/auth/register', data); + // Shape required by the passenger-api RegisterDto; username = email by convention. + const payload = { + email: data.email, + username: data.email, + phoneNumber: data.phone, + name: { en: data.fullName, am: data.fullName }, + password: data.password, + confirmPassword: data.confirmPassword, + }; + const response: any = await apiClient.post('/auth/register', payload); const { token, user } = response.data || response; if (typeof window !== 'undefined') {