train gate pass, Telebirr and Wafi

This commit is contained in:
hagiye
2026-07-01 16:23:52 +03:00
parent 38db9a4177
commit 14a00af341
41 changed files with 2807 additions and 151 deletions

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { ValidateNested } from 'class-validator';
export class TruckEntranceDto {
@@ -90,6 +90,11 @@ export class TruckEntranceDto {
@Min(0)
grossWeightKg?: number;
@ApiPropertyOptional({ description: 'Whether the customer truck was weighed at receipt.' })
@IsOptional()
@IsBoolean()
weighingRequired?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@@ -135,10 +140,11 @@ export class TruckEntranceDto {
@IsString()
truckType?: string;
@ApiProperty()
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
entranceTareWeightKg!: number;
entranceTareWeightKg?: number;
@ApiPropertyOptional()
@IsOptional()

View File

@@ -1,8 +1,27 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
export class FeeRuleTierDto {
@ApiProperty({ example: 4 })
@IsInt()
@Min(1)
fromDay!: number;
@ApiPropertyOptional({ example: 4, description: 'Inclusive. Leave empty for an open-ended tier.' })
@IsOptional()
@IsInt()
@Min(1)
toDay?: number | null;
@ApiProperty({ example: 2500 })
@IsNumber()
@Min(0)
ratePerDay!: number;
}
export class CreateFeeRuleDto {
@ApiProperty()
@IsString()
@@ -67,6 +86,13 @@ export class CreateFeeRuleDto {
@Min(0)
ratePerDay!: number;
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FeeRuleTierDto)
tiers?: FeeRuleTierDto[];
@ApiPropertyOptional({ default: 'USD' })
@IsOptional()
@IsString()

View File

@@ -4,6 +4,12 @@ import { Column, Entity, Index } from 'typeorm';
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
export interface WarehouseFeeTier {
fromDay: number;
toDay: number | null;
ratePerDay: number;
}
/**
* Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6).
* The most specific active rule (highest `specificity` then lowest `priority`) applies to an item.
@@ -54,6 +60,9 @@ export class WarehouseFeeRule extends BaseEntity {
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
ratePerDay!: number;
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
tiers!: WarehouseFeeTier[];
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string;

View File

@@ -1,9 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ExchangeService } from '@edr/api-common';
import { DataSource } from 'typeorm';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
interface ItemAttributes {
@@ -39,6 +39,15 @@ export interface FeePreview {
containerCount: number;
billableUnits: number;
amount: number;
tiers: Array<{
fromDay: number;
toDay: number | null;
appliedFromDay: number;
appliedToDay: number;
days: number;
ratePerDay: number;
amount: number;
}>;
}
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -57,11 +66,16 @@ export class WarehouseFeeService {
}
createRule(dto: CreateFeeRuleDto): Promise<WarehouseFeeRule> {
return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto });
return this.feeRuleRepository.create({
isActive: true,
priority: 100,
currency: 'USD',
...this.normalizeRuleInput(dto),
});
}
async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
const updated = await this.feeRuleRepository.update(id, dto);
const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto));
if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
return updated;
}
@@ -70,6 +84,40 @@ export class WarehouseFeeService {
return this.feeRuleRepository.softDelete(id);
}
private normalizeRuleInput<T extends CreateFeeRuleDto | UpdateFeeRuleDto>(dto: T): T {
if (dto.tiers === undefined) return dto;
const tiers = (dto.tiers ?? [])
.map((tier) => ({
fromDay: Number(tier.fromDay),
toDay: tier.toDay == null ? null : Number(tier.toDay),
ratePerDay: Number(tier.ratePerDay),
}))
.filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0);
for (const tier of tiers) {
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
throw new BadRequestException('Fee tier from day must be a positive whole number.');
}
if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) {
throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.');
}
if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) {
throw new BadRequestException('Fee tier rate per day must be zero or greater.');
}
}
const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity));
for (let i = 1; i < sorted.length; i += 1) {
const prev = sorted[i - 1];
const current = sorted[i];
if (prev.toDay == null || current.fromDay <= prev.toDay) {
throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.');
}
}
return { ...dto, tiers: sorted } as T;
}
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
const [row] = await this.dataSource.query(
`SELECT inv.arrived_at AS "arrivedAt",
@@ -82,16 +130,27 @@ export class WarehouseFeeService {
w.facility_id AS "facilityId",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode",
ctt.code AS "containerTypeCode",
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
LEFT JOIN LATERAL (
SELECT bc.container_type_id
FROM freight.booking_container bc
WHERE bc.booking_id = inv.booking_id
AND bc.deleted_at IS NULL
AND bc.container_type_id IS NOT NULL
ORDER BY bc.created_at ASC
LIMIT 1
) booking_container_type ON true
LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
FROM freight.booking_container bc
@@ -108,16 +167,26 @@ export class WarehouseFeeService {
private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null {
// Returns specificity score (#matched non-null scope fields), or null if any constraint fails.
let score = 0;
const check = (ruleVal: string | null | undefined, itemVal: string | null) => {
if (ruleVal == null) return true;
if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) {
const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null;
const check = (
ruleVal: string | null | undefined,
itemVal: string | null,
opts: { allowBoth?: boolean } = {},
) => {
const ruleCode = normalized(ruleVal);
if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true;
if (opts.allowBoth && ruleCode === 'BOTH') {
score += 1;
return true;
}
if (ruleCode === normalized(itemVal)) {
score += 1;
return true;
}
return false;
};
if (!check(rule.freightType, item.freightType)) return null;
if (!check(rule.tradeDirection, item.tradeDirection)) return null;
if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null;
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
if (!check(rule.containerType, item.containerTypeCode)) return null;
if (!check(rule.facilityId, item.facilityId)) return null;
@@ -153,6 +222,60 @@ export class WarehouseFeeService {
return Math.round(amount * rate * 100) / 100;
}
private calculateTieredAmount(
tiers: WarehouseFeeTier[] | null | undefined,
elapsedDays: number,
containerCount: number,
): {
sourceAmount: number;
billableUnits: number;
chargeableDays: number;
weightedRatePerDay: number;
tiers: FeePreview['tiers'];
} {
const sourceTiers = (tiers ?? [])
.map((tier) => ({
fromDay: Number(tier.fromDay),
toDay: tier.toDay == null ? null : Number(tier.toDay),
ratePerDay: Number(tier.ratePerDay),
}))
.filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay))
.sort((a, b) => a.fromDay - b.fromDay);
let sourceAmount = 0;
let tierDays = 0;
const appliedTiers: FeePreview['tiers'] = [];
for (const tier of sourceTiers) {
if (elapsedDays < tier.fromDay) continue;
const appliedFromDay = tier.fromDay;
const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays);
const days = Math.max(0, appliedToDay - appliedFromDay + 1);
if (days <= 0) continue;
const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100;
sourceAmount += amount;
tierDays += days;
appliedTiers.push({
fromDay: tier.fromDay,
toDay: tier.toDay,
appliedFromDay,
appliedToDay,
days,
ratePerDay: tier.ratePerDay,
amount,
});
}
return {
sourceAmount: Math.round(sourceAmount * 100) / 100,
billableUnits: tierDays * containerCount,
chargeableDays: tierDays,
weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0,
tiers: appliedTiers,
};
}
private async compute(
ruleType: FeeRuleType,
rule: WarehouseFeeRule | null,
@@ -176,13 +299,25 @@ export class WarehouseFeeService {
const elapsedDays = start
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
: 0;
const chargeableDays = Math.max(0, elapsedDays - freeDays);
const billableUnits = chargeableDays * containerCount;
const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100;
const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount);
const hasTiers = Boolean(rule?.tiers?.length);
const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays);
const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount;
const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
const convertedRatePerDay = ruleCurrency
? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency)
? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
: 0;
const convertedTiers = ruleCurrency
? await Promise.all(
tiered.tiers.map(async (tier) => ({
...tier,
ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
})),
)
: [];
return {
ruleType,
@@ -201,6 +336,7 @@ export class WarehouseFeeService {
containerCount,
billableUnits,
amount,
tiers: hasTiers ? convertedTiers : [],
};
}

View File

@@ -221,6 +221,11 @@ export interface EligibleBookingRow {
firstMileDriverPhone: string | null;
firstMileDriverLicenseNumber: string | null;
firstMileTruckType: string | null;
customerTruckPlateNumber: string | null;
customerTruckDriverName: string | null;
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
}
export interface BulkReceiveResult {
@@ -302,6 +307,11 @@ export interface ImportUnloadedRow {
inspectionStatus: string | null;
pickupOption: string;
lastMileRequested: boolean;
customerTruckPlateNumber: string | null;
customerTruckDriverName: string | null;
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
@@ -704,7 +714,12 @@ export class WarehouseInventoryService {
) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType"
v.vehicle_type AS "firstMileTruckType",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
@@ -796,7 +811,12 @@ export class WarehouseInventoryService {
) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType"
v.vehicle_type AS "firstMileTruckType",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
@@ -1044,6 +1064,11 @@ export class WarehouseInventoryService {
CASE WHEN b.last_mile_delivery_address IS NOT NULL
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
@@ -1336,6 +1361,16 @@ export class WarehouseInventoryService {
if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) {
throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
}
const [gatepass] = await this.dataSource.query(
`SELECT gatepass_granted_at AS "gatepassSecuredAt"
FROM freight.import_djibouti_operations
WHERE train_schedule_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!gatepass?.gatepassSecuredAt) {
throw new BadRequestException('Djibouti Port entry blocked: gate pass status is NOT_SECURED');
}
const items: Array<{
bookingId: string;
@@ -1955,6 +1990,9 @@ export class WarehouseInventoryService {
}
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
if (isTruckLeaving) {
await this.invoices.assertClearanceAllowed(id);
}
const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
: item.releaseDate ?? null;
@@ -1967,6 +2005,17 @@ export class WarehouseInventoryService {
releaseOrderReference: reference,
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
});
if (!isTruckLeaving && item.bookingId) {
await manager.query(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
updated_at = NOW()
WHERE id = $1
AND customer_truck_assigned_at IS NOT NULL
AND deleted_at IS NULL`,
[item.bookingId],
);
}
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
@@ -2034,6 +2083,7 @@ export class WarehouseInventoryService {
if (!row.releaseDate) {
throw new BadRequestException('A release order must be issued before downloading the exit paper');
}
await this.invoices.assertClearanceAllowed(id);
const bookingReference = row?.bookingReference || 'N/A';
const reference =
@@ -2180,11 +2230,19 @@ export class WarehouseInventoryService {
throw new BadRequestException('Please save your signature before approving delivery');
}
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> =
const [item]: Array<{
id: string;
warehouseId: string | null;
notes: string | null;
customerTruckAssignedAt: string | null;
customerTruckArrivedAt: string | null;
}> =
await this.dataSource.query(
`SELECT inv.id,
inv.warehouse_id AS "warehouseId",
inv.notes
inv.notes,
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
b.customer_truck_arrived_at AS "customerTruckArrivedAt"
FROM freight.warehouse_inventory inv
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
WHERE inv.booking_id = $1
@@ -2198,6 +2256,10 @@ export class WarehouseInventoryService {
if (!item) {
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
}
if (item.customerTruckAssignedAt && !item.customerTruckArrivedAt) {
throw new BadRequestException('Customer truck arrival must be recorded before delivery approval');
}
await this.invoices.assertClearanceAllowed(item.id);
const approvedAt = new Date();
const approval = {
@@ -2301,6 +2363,7 @@ export class WarehouseInventoryService {
if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
await this.invoices.assertClearanceAllowed(id);
if (row.inspectionStatus !== 'PASSED') {
throw new BadRequestException('Handover document is available after inspection has passed');
}
@@ -3302,8 +3365,13 @@ export class WarehouseInventoryService {
if (!truckEntrance.driverPhone?.trim()) {
throw new BadRequestException('Driver phone is required for entrance registration');
}
if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) {
throw new BadRequestException('Entrance tare weight is required for entrance registration');
if (truckEntrance.weighingRequired) {
if (truckEntrance.grossWeightKg === undefined || Number(truckEntrance.grossWeightKg) < 0) {
throw new BadRequestException('Gross weight is required when customer truck weighing is Yes');
}
if (truckEntrance.exitTareWeightKg === undefined || Number(truckEntrance.exitTareWeightKg) < 0) {
throw new BadRequestException('Exit tare weight is required when customer truck weighing is Yes');
}
}
}
@@ -3325,6 +3393,11 @@ export class WarehouseInventoryService {
firstMileDriverPhone?: string | null;
firstMileDriverLicenseNumber?: string | null;
firstMileTruckType?: string | null;
customerTruckPlateNumber?: string | null;
customerTruckDriverName?: string | null;
customerTruckType?: string | null;
customerTruckContainerNumber?: string | null;
customerTruckAssignedAt?: string | null;
},
): TruckEntranceDto {
return {
@@ -3340,16 +3413,22 @@ export class WarehouseInventoryService {
booking.containerQuantity !== undefined && booking.containerQuantity !== null
? Number(booking.containerQuantity)
: submitted.unitCount,
grossWeightKg:
booking.weight !== undefined && booking.weight !== null
? Number(booking.weight)
: submitted.grossWeightKg,
truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber,
grossWeightKg: submitted.grossWeightKg,
truckPlateNumber:
booking.firstMileTruckPlateNumber?.trim() ||
booking.customerTruckPlateNumber?.trim() ||
submitted.truckPlateNumber,
trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
driverName: booking.firstMileDriverName?.trim() || submitted.driverName,
driverName:
booking.firstMileDriverName?.trim() ||
booking.customerTruckDriverName?.trim() ||
submitted.driverName,
driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
truckType: booking.firstMileTruckType?.trim() || submitted.truckType,
truckType:
booking.firstMileTruckType?.trim() ||
booking.customerTruckType?.trim() ||
submitted.truckType,
};
}
@@ -3622,6 +3701,7 @@ export class WarehouseInventoryService {
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
@@ -86,4 +87,10 @@ export class WarehouseInvoiceController {
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
return this.invoiceService.pay(id, dto);
}
@Post('warehouse-fee-invoices/:id/pay-online')
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
return this.invoiceService.initiatePayment(id, dto);
}
}

View File

@@ -3,6 +3,7 @@ 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';
@@ -171,8 +172,12 @@ export class WarehouseInvoiceService {
feeType,
description:
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`,
? `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,
@@ -305,6 +310,22 @@ export class WarehouseInvoiceService {
return detail;
}
/** Initiate a wallet/gateway payment for the invoice. */
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.');
}
return this.billing.payInvoice(invoice.source as Freight.InvoiceSource, invoice.sourceId, {
method: dto.method ?? (invoice.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'),
platform: dto.platform ?? 'web',
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
/**
* Notify on online (gateway) settlement — the domain side-effect of a warehouse
* fee being paid through billing's payment flow. The counter {@link pay} path