Merge pull request #406 from Tria-plc/freight/feat/fixes-v1

Freight/feat/fixes v1
This commit is contained in:
Nathnael Wondisha
2026-07-02 16:44:43 +03:00
committed by GitHub
5 changed files with 436 additions and 226 deletions

View File

@@ -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) ───────────────────────────────────────
@@ -823,7 +829,10 @@ export class BillingService {
): Promise<void> {
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(
@@ -881,7 +890,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}`,

View File

@@ -97,6 +97,9 @@ export class BookingInvoiceService {
*/
@OnEvent("booking.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
this.logger.log(
`onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`,
);
switch (payload.type) {
case "PREPAID":
await this.advanceBookingOnPayment(payload.sourceId);
@@ -143,9 +146,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) {

View File

@@ -1,19 +1,19 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { 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 +26,10 @@ type FirstMileListFilter = {
};
const SORTABLE_FIELDS: (keyof FirstMile)[] = [
'status',
'advancedPayment',
'remainingPayment',
'createdAt',
"status",
"advancedPayment",
"remainingPayment",
"createdAt",
];
@Injectable()
@@ -43,26 +43,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<FirstMile> {
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
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<FirstMile> {
async acceptBookingByReference(
bookingReference: string,
): Promise<FirstMile | null> {
const [booking] = await this.bookingsRepository.findAll({
where: { reference: bookingReference },
relations: { serviceType: true },
@@ -89,20 +91,18 @@ export class FirstMileService {
tradeDirection?: string | null;
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): Promise<FirstMile> {
const label = booking.reference ?? booking.id;
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException(`Booking ${label} is not paid`);
}): Promise<FirstMile | null> {
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 +118,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<FirstMile> = {};
if (filter.status) where.status = filter.status;
@@ -129,7 +130,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 +158,12 @@ export class FirstMileService {
@OnEvent("firstmile.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
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 +174,13 @@ export class FirstMileService {
async findById(id: string): Promise<FirstMile> {
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 +200,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 +214,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 +236,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 +249,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 +285,63 @@ export class FirstMileService {
return updated;
}
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
private async notifyDriverAssignment(
vehicleId: string,
record: FirstMile,
): Promise<void> {
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 +369,7 @@ export class FirstMileService {
firstMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
containerType: "CONTAINER",
quantity: 1,
});
}

View File

@@ -189,48 +189,53 @@ export class PaymentService {
* has stored the intent id, avoiding a settle-before-correlation race.
*/
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
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. */

View File

@@ -1,30 +1,41 @@
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 { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
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 { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
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 {
@@ -46,7 +57,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;
@@ -121,10 +135,13 @@ export class WarehouseInvoiceService {
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly feeService: WarehouseFeeService,
private readonly notifications: NotificationsService,
) {}
) { }
// ── Generation ───────────────────────────────────────────────────────────
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoiceDetail> {
async generateForInventory(
inventoryId: string,
opts: GenerateOptions = {},
): Promise<WarehouseFeeInvoiceDetail> {
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",
@@ -137,47 +154,53 @@ 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'
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${
p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free`
}`
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${
p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free`
}`,
p.ruleType === "STORAGE_FEE"
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
? " using tiered tariff"
: ` after ${p.freeDays} free`
}`
: `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
? " using tiered tariff"
: ` after ${p.freeDays} free`
}`,
quantity: p.billableUnits,
unitRate: p.ratePerDay,
amount: p.amount,
@@ -189,13 +212,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,
@@ -237,18 +266,23 @@ export class WarehouseInvoiceService {
}
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> {
return this.queryViews('AND i.source_id = $1', [inventoryId]);
return this.queryViews("AND i.source_id = $1", [inventoryId]);
}
listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> {
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<WarehouseFeeInvoiceView[]> {
@@ -259,41 +293,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<WarehouseFeeInvoiceDetail> {
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<WarehouseFeeInvoiceDetail> {
async pay(
id: string,
dto: PayInvoiceDto,
): Promise<WarehouseFeeInvoiceDetail> {
// Guard that this is a warehouse invoice before recording (404 otherwise).
await this.loadWarehouseInvoice(id);
await this.billing.recordPayment(id, {
@@ -302,7 +351,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);
@@ -314,12 +366,12 @@ export class WarehouseInvoiceService {
async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) {
const invoice = await this.loadWarehouseInvoice(id);
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('Invoice is already fully paid.');
throw new BadRequestException("Invoice is already fully paid.");
}
return this.billing.payInvoice(invoice.source as Freight.InvoiceSource, invoice.sourceId, {
method: dto.method ?? (invoice.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'),
platform: dto.platform ?? 'web',
return this.billing.payInvoice(invoice.id, {
method: dto.method ?? (invoice.currency === "USD" ? "WAAFI" : "TELEBIRR"),
platform: dto.platform ?? "web",
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
@@ -334,16 +386,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<void> {
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<WarehouseFeeInvoiceView | null> {
async findBlockingInvoice(
inventoryId: string,
): Promise<WarehouseFeeInvoiceView | null> {
const blocking = await this.queryViews(
`AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
[inventoryId, BLOCKING_STATUSES],
@@ -352,21 +408,31 @@ export class WarehouseInvoiceService {
}
async assertClearanceAllowed(inventoryId: string): Promise<void> {
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.",
);
}
}
@@ -374,7 +440,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<Invoice & { lines: InvoiceLine[] }> {
private async loadWarehouseInvoice(
id: string,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.billing.findById(id);
if (invoice.source !== SOURCE) {
throw new NotFoundException(`Invoice ${id} not found`);
@@ -397,7 +465,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<WarehouseFeeInvoiceView[]> {
private async queryViews(
extraWhere: string,
params: unknown[],
): Promise<WarehouseFeeInvoiceView[]> {
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,
@@ -410,7 +481,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`,
@@ -430,7 +501,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,
@@ -457,7 +531,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,
@@ -479,7 +553,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),
@@ -489,32 +563,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;
@@ -524,39 +602,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,
@@ -566,17 +659,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<InvoiceDocumentDetails> {
private async getInvoiceDocumentDetails(
invoice: ViewSource,
): Promise<InvoiceDocumentDetails> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
@@ -612,12 +707,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,
@@ -634,7 +729,9 @@ export class WarehouseInvoiceService {
};
}
private async getInventoryContext(inventoryId: string): Promise<InventoryContext> {
private async getInventoryContext(
inventoryId: string,
): Promise<InventoryContext> {
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",
@@ -722,55 +819,89 @@ export class WarehouseInvoiceService {
};
}
private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise<void> {
private async sendSms(
recipient: string | null | undefined,
message: string,
context: string,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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}`,
);
}
}