Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-09 13:44:18 +00:00
79 changed files with 1463 additions and 239 deletions

View File

@@ -36,4 +36,27 @@ describe('assertExportReceivedWithGrn', () => {
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
).resolves.toBeUndefined();
});
it('never blocks direct truck-to-train export — that cargo has no GRN by design', async () => {
const source = db([]);
await expect(
assertExportReceivedWithGrn(source, {
id: 'b-1',
tradeDirection: 'EXPORT',
exportHandoverMode: 'DIRECT_TO_TRAIN',
}),
).resolves.toBeUndefined();
// Direct short-circuits before querying — there is no inventory to look for.
expect(source.query as jest.Mock).not.toHaveBeenCalled();
});
it('still gates a warehouse export booking', async () => {
await expect(
assertExportReceivedWithGrn(db([]), {
id: 'b-1',
tradeDirection: 'EXPORT',
exportHandoverMode: 'WAREHOUSE',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -5,8 +5,15 @@ import type { DataSource, EntityManager } from 'typeorm';
export interface ExportLoadGateBooking {
id: string;
tradeDirection?: string | null;
/** 'DIRECT_TO_TRAIN' skips the gate entirely; null/'WAREHOUSE' keeps it. */
exportHandoverMode?: string | null;
}
/** Direct truck-to-train: the cargo never sees a warehouse, so it never has a GRN. */
export const DIRECT_TO_TRAIN = 'DIRECT_TO_TRAIN';
/** Warehouse-then-train: the existing flow. Also what a null mode means. */
export const WAREHOUSE = 'WAREHOUSE';
/**
* Export cargo may not be loaded onto its train until it has physically reached
* the warehouse and been issued a GRN — whether it got there by first-mile or by
@@ -21,12 +28,18 @@ export interface ExportLoadGateBooking {
* "Received with a GRN" = an inventory row that has reached the warehouse
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
* fallback older rows use.
*
* Export has a second, warehouse-free shape: the customer's truck loads straight
* onto the wagon. That cargo is never received and never GRN'd, so a booking
* marked DIRECT_TO_TRAIN is outside this gate by definition — its custody is
* attested by the carriage acceptance sheet instead.
*/
export async function assertExportReceivedWithGrn(
db: DataSource | EntityManager,
booking: ExportLoadGateBooking,
): Promise<void> {
if (booking.tradeDirection !== 'EXPORT') return;
if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return;
const [row] = await db.query(
`SELECT 1

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Export cargo reaches a train two ways, and until now only one was modelled.
*
* DIRECT_TO_TRAIN — the customer's truck pulls alongside and the cargo goes
* straight onto the wagon. It never enters a warehouse, so no GRN is ever
* raised; the Carriage Acceptance Sheet is the only document handed over.
*
* WAREHOUSE — cargo is received into the warehouse, GRN'd, then loaded. This is
* the existing flow and stays gated on the GRN.
*
* NULL means WAREHOUSE, so existing rows keep today's behaviour with no backfill.
*/
export class BookingExportHandoverMode3370000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS export_handover_mode varchar(20)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS export_handover_mode
`);
}
}

View File

@@ -1,19 +1,31 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
Res,
UploadedFile,
UseInterceptors,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express";
import {
ApiBearerAuth,
ApiConsumes,
ApiOperation,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { actorLabel } from "../warehouses/current-actor.util";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@@ -25,6 +37,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@BookingStaff([
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
FREIGHT_PERMS.invoices.confirmOffline,
])
@ApiBearerAuth()
export class BillingController {
@@ -56,6 +69,36 @@ export class BillingController {
return this.billingService.findById(id);
}
@Get("offline-usd")
@ApiOperation({
summary:
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context",
})
findOfflineUsd(@Query() query: FilterInvoiceDto) {
return this.billingService.findOfflineUsdPaginated(query);
}
@Post("invoices/:id/confirm-offline")
@BookingStaff(FREIGHT_PERMS.invoices.confirmOffline)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance",
})
confirmOffline(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body("reference") reference: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.billingService.confirmOfflinePayment(id, file, {
reference: reference?.trim() || null,
userId: resolveAuthUserId(user),
userName: actorLabel(user) ?? null,
});
}
@Get("invoices/:id/document")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed invoice PDF" })

View File

@@ -13,6 +13,7 @@ import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentModule } from "../payment/payment.module";
import { CompaniesModule } from "../companies/companies.module";
import { FilesModule } from "../files/files.module";
@Module({
imports: [
@@ -21,6 +22,7 @@ import { CompaniesModule } from "../companies/companies.module";
CompaniesModule,
DocumentsModule,
UserTradeAccessModule,
FilesModule,
],
controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],

View File

@@ -79,6 +79,7 @@ describe("BillingService.generateInvoice", () => {
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
);
});
@@ -140,6 +141,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -193,6 +195,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -236,6 +239,7 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
);
return { service, mg, events };
}
@@ -347,6 +351,7 @@ describe("BillingService.recordPayment", () => {
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
);
return { service, mg, events };
}
@@ -462,6 +467,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never,
{} as never,
{} as never,
{} as never,
);
return { service, defaultManager, txManager, transaction };
};
@@ -533,6 +539,7 @@ describe("BillingService.issuePayable", () => {
{} as never,
{} as never,
{} as never,
{} as never,
);
return { service, manager };
};
@@ -622,6 +629,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
payment as never,
{} as never,
{} as never,
{} as never,
);
return { service, repo };
};
@@ -669,11 +677,11 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
});
});
describe("BillingService — CBE bill amounts round UP to whole birr", () => {
// CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down
// settles 0.40 short while markInvoiceAsPaid still writes paidAmount =
// totalAmount — money missing from the bank with the books saying paid.
// payInvoice and billQuery must agree, or /cbe/payment sees a mismatch.
describe("BillingService — CBE bill amounts carry cents, never rounded", () => {
// CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the
// bill must quote the exact balance. Rounding UP overcharged the payer by up to
// a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount
// = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches.
const invoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
@@ -682,9 +690,9 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "ETB",
// .40the case Math.round gets wrong (rounds down, underpays).
balanceAmount: 12345.4,
totalAmount: 12345.4,
// .43cents that must survive all the way to the bill.
balanceAmount: 12345.43,
totalAmount: 12345.43,
company: { name: "Acme PLC" },
paymentId: null,
dueAt: null,
@@ -703,11 +711,12 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
payment as never,
{} as never,
{} as never,
{} as never,
);
return { service, repo };
};
it("opens the intent for the ceiled balance, never below it", async () => {
it("opens the intent for the exact balance, cents included", async () => {
const initiate = jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
@@ -718,16 +727,16 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
await service.payInvoice("inv-1", { method: "CBE_BILL" });
expect(initiate).toHaveBeenCalledWith(
expect.objectContaining({ amountMinor: 12346 }),
expect.objectContaining({ amountMinor: 12345.43 }),
);
});
it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => {
it("quotes the same exact amount on bill-query as payInvoice opened", async () => {
const { service } = build();
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
stillPayable: true,
currentAmountMinor: 12346,
currentAmountMinor: 12345.43,
});
});
});

View File

@@ -12,6 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
@@ -35,6 +36,14 @@ export interface PayInvoiceOptions {
failureUrl?: string;
}
/** Booking context attached to a finance offline-USD invoice row. */
export interface OfflineUsdBookingInfo {
id: string;
reference: string;
paymentDeadline: Date | null;
paymentStatus: string;
}
/** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */
@@ -150,6 +159,7 @@ export class BillingService {
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly files: FilesService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -214,6 +224,146 @@ export class BillingService {
return { items, total };
}
/**
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway), open ones by default or a single status when
* filtered. Booking-sourced rows carry the booking's reference and pay-window
* deadline so the UI can show the countdown and link to the booking.
*/
async findOfflineUsdPaginated(
filter: {
status?: Freight.InvoiceStatus;
search?: string;
page?: number;
pageSize?: number;
} = {},
): Promise<{
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
total: number;
}> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) = 'USD'")
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
} else {
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
const [items, total] = await qb.getManyAndCount();
const bookingIds = items
.filter((i) => i.source === "booking")
.map((i) => i.sourceId);
const bookings = bookingIds.length
? await this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) },
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
})
: [];
const byId = new Map(bookings.map((b) => [b.id, b]));
return {
items: items.map((inv) => {
const b = byId.get(inv.sourceId);
return {
...inv,
booking: b
? {
id: b.id,
reference: b.reference,
paymentDeadline: b.paymentDeadline ?? null,
paymentStatus: b.paymentStatus,
}
: null,
} as Invoice & { booking: OfflineUsdBookingInfo | null };
}),
total,
};
}
/**
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
* against the invoice and settles the FULL outstanding balance through
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
* emits `booking.invoice.paid` — the same event an online payment fires, so
* the booking advances exactly as if it had been paid through the gateway.
*
* Guarded by the booking's pay window: past the deadline the booking expires
* like any unpaid one, so confirmation is refused.
*/
async confirmOfflinePayment(
invoiceId: string,
file: Express.Multer.File | undefined,
input: {
reference?: string | null;
userId?: string | null;
userName?: string | null;
},
): Promise<Invoice> {
const invoice = await this.invoices.findById(invoiceId);
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.currency?.toUpperCase() !== "USD") {
throw new BadRequestException(
"Offline confirmation is only for USD invoices — this invoice is paid online.",
);
}
if (!file) {
throw new BadRequestException("The bank payment slip file is required.");
}
if (invoice.source === "booking") {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
select: ["id", "paymentDeadline"],
});
const deadline = booking?.paymentDeadline;
if (deadline && new Date(deadline).getTime() < Date.now()) {
throw new BadRequestException(
"The payment window has closed — this booking can no longer be confirmed as paid.",
);
}
}
const slip = await this.files.upload({
resource: "invoice",
resourceId: invoice.id,
code: "OFFLINE_PAYMENT_SLIP",
file,
title: "Bank payment slip",
uploadedByUserId: input.userId ?? null,
uploadedByName: input.userName ?? null,
});
return this.recordPayment(invoiceId, {
amount: Number(invoice.balanceAmount),
method: "BANK_TRANSFER",
reference: input.reference || slip.name,
metadata: {
offline: true,
slipFileId: slip.id,
confirmedByUserId: input.userId ?? null,
confirmedByName: input.userName ?? null,
},
});
}
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id, {
@@ -1191,11 +1341,11 @@ export class BillingService {
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
// Whole birr, always UP. CBE bills this amount verbatim, so it must never
// land below the outstanding balance — Math.round would let a .40 balance
// settle 0.40 short. Ceil overcharges by <1 birr instead, and the same
// ceil in billQuery keeps the quoted and debited amounts identical.
amountMinor: Math.ceil(Number(invoice.balanceAmount)),
// Exact balance, cents included. CBE bills this verbatim and /cbe/payment
// matches the debited amount to the cent (amountsMatchToTheCent), so any
// rounding here would overcharge the payer and leave the invoice balance
// non-zero. billQuery quotes the same unrounded value.
amountMinor: round2(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
@@ -1340,9 +1490,10 @@ export class BillingService {
});
if (open) {
// Ceil, matching payInvoice — the amount CBE quotes at the counter has to
// be the amount the intent was opened for, or /cbe/payment sees a mismatch.
const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount));
// Unrounded, matching payInvoice — the amount CBE quotes at the counter has
// to be the amount the intent was opened for, to the cent, or /cbe/payment
// sees a mismatch.
const balance = round2(Number(open.balanceAmount ?? open.totalAmount));
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
return {
stillPayable: balance > 0 && !expired,
@@ -1377,7 +1528,7 @@ export class BillingService {
return {
stillPayable: false,
payerName: latest.company?.name ?? null,
currentAmountMinor: Math.ceil(Number(latest.totalAmount)),
currentAmountMinor: round2(Number(latest.totalAmount)),
currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status),

View File

@@ -279,9 +279,10 @@ export class BookingPricingService {
return {
lineItems,
// Grand total is billed in whole currency units — fractional line sums
// (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD.
totalAmount: Math.round(total),
// Grand total keeps its cents, matching the line items it sums — rounding
// to whole birr made the total disagree with the breakdown (135,375.61 of
// lines shown as a 135,376.00 total) and CBE bills this figure to the cent.
totalAmount: round2(total),
currency: booking.paymentCurrency,
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,

View File

@@ -20,7 +20,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';

View File

@@ -77,6 +77,7 @@ import { LastMileService } from '../last-mile/last-mile.service';
import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
@@ -757,6 +758,18 @@ export class BookingsController {
return this.customerTruckService.getLoadableContainers(id);
}
@Patch(':id/export-handover-mode')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first',
})
setExportHandoverMode(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetExportHandoverModeDto,
) {
return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode);
}
@Post(':id/customer-trucks/:assignmentId/load')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })

View File

@@ -13,7 +13,7 @@ import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
@@ -28,7 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -285,10 +285,24 @@ export class BookingsService {
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
// and never appears on this sheet — it is only the signal that EDR has taken
// the cargo, which is what the customer's sheet attests to.
const isDirectExport =
booking.tradeDirection === 'EXPORT' && booking.exportHandoverMode === DIRECT_TO_TRAIN;
const pendingWagons = wagons.length === 0;
if (pendingWagons) {
const receivedLines: CarriageAcceptanceReceivedRow[] =
booking.tradeDirection === 'EXPORT'
// Direct truck-to-train cargo never enters the warehouse, so there is no
// GRN'd inventory to build the sheet from. Choosing direct handover is
// itself the acceptance, so the sheet issues off the booking's own
// containers (or its VGM weight when the cargo is bulk).
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
? await this.dataSource.query(
`SELECT NULL::numeric AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.containers c
WHERE c.booking_id = $1 AND c.deleted_at IS NULL
ORDER BY c.container_number`,
[bookingId],
)
: booking.tradeDirection === 'EXPORT'
? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
@@ -304,6 +318,15 @@ export class BookingsService {
[bookingId],
)
: [];
// Bulk direct cargo has no containers — one line carrying the booking's
// declared weight still makes a valid sheet.
if (isDirectExport && receivedLines.length === 0) {
receivedLines.push({
allocatedWeightTons:
booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons),
containerNumbers: null,
});
}
if (receivedLines.length === 0) {
throw new BadRequestException(
booking.tradeDirection === 'EXPORT'
@@ -1997,6 +2020,47 @@ export class BookingsService {
}
/** Get a single booking by ID with files. */
/**
* EXPORT only. Choose how the cargo reaches the train. DIRECT_TO_TRAIN takes
* the booking out of the warehouse flow entirely — no receipt, no GRN, and the
* carriage acceptance sheet becomes issuable straight away.
*
* Switching to direct is refused once the goods are already in the shed:
* inventory exists, so the cargo demonstrably went the warehouse route and its
* GRN paperwork must stand.
*/
async setExportHandoverMode(
bookingId: string,
mode: string,
): Promise<{ bookingId: string; exportHandoverMode: string }> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
if ((booking.tradeDirection ?? '').toUpperCase() !== 'EXPORT') {
throw new BadRequestException('Handover mode applies to export bookings only');
}
if (mode === DIRECT_TO_TRAIN) {
const [stored]: Array<{ one: number }> = await this.dataSource.query(
`SELECT 1 AS one
FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[bookingId],
);
if (stored) {
throw new BadRequestException(
'This booking already has cargo in the warehouse, so it cannot be switched to direct truck-to-train',
);
}
}
await this.dataSource.query(
`UPDATE freight.bookings SET export_handover_mode = $2, updated_at = NOW() WHERE id = $1`,
[bookingId, mode],
);
return { bookingId, exportHandoverMode: mode };
}
async findById(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) {

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn } from 'class-validator';
import { DIRECT_TO_TRAIN, WAREHOUSE } from '../../../common/export-received-gate';
export class SetExportHandoverModeDto {
@ApiProperty({
enum: [DIRECT_TO_TRAIN, WAREHOUSE],
description:
'DIRECT_TO_TRAIN — the customer truck loads straight onto the wagon (no warehouse, no GRN). ' +
'WAREHOUSE — received at the warehouse and issued a GRN first.',
})
@IsIn([DIRECT_TO_TRAIN, WAREHOUSE])
exportHandoverMode!: string;
}

View File

@@ -302,6 +302,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
customerTruckArrivedAt?: Date | null;
/**
* EXPORT only. How the cargo reaches the train:
* - DIRECT_TO_TRAIN — the customer's truck loads straight onto the wagon. No
* warehouse, so no GRN is ever raised and the carriage acceptance sheet is
* the only document handed over.
* - WAREHOUSE (also null) — received into the warehouse and GRN'd first.
*
* Null is treated as WAREHOUSE so existing bookings keep the GRN gate.
*/
@Column({ name: 'export_handover_mode', type: 'varchar', length: 20, nullable: true })
exportHandoverMode?: string | null;
/**
* Did the goods need re-handling in the warehouse? Recorded by warehouse
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;

View File

@@ -32,7 +32,7 @@ export class Container extends BaseEntity {
type: 'varchar',
nullable: true,
})
sealNumber!: string | null;
sealNumber!: string | null;
@Column({ type: 'varchar', default: 'AVAILABLE' })
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED

View File

@@ -24,7 +24,7 @@ import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';

View File

@@ -0,0 +1,38 @@
import { ContractsRepository } from './contracts.repository';
/**
* A ONE_TIME contract stops blocking a duplicate request only once its booking
* is PAID. The existing duplicate-guard spec stubs the repository out, so the
* candidate SQL itself is unchecked there — this pins the predicate.
*/
describe('findDuplicateCandidates ONE_TIME paid gate', () => {
const candidateSql = (): string => {
const conditions: string[] = [];
const qb = {
leftJoinAndSelect: () => qb,
where: () => qb,
andWhere: (condition: string) => {
if (typeof condition === 'string') conditions.push(condition);
return qb;
},
getMany: async () => [],
};
const repository = new ContractsRepository(
{ createQueryBuilder: () => qb } as never,
{} as never,
);
void repository.findDuplicateCandidates('company-1', 'svc-1');
return conditions.join(' AND ');
};
it('spends the contract on payment, not on the booking row existing', () => {
const sql = candidateSql();
expect(sql).toContain("contract.contract_kind <> 'ONE_TIME'");
// The gate: an unpaid booking must NOT free the lane.
expect(sql).toContain("b.payment_status = 'PAID'");
expect(sql).toContain('b.deleted_at IS NULL');
});
});

View File

@@ -104,15 +104,19 @@ export class ContractsRepository extends BaseRepository<Contract> {
.andWhere('contract.status NOT IN (:...terminal)', {
terminal: TERMINAL_CONTRACT_STATUSES,
})
// A ONE_TIME contract allows a single booking, so once that booking
// exists the contract is spent and can never carry another shipment.
// A ONE_TIME contract allows a single booking, so once that booking is
// PAID the contract is spent and can never carry another shipment.
// Without this it kept blocking new requests on the same service type +
// route until its validity lapsed — locking a customer out of a lane for
// the rest of the term after one completed shipment.
// Payment is the gate, not the booking row: a DRAFT or abandoned unpaid
// booking must keep the contract blocking, otherwise a customer holds an
// unpaid booking and requests an identical contract alongside it.
.andWhere(
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
SELECT 1 FROM freight.bookings b
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
AND b.payment_status = 'PAID'
))`,
)
.getMany();

View File

@@ -11,7 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';

View File

@@ -37,7 +37,7 @@ import { BookingNotifierService } from './booking-notifier.service';
import {
TrainSchedulingService,
effectiveWindowConfig,
} from './train-scheduling.service';
} from './services/train-scheduling.service';
import { eatDay, listConfigBookingWindows } from './batch-window.util';
import {
BATCH_BOARD_STATUSES,
@@ -90,7 +90,7 @@ import { BookingWindowGateway } from './booking-window.gateway';
import {
MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines,
} from './wagon-plan.util';
} from './utils/wagon-plan.util';
import {
Capacity,
CorridorBudget,

View File

@@ -19,7 +19,7 @@ import {
} from '../notifications/resolve-company-phone.util';
import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { TrainSchedulingService, effectiveWindowConfig } from './services/train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import {
bookingCloseCutoff,

View File

@@ -1,4 +1,4 @@
import type { ContainerPlacementInput } from './wagon-plan.util';
import type { ContainerPlacementInput } from './utils/wagon-plan.util';
export type ContainerUnitForPlacement = {
bookingId: string;

View File

@@ -1,8 +1,8 @@
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
import type { AuthUserPayload } from "../../../common/resolve-auth-user-id";
import { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service";
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
import {
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
@@ -19,46 +19,46 @@ import {
TrainSchedulingRulesManage,
TrainSchedulingUpdate,
TrainSchedulingView,
} from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
import { SwitchGovernmentBookingDto } from "./dto/switch-government-booking.dto";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
import { PinWagonsDto } from "./dto/pin-wagons.dto";
import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto";
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
} from "../../../common/booking-guards";
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "../dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto";
import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto";
import { CreateContainerTrainScheduleDto } from "../dto/create-container-train-schedule.dto";
import { GetEligibleBookingsDto } from "../dto/get-eligible-bookings.dto";
import { GetEligibleBulkBookingsDto } from "../dto/get-eligible-bulk-bookings.dto";
import { GetEligibleContainerBookingsDto } from "../dto/get-eligible-container-bookings.dto";
import { PinWagonsDto } from "../dto/pin-wagons.dto";
import { MoveWagonLoadDto } from "../dto/move-wagon-load.dto";
import { UpdateContainerItemDto } from "../dto/update-container-item.dto";
import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-status.dto";
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "../dto/record-checkpoint.dto";
import {
ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto,
} from "./dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto";
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
import { BookingJourneyService } from "./booking-journey.service";
import { BookingWindowService } from "./booking-window.service";
import { IntercityService } from "./intercity.service";
import { BillingService } from "../billing/billing.service";
} from "../dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "../dto/available-locomotives-query.dto";
import { AdjustScheduleConsistDto } from "../dto/adjust-schedule-consist.dto";
import { AvailableTrainsQueryDto } from "../dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "../dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "../dto/bookable-schedules-query.dto";
import { ListTrainSchedulesQueryDto } from "../dto/list-train-schedules-query.dto";
import { AvailableDaysQueryDto } from "../dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
import { MaintenanceRescheduleDto } from "../dto/maintenance-reschedule.dto";
import { TrainSchedulingService } from "../services/train-scheduling.service";
import { BookingBatchService } from "../booking-batch.service";
import { BookingJourneyService } from "../booking-journey.service";
import { BookingWindowService } from "../booking-window.service";
import { IntercityService } from "../intercity.service";
import { BillingService } from "../../billing/billing.service";
@ApiTags("train-scheduling")
@ApiBearerAuth()

View File

@@ -1,4 +1,4 @@
import { TrainSchedulingService } from './train-scheduling.service';
import { TrainSchedulingService } from './services/train-scheduling.service';
import { Booking } from '../bookings/entities/booking.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
@Injectable()
export class TrainCheckpointEventsRepository extends BaseRepository<TrainCheckpointEvent> {

View File

@@ -1,11 +1,11 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = {

View File

@@ -36,71 +36,71 @@ import {
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Contract } from '../contracts/entities/contract.entity';
import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Train } from '../trains/entities/train.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository';
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
import { Yard } from '../rule-engine/entities/yard.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
} from '../../../common/utils/pagination.util';
import { BookingsRepository } from '../../bookings/bookings.repository';
import { Booking } from '../../bookings/entities/booking.entity';
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../../contracts/clearance-milestone.service';
import { Contract } from '../../contracts/entities/contract.entity';
import { Container } from '../../container-management/entities/container.entity';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../../locomotives/locomotives.repository';
import { formatRouteLabel, Route } from '../../routes/entities/route.entity';
import { WagonMovement } from '../../wagons/entities/wagon-movement.entity';
import { Train } from '../../trains/entities/train.entity';
import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from '../dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from '../dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from '../dto/get-eligible-container-bookings.dto';
import {
ListTrainSchedulesQueryDto,
TrainScheduleFreightType,
} from './dto/list-train-schedules-query.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { MoveWagonLoadDto } from './dto/move-wagon-load.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
} from '../dto/list-train-schedules-query.dto';
import { PinWagonsDto } from '../dto/pin-wagons.dto';
import { MoveWagonLoadDto } from '../dto/move-wagon-load.dto';
import { UpdateContainerItemDto } from '../dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from '../dto/update-import-loading-status.dto';
import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
import {
ImportDjiboutiOperation,
type ImportDjiboutiDocumentType,
} from './entities/import-djibouti-operation.entity';
} from '../entities/import-djibouti-operation.entity';
import {
ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto,
} from './dto/import-djibouti-operation.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from './booking-window.config';
import { BookingWindowGateway } from './booking-window.gateway';
import { BookingNotifierService } from './booking-notifier.service';
import { BookingBatchService } from './booking-batch.service';
} from '../dto/import-djibouti-operation.dto';
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from '../booking-window.config';
import { BookingWindowGateway } from '../booking-window.gateway';
import { BookingNotifierService } from '../booking-notifier.service';
import { BookingBatchService } from '../booking-batch.service';
import {
computeFleetAvailability,
summarizeFleetWarnings,
@@ -109,13 +109,13 @@ import {
type BookingWagonShortage,
type DeferredBookingRow,
type FleetAvailabilityRow,
} from './fleet-plan.util';
} from '../utils/fleet-plan.util';
import {
applyWagonOrderReversal,
planWagonsWithStock,
type AllowedWagonTypeMap,
type WagonStock,
} from './wagon-plan-flex.util';
} from '../wagon-plan-flex.util';
import {
containerWagonsForLines,
expandBookingContainerUnits,
@@ -129,10 +129,10 @@ import {
validateMixedTrainLimitsPerEdge,
type ContainerPlacementInput,
type WagonPlanSlot,
} from './wagon-plan.util';
import { CorridorBudget } from './corridor-capacity.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
} from '../utils/wagon-plan.util';
import { CorridorBudget } from '../corridor-capacity.util';
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
import {
bookingCargoTons,
bulkItemsFitFor,
@@ -144,7 +144,7 @@ import {
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
WagonTypeDimensions,
} from './train-capacity.util';
} from '../train-capacity.util';
import {
DEFAULT_BULK_WAGON_CAPACITY_TONS,
DEFAULT_BULK_WAGON_LENGTH_METERS,
@@ -153,8 +153,8 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
paymentDrainEndsAtIso,
} from './booking-batch.constants';
import { orderConsistWagons } from './consist-order.util';
} from '../booking-batch.constants';
import { orderConsistWagons } from '../consist-order.util';
import {
computeExportWindowTimes,
computeImportWindowTimes,
@@ -162,22 +162,22 @@ import {
eatDay,
eatDayToUtc,
shiftEatDay,
} from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { BookingJourneyService } from './booking-journey.service';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
} from '../batch-window.util';
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
import { BookingJourneyService } from '../booking-journey.service';
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto';
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service';
import {
autoFillPlacements,
findMissingContainerNumberIssues,
isPlaceholderContainerNumber,
placementsForBookings,
type ContainerUnitForPlacement,
} from './container-placement.util';
} from '../container-placement.util';
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
@@ -5709,7 +5709,7 @@ export class TrainSchedulingService {
}
private resolveScheduleFreightType(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
const types = new Set(
(schedule.scheduleBookings ?? [])
@@ -5751,7 +5751,7 @@ export class TrainSchedulingService {
throw new ConflictException('Could not allocate a unique schedule reference');
}
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
return {
id: schedule.id,
reference: schedule.reference ?? null,
@@ -6907,7 +6907,7 @@ export class TrainSchedulingService {
originYardId?: string,
destinationYardId?: string,
): Promise<
import('../train-schedules/entities/train-schedule.entity').TrainSchedule[]
import('../../train-schedules/entities/train-schedule.entity').TrainSchedule[]
> {
const schedules = await this.trainSchedulesRepository.findAll({
where: {
@@ -7497,7 +7497,7 @@ export class TrainSchedulingService {
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
(w) => w.allocations ?? [],

View File

@@ -24,9 +24,9 @@ import { WarehousesModule } from '../warehouses/warehouses.module';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
import { TrainCheckpointEventsRepository } from './repositories/train-checkpoint-events.repository';
import { TrainSchedulingController } from './controllers/train-scheduling.controller';
import { TrainSchedulingService } from './services/train-scheduling.service';
import { BookingBatchService } from './booking-batch.service';
import { BookingNotifierService } from './booking-notifier.service';
import { BookingWindowGateway } from './booking-window.gateway';

View File

@@ -1,4 +1,4 @@
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
/** @deprecated Use deriveTradeDirection from common — kept as alias for train scheduling. */
export const deriveScheduleDirection = deriveTradeDirection;

View File

@@ -1,5 +1,5 @@
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { Booking } from '../../bookings/entities/booking.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import {
computeFleetAvailability,
selectBookingsWithinFleetCap,

View File

@@ -1,6 +1,6 @@
import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util';
import type { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { bookingCargoTons, bulkWagonsForAllowedTypes } from '../train-capacity.util';
import type { Booking } from '../../bookings/entities/booking.entity';
import type { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import {
buildBulkWagonPlan,
buildContainerWagonPlan,

View File

@@ -1,7 +1,7 @@
import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { Booking } from '../../bookings/entities/booking.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import {
buildBulkWagonPlan,
buildContainerWagonPlan,

View File

@@ -1,8 +1,8 @@
import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { Booking } from '../../bookings/entities/booking.entity';
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../../rule-engine/container-type.util';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import {
bookingCargoTons,
bulkItemsFitFor,
@@ -10,7 +10,7 @@ import {
bulkTonsPerWagon,
bulkTonWagonsRequired,
consistViolations,
} from './train-capacity.util';
} from '../train-capacity.util';
export const MAX_TRAIN_WEIGHT_TONS = 3500;
export const MAX_TRAIN_LENGTH_METERS = 760;

View File

@@ -0,0 +1,49 @@
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
COFFEE: 'KW2',
GRAIN: 'KW2',
WHEAT: 'KW2',
SORGHUM: 'KW2',
CORN: 'KW2',
FERTILIZER: 'PW2',
SUGAR: 'PW2',
COAL: 'KW3',
STEEL: 'CW3',
ORE: 'CW3',
};
const DEFAULT_BULK_WAGON_TYPE = 'CW3';
const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5';
/**
* Resolve wagon type code from cargo type code for bulk freight.
*/
export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string {
if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE;
const normalized = cargoTypeCode.trim().toUpperCase();
return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE;
}
/**
* Pick the best matching wagon type entity for bulk cargo.
*/
export function pickBulkWagonType(
wagonTypes: WagonType[],
cargoTypeCode?: string | null,
): WagonType | undefined {
const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode);
const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive);
if (direct) return direct;
return wagonTypes.find(
(wt) =>
wt.isActive &&
!wt.supportsContainer &&
wt.code !== DEFAULT_CONTAINER_WAGON_TYPE,
);
}
export function getDefaultContainerWagonTypeCode(): string {
return DEFAULT_CONTAINER_WAGON_TYPE;
}

View File

@@ -4,7 +4,7 @@ import {
applyWagonOrderReversal,
planWagonsWithStock,
} from './wagon-plan-flex.util';
import type { WagonPlanSlot } from './wagon-plan.util';
import type { WagonPlanSlot } from './utils/wagon-plan.util';
const nw6: WagonType = {
id: 'wt-nw6',

View File

@@ -11,7 +11,7 @@ import {
sortBookingsForScheduling,
type BookingWagonShortage,
type DeferredBookingRow,
} from './fleet-plan.util';
} from './utils/fleet-plan.util';
import {
MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines,
@@ -21,7 +21,7 @@ import {
teuSlotsForSizeFt,
type SlotLoadType,
type WagonPlanSlot,
} from './wagon-plan.util';
} from './utils/wagon-plan.util';
/**
* Wagon types allowed to carry each container type / bulk cargo type — the

View File

@@ -1425,6 +1425,9 @@ export class WarehouseInventoryService {
WHERE b.deleted_at IS NULL
AND b.payment_status = 'PAID'
AND inv.id IS NULL
-- Direct truck-to-train cargo never comes to the warehouse, so never
-- offer it for receipt.
AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN'
ORDER BY b.scheduled_date DESC NULLS LAST`,
);

View File

@@ -482,6 +482,13 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:invoices:eims_resolve",
"Resolve a blocked MoR EIMS submission",
),
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
// the invoice. Moves money state, so it is its own grant, not part of view.
perm(
"d2b00001-0001-4000-8000-000000000007",
"edr_freight_app:invoices:confirm_offline",
"Confirm offline (bank transfer) invoice payment",
),
];
// E. First / last mile operations
@@ -1640,6 +1647,7 @@ export const FREIGHT_PERMS = {
export: "edr_freight_app:invoices:export",
eimsRegister: "edr_freight_app:invoices:eims_register",
eimsResolve: "edr_freight_app:invoices:eims_resolve",
confirmOffline: "edr_freight_app:invoices:confirm_offline",
},
firstMile: {
view: "edr_freight_app:first_mile:view",

View File

@@ -36,6 +36,7 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
import InvoicesPage from "./pages/invoices/InvoicesPage";
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import ReportsHubPage from "./pages/reports/ReportsHubPage";
@@ -266,6 +267,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="usd-payments"
element={
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<UsdPaymentsPage />
</RequirePermission>
}
/>
<Route
path="invoices/:id"
element={

View File

@@ -19,7 +19,10 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
const fmt = (n: number) =>
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
`${booking.paymentCurrency} ${n.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
return (
<SectionCard icon={Banknote} title="Pricing & payment">
@@ -74,7 +77,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{li.description}
</Text>
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
{Number(li.amount).toLocaleString()} {li.currency}
{Number(li.amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}
{li.currency}
</Text>
</Group>
))}

View File

@@ -11,7 +11,10 @@ import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
`${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
/**
* Cargo costs (booking-level totals) plus the same truck-import block the

View File

@@ -348,6 +348,9 @@ export default function GlCreateBookingForm() {
// Intercity shipments ride a passing import/export train staff pick at
// finalize time — no shipment day is chosen and no window gate applies.
const isIntercity = contract?.tradeDirection === "DOMESTIC";
// USD billing is offered on import traffic only — export and domestic
// shipments are always invoiced in ETB.
const isImport = contract?.tradeDirection === "IMPORT";
// ONE_TIME split-remainder mode: a previous booking on this contract was
// split on train capacity, so the capacity endpoint reports the outstanding
@@ -1757,12 +1760,15 @@ export default function GlCreateBookingForm() {
Billing currency
</Text>
<Text size="xs" c="dimmed" mb={8}>
Shipments are invoiced in ETB.
{isImport
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
: "Shipments are invoiced in ETB."}
</Text>
<CurrencySelector
value={isIntercity ? "ETB" : paymentCurrency}
onChange={setPaymentCurrency}
disabled={isIntercity}
allowUsd={isImport}
/>
</Box>

View File

@@ -9,6 +9,7 @@ import {
FileText,
Hammer,
History,
Landmark,
LayoutDashboard,
LayoutGrid,
MapPin,
@@ -111,6 +112,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
icon: <Receipt />,
permission: FREIGHT_PERMS.invoices.view,
},
{
label: "USD Payments",
href: "/dashboard/usd-payments",
icon: <Landmark />,
permission: FREIGHT_PERMS.invoices.view,
},
{
label: "Support",
href: "/dashboard/support",

View File

@@ -561,7 +561,7 @@ const RuleEngineFormDialog = ({
// numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km),
// so let the field carry decimals and let a 400 catch the rest.
step={isNumber ? "any" : undefined}
disabled={field.disabled || computed !== undefined}
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord) || computed !== undefined}
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
onChange={(e) => {
const next = e.currentTarget.value;

View File

@@ -51,6 +51,8 @@ export const QUERY_KEYS = {
list: (filter?: InvoiceListFilter) =>
["invoices", "list", filter ?? {}] as const,
byId: (id: string) => ["invoices", "detail", id] as const,
offlineUsd: (filter?: InvoiceListFilter) =>
["invoices", "offline-usd", filter ?? {}] as const,
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
},

View File

@@ -105,6 +105,8 @@ export const URL_CONSTANTS = {
INVOICES: "/billing/invoices",
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
OFFLINE_USD: "/billing/offline-usd",
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
},
// MoR EIMS filing. Mounted on /invoices, not /billing/invoices — see EimsInvoiceController.
@@ -153,6 +155,8 @@ export const URL_CONSTANTS = {
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
`/bookings/${id}/carriage-acceptance-sheet`,
EXPORT_HANDOVER_MODE: (id: string) =>
`/bookings/${id}/export-handover-mode`,
SUMMARY: (id: string) => `/bookings/${id}/summary`,
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,

View File

@@ -128,6 +128,7 @@ export const FREIGHT_PERMS = {
invoices: {
view: "edr_freight_app:invoices:view",
export: "edr_freight_app:invoices:export",
confirmOffline: "edr_freight_app:invoices:confirm_offline",
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
// irreversible at the tax authority, and resolving clears a system-wide filing block.
eimsRegister: "edr_freight_app:invoices:eims_register",

View File

@@ -22,6 +22,7 @@ import {
Paper,
Button,
Box,
SegmentedControl,
} from "@mantine/core";
import { PageContainer } from "@/components/page";
@@ -258,6 +259,44 @@ export default function BookingRequestDetailPage() {
booking={booking}
mutations={mutations}
/>
{booking.tradeDirection === "EXPORT" && (
<Paper withBorder radius="md" p="sm">
<Stack gap={6}>
<Text size="sm" fw={600}>
How the cargo reaches the train
</Text>
<SegmentedControl
fullWidth
size="xs"
value={booking.exportHandoverMode ?? "WAREHOUSE"}
data={[
{ value: "WAREHOUSE", label: "Warehouse then train" },
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
]}
onChange={async (value) => {
try {
await bookingsService.setExportHandoverMode(
booking.id,
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
);
await refetch();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Could not change the handover mode",
);
}
}}
/>
<Text size="xs" c="dimmed">
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
: "Cargo is received at the warehouse and issued a GRN before loading."}
</Text>
</Stack>
</Paper>
)}
{booking.isGovernment && booking.contractSummary && (
<Button
fullWidth

View File

@@ -0,0 +1,426 @@
import type { Freight } from "@edr/types";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Modal,
SegmentedControl,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, ExternalLink, RefreshCw, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import {
InvoiceStatusBadge,
formatMoney,
humanize,
} from "@/components/customers";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { OfflineUsdInvoice } from "@/types/invoice";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
/**
* The customer's pay window, counted down live. Finance must confirm the bank
* transfer before it closes — past the deadline the booking expires like any
* unpaid one and the API refuses the confirmation.
*/
function formatRemaining(deadlineMs: number, now: number): string | null {
const diff = deadlineMs - now;
if (diff <= 0) return null;
const total = Math.floor(diff / 1000);
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return days > 0
? `${days}d ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!deadline) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [deadline]);
if (!deadline) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
const remaining = formatRemaining(new Date(deadline).getTime(), now);
if (!remaining) {
return (
<Badge color="red" variant="light" radius="sm">
Window closed
</Badge>
);
}
return (
<Text size="sm" fw={600} c="edr-text" ff="monospace">
{remaining}
</Text>
);
}
/** True once the pay window has closed — the API refuses confirmation then. */
function windowClosed(row: OfflineUsdInvoice): boolean {
const deadline = row.booking?.paymentDeadline;
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
}
export default function UsdPaymentsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState("");
const { user } = useAuth();
const canConfirm = hasPermission(
user,
FREIGHT_PERMS.invoices.confirmOffline,
);
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
);
const confirm = useMutation(api.invoices.confirmOffline.mutationOptions());
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const closeConfirm = () => {
setConfirming(null);
setSlip(null);
setReference("");
};
const submitConfirm = async () => {
if (!confirming || !slip) return;
try {
await confirm.mutateAsync({
id: confirming.id,
file: slip,
reference: reference.trim() || undefined,
});
toast.success(`${confirming.invoiceNumber} confirmed as paid`);
closeConfirm();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Confirmation failed");
}
};
const columns: ColumnDef<OfflineUsdInvoice>[] = useMemo(
() => [
{
id: "invoiceNumber",
header: "Invoice",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.invoiceNumber}
</Text>
),
},
{
id: "billedTo",
header: "Customer",
cell: ({ row }) => (
<Text size="sm" c="edr-text">
{row.original.company?.name ?? "—"}
</Text>
),
},
{
id: "booking",
header: "Booking",
cell: ({ row }) => {
const booking = row.original.booking;
if (!booking) {
return (
<Text size="sm" c="dimmed">
{humanize(row.original.source)}
</Text>
);
}
return (
<Button
variant="subtle"
size="compact-sm"
rightSection={<ExternalLink size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${booking.id}`);
}}
>
{booking.reference}
</Button>
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
},
{
id: "amount",
header: "Amount",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{formatMoney(row.original.totalAmount, row.original.currency)}
</Text>
),
},
{
id: "balance",
header: "Balance",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatMoney(row.original.balanceAmount, row.original.currency)}
</Text>
),
},
{
id: "payWindow",
header: "Pay window",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<PayWindowCell deadline={row.original.booking?.paymentDeadline ?? null} />
),
},
{
id: "action",
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => {
const paid = row.original.status === "PAID";
if (paid || !canConfirm) return null;
return (
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={windowClosed(row.original)}
onClick={(e) => {
e.stopPropagation();
setConfirming(row.original);
}}
>
Confirm paid
</Button>
);
},
},
],
[canConfirm, navigate],
);
return (
<PageContainer>
<PageHeader
title="USD Payments"
subtitle="USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by invoice number…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "open"}
onChange={(v) => {
setStatusFilter(
v === "open" ? "" : (v as Freight.InvoiceStatus),
);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "Awaiting payment", value: "open" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={1040}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No USD invoices match your search."
: "No USD invoices awaiting confirmation."
}
error={
isError
? {
message: "Failed to load USD invoices.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Box>
</Stack>
</Card>
<Modal
opened={confirming !== null}
onClose={closeConfirm}
title={
<Text fw={700}>Confirm bank transfer payment</Text>
}
radius="md"
size="md"
>
{confirming && (
<Stack gap="md">
<Text size="sm" c="dimmed">
Confirming settles {confirming.invoiceNumber} in full (
{formatMoney(confirming.balanceAmount, confirming.currency)}) and
marks the booking as paid. Upload the customer&apos;s bank slip
first this cannot be undone.
</Text>
<PhasedFileDropzone
label="Bank payment slip"
description="PDF or image of the customer's transfer slip."
value={slip}
onChange={setSlip}
/>
<TextInput
label="Bank reference"
description="Optional — the transfer reference from the slip."
placeholder="e.g. FT24091234567"
value={reference}
onChange={(e) => setReference(e.target.value)}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={closeConfirm}
disabled={confirm.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
loading={confirm.isPending}
disabled={!slip}
leftSection={<CheckCircle2 size={16} />}
onClick={() => void submitConfirm()}
>
Confirm as paid
</Button>
</Group>
</Stack>
)}
</Modal>
</PageContainer>
);
}

View File

@@ -39,6 +39,8 @@ export interface FormFieldDef {
placeholder?: string;
description?: string;
disabled?: boolean;
/** Editable on create, locked when editing an existing record. */
disabledOnEdit?: boolean;
/** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */
suffix?: string;
/** Hide this field when another field currently equals one of these values. */
@@ -389,7 +391,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true, disabledOnEdit: true },
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
{
name: "wagonTypeIds",

View File

@@ -41,6 +41,7 @@ import type {
Invoice,
InvoiceListFilter,
PaginatedInvoices,
PaginatedOfflineUsdInvoices,
} from "@/types/invoice";
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
import {
@@ -2917,6 +2918,29 @@ export const api = {
({ id }) => QUERY_KEYS.INVOICES.byId(id),
),
listOfflineUsd: endpoint<
{ filter: InvoiceListFilter },
PaginatedOfflineUsdInvoices
>(
"invoices",
"listOfflineUsd",
({ filter }) => invoicesService.listOfflineUsd(filter),
({ filter }) => QUERY_KEYS.INVOICES.offlineUsd(filter),
),
confirmOffline: endpoint<
{ id: string; file: File; reference?: string },
Invoice
>(
"invoices",
"confirmOffline",
({ id, file, reference }) =>
invoicesService.confirmOffline(id, file, reference),
undefined,
// Settling the invoice also advances the booking, so refresh both trees.
() => [QUERY_KEYS.INVOICES.ROOT, QUERY_KEYS.BOOKINGS.ROOT],
),
eimsStatus: endpoint<{ id: string }, EimsInvoiceStatusView>(
"invoices",
"eimsStatus",

View File

@@ -458,6 +458,13 @@ export const bookingsService = {
return (unwrap(response.data) ?? []) as BookingDetail[];
},
setExportHandoverMode: async (
id: string,
exportHandoverMode: "DIRECT_TO_TRAIN" | "WAREHOUSE",
): Promise<void> => {
await client.patch(B.EXPORT_HANDOVER_MODE(id), { exportHandoverMode });
},
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
responseType: "blob",

View File

@@ -4,6 +4,7 @@ import type {
Invoice,
InvoiceListFilter,
PaginatedInvoices,
PaginatedOfflineUsdInvoices,
} from "@/types/invoice";
const cleanParams = (params: object) =>
@@ -33,4 +34,25 @@ export const invoicesService = {
responseType: "blob",
});
},
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
listOfflineUsd(
filter: InvoiceListFilter,
): Promise<PaginatedOfflineUsdInvoices> {
return apiClient
.get<PaginatedOfflineUsdInvoices>(URL_CONSTANTS.BILLING.OFFLINE_USD, {
params: cleanParams(filter),
})
.then((r) => r.data);
},
/** Confirm a USD invoice paid by bank transfer — the slip file is required. */
confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> {
const body = new FormData();
body.append("file", file);
if (reference) body.append("reference", reference);
return apiClient
.post<Invoice>(URL_CONSTANTS.BILLING.CONFIRM_OFFLINE(id), body)
.then((r) => r.data);
},
};

View File

@@ -168,6 +168,11 @@ export interface BookingDetail {
contractType: string;
freightType: "CONTAINER" | "BULK";
tradeDirection: string;
/**
* EXPORT only. DIRECT_TO_TRAIN = customer truck loads straight onto the wagon
* (no warehouse, no GRN). null/WAREHOUSE = received and GRN'd first.
*/
exportHandoverMode?: "DIRECT_TO_TRAIN" | "WAREHOUSE" | null;
/** What the containers carry / bulk commodity label — entered at booking time. */
cargoFreeText?: string | null;
cargoTotalWeightVgm: number;

View File

@@ -20,3 +20,22 @@ export interface PaginatedInvoices {
items: Invoice[];
total: number;
}
/**
* A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows
* carry the shipment's pay-window deadline so the list can show the same
* countdown the customer sees — Finance must confirm before it closes.
*/
export interface OfflineUsdInvoice extends Invoice {
booking: {
id: string;
reference: string;
paymentDeadline: string | null;
paymentStatus: string;
} | null;
}
export interface PaginatedOfflineUsdInvoices {
items: OfflineUsdInvoice[];
total: number;
}

View File

@@ -1,5 +1,7 @@
import type { Freight } from "@edr/types";
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
/** A pending customer action surfaced on the home "needs attention" card. */
export interface ActionItem {
id: string;
@@ -13,6 +15,8 @@ export interface ActionItem {
targetId: string;
/** Highlighted as action-required in the home card. */
urgent?: boolean;
/** USD booking: paid by bank transfer, no online payment modal. */
offlinePay?: boolean;
}
/**
@@ -60,13 +64,17 @@ export function deriveActionItems(
? b.status === "FULLY_EXECUTED"
: b.status === "SELECTED_FOR_BATCH");
if (canPay) {
const offlinePay = isUsdOfflineBooking(b);
items.push({
id: `pay-${b.id}`,
kind: "pay",
reference: b.reference,
description: "Payment due for this shipment",
description: offlinePay
? "Payment due — pay by bank transfer and send the slip to Finance"
: "Payment due for this shipment",
targetId: b.id,
urgent: true,
offlinePay,
});
}
}

View File

@@ -65,7 +65,10 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
const handleClick = (item: ActionItem) => {
switch (item.kind) {
case "pay":
setPayItem(item);
// USD is paid by bank transfer — the booking page shows the countdown
// and the pay-by-bank instructions instead of the payment modal.
if (item.offlinePay) navigate(`/bookings/${item.targetId}`);
else setPayItem(item);
break;
case "sign":
navigate(`/contracts/${item.targetId}/view`);
@@ -151,7 +154,9 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
leftSection={<FilePlus2 size={14} />}
>
{item.kind === "pay"
? "Pay now"
? item.offlinePay
? "Pay by bank"
: "Pay now"
: item.kind === "sign"
? "Sign"
: "Book"}

View File

@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Center,
@@ -21,6 +22,7 @@ import {
CreditCard,
Download,
ExternalLink,
Landmark,
Receipt,
} from "lucide-react";
import toast from "react-hot-toast";
@@ -30,6 +32,7 @@ import { invoicesService } from "@/services/invoices.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment";
import { saveBlob } from "@/utils/download";
import { formatCurrency } from "@/lib/currency";
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
@@ -224,7 +227,7 @@ export default function InvoiceDetailPage() {
Receipt
</Button>
)}
{payable && (
{payable && !isUsdCurrency(invoice.currency) && (
<Button
color="edr-green"
radius="md"
@@ -239,6 +242,18 @@ export default function InvoiceDetailPage() {
Pay {formatCurrency(amountDue, invoice.currency)}
</Button>
)}
{payable && isUsdCurrency(invoice.currency) && (
<Badge
size="lg"
radius="md"
variant="light"
color="yellow"
leftSection={<Landmark size={12} />}
styles={{ label: { textTransform: "none", fontWeight: 700 } }}
>
Pay by bank transfer send the slip to Finance
</Badge>
)}
</Group>
</Group>

View File

@@ -32,6 +32,7 @@ import { Freight } from "@edr/types";
import { api } from "@/services/api";
import { formatCurrency } from "@/lib/currency";
import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment";
import {
BORDER,
GREEN,
@@ -276,7 +277,10 @@ export default function InvoicesList() {
{!isLoading &&
!isError &&
pageRows.map((inv) => {
const payable = isPayable(inv.status);
// USD invoices are paid by bank transfer — the detail page
// shows the instructions, so the row action reads "View".
const payable =
isPayable(inv.status) && !isUsdCurrency(inv.currency);
return (
<Table.Tr
key={inv.id}

View File

@@ -45,6 +45,7 @@ import { StatusHero } from "./components/StatusHero";
import { StepGhostButton, StepLine } from "./components/Steps";
import { SupportCard } from "./components/SupportCard";
import { BodyGrid } from "./components/layout";
import { formatAmount } from "./utils";
export function DraftBookingView({
booking,
@@ -439,7 +440,7 @@ export function DraftBookingView({
Previous total
</Text>
<Text size="sm" td="line-through">
{priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
{formatAmount(priceChangeModal.previousTotalAmount)}{" "}
{priceChangeModal.currency}
</Text>
</Group>
@@ -447,7 +448,7 @@ export function DraftBookingView({
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{priceChangeModal.totalAmount.toLocaleString()}{" "}
{formatAmount(priceChangeModal.totalAmount)}{" "}
{priceChangeModal.currency}
</Text>
</Group>
@@ -459,7 +460,7 @@ export function DraftBookingView({
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
{formatAmount(item.amount)} {item.currency}
</Text>
</Group>
))}

View File

@@ -53,6 +53,7 @@ import { WagonsTab } from "./components/WagonsTab";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
// Pre-payment statuses the customer may self-cancel from this view (free of
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
@@ -188,7 +189,7 @@ export function ReadonlyBookingView({
{canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} />
)}
{canPay && !showCountdown && (
{canPay && !showCountdown && !isUsdOfflineBooking(booking) && (
<HeaderButton
green
icon={<CreditCard size={16} />}

View File

@@ -17,9 +17,16 @@ import type { Freight } from "@edr/types";
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
import { saveBlob } from "@/utils/download";
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
import {
fmtDate,
formatAmount,
priceLineItems,
priceTotal,
type Pricing,
} from "../utils";
import { CardTitle, SectionCard } from "./layout";
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
@@ -190,12 +197,13 @@ export function BookingPaymentPanel({
}) {
const navigate = useNavigate();
const paid = booking.paymentStatus === "PAID";
const offlineUsd = isUsdOfflineBooking(booking);
const isAdjusted =
booking.adjustedTotalAmount !== null &&
booking.adjustedTotalAmount !== undefined;
const currency = pricing?.currency ?? booking.paymentCurrency;
const total = isAdjusted
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
? `${formatAmount(booking.adjustedTotalAmount)} ${currency}`
: priceTotal(pricing);
const items = priceLineItems(pricing);
@@ -268,11 +276,36 @@ export function BookingPaymentPanel({
<PartialOfferNotice offer={booking.activeBatchOffer} />
)}
{/* USD: no online payment — bank transfer + slip to Finance, who confirm
the payment (backoffice flow lands in a later phase). Shown for any
unpaid USD booking, with or without an open pay window. */}
{!paid && offlineUsd && (
<Box
mt={14}
p={14}
style={{
borderRadius: 10,
backgroundColor: "#FEF6E6",
border: "1px solid #F3E2B8",
}}
>
<Text fz="13px" fw={800} c="#9A5B00">
Pay by bank transfer
</Text>
<Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}>
Online payment isn&apos;t available for USD bookings. Transfer the
total amount to EDR&apos;s bank account before the payment deadline,
then send the payment slip to the EDR Finance department they
will confirm your payment.
</Text>
</Box>
)}
{showCountdown && booking.paymentDeadline && (
<Box mt={16}>
<Countdown
deadline={booking.paymentDeadline}
onPay={onPay}
onPay={offlineUsd ? undefined : onPay}
paying={paying}
/>
{showConsolidationNote && (

View File

@@ -31,6 +31,7 @@ interface ProviderOption {
// ponytail: ETB pays via CBE bill only for now — restore the Telebirr entry
// ({ method: "TELEBIRR", currencies: ["ETB"] }) when mobile money returns.
const PROVIDERS: ProviderOption[] = [
// {
// {
// method: "TELEBIRR",
// label: "telebirr",

View File

@@ -29,7 +29,7 @@ import { OperationDatePicker } from "@/pages/bookings/clearance";
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
import type { BookingDetail } from "../booking-detail-types";
import { fmtDate } from "../utils";
import { fmtDate, formatAmount } from "../utils";
import { CardTitle, SectionCard } from "./layout";
import { PaymentMethodModal } from "./PaymentMethodModal";
@@ -62,7 +62,7 @@ function StatusPill({ status }: { status: WagonCancellation["status"] }) {
}
const fmtMoney = (amount: number | string, currency: string) =>
`${Number(amount).toLocaleString()} ${currency}`;
`${formatAmount(amount)} ${currency}`;
const apiErrorMessage = (error: unknown, fallback: string) => {
const data = (

View File

@@ -10,6 +10,7 @@ import {
type PortalWarehouseInvoice,
} from "@/services/warehouse-invoices.service";
import { saveBlob } from "@/utils/download";
import { formatAmount } from "../utils";
import { PaymentMethodModal } from "./PaymentMethodModal";
import { CardTitle, SectionCard } from "./layout";
@@ -20,7 +21,7 @@ const isPayable = (inv: PortalWarehouseInvoice) =>
PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0;
const money = (amount: number | string | null | undefined, currency: string) =>
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
`${formatAmount(amount)} ${currency}`;
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
DRAFT: { bg: "#EEF2F6", fg: "#64748B" },

View File

@@ -151,15 +151,27 @@ export function bookingSubtitle(b: BookingDetail) {
export type Pricing = Freight.PricingBreakdown | null | undefined;
/**
* Money always prints its cents. Bare toLocaleString() defaults to
* maximumFractionDigits: 0, which silently hid the cents the customer is
* actually charged — CBE bills the exact amount, so the shown figure must match.
*/
export function formatAmount(amount: number | string | null | undefined) {
return Number(amount ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function priceLineItems(pricing: Pricing) {
return (pricing?.lineItems ?? []).map((li) => ({
label: li.description,
value: `${li.amount.toLocaleString()} ${li.currency}`,
value: `${formatAmount(li.amount)} ${li.currency}`,
}));
}
export function priceTotal(pricing: Pricing) {
if (!pricing) return "—";
const total = pricing.lineItems.reduce((s, li) => s + li.amount, 0);
return `${total.toLocaleString()} ${pricing.currency}`;
return `${formatAmount(total)} ${pricing.currency}`;
}

View File

@@ -683,7 +683,11 @@ export default function EditBookingPage() {
/>
</SimpleGrid>
<PaymentCurrencyField control={form.control} />
{/* USD billing is import-only; export/intercity stay ETB. */}
<PaymentCurrencyField
control={form.control}
allowUsd={operationType === "import"}
/>
{(selectedService?.includesFirstMile ||
selectedService?.includesLastMile ||

View File

@@ -19,14 +19,25 @@ const CURRENCY_ICONS: Record<
export function PaymentCurrencyField({
control,
allowUsd = false,
}: {
control: Control<BookingFormInputValues, any, BookingFormValues>;
/**
* Offer USD alongside ETB. Import shipments only — export and domestic
* traffic is always invoiced in ETB.
*/
allowUsd?: boolean;
}) {
const options = allowUsd
? PAYMENT_CURRENCY_OPTIONS
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value !== "USD");
return (
<Box mt={24}>
<StepLabel>Payment currency</StepLabel>
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
Choose the currency for your freight quote and invoices.
{allowUsd
? "Choose the currency for your freight quote and invoices. USD is paid by bank transfer, not online."
: "Choose the currency for your freight quote and invoices."}
</Text>
<Controller
name="paymentCurrency"
@@ -44,7 +55,7 @@ export function PaymentCurrencyField({
border: "1px solid #E6ECF2",
}}
>
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
{options.map((option) => {
const Icon = CURRENCY_ICONS[option.value].icon;
const selected = field.value === option.value;
return (
@@ -97,10 +108,7 @@ export function PaymentCurrencyField({
</Group>
{/* Description for the active currency, kept subtle. */}
<Text fz={11.5} c="#6B7C8E" mt={8}>
{
PAYMENT_CURRENCY_OPTIONS.find((o) => o.value === field.value)
?.description
}
{options.find((o) => o.value === field.value)?.description}
</Text>
<OptionFieldError error={fieldState.error} />
</div>

View File

@@ -81,11 +81,16 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
label: string;
description: string;
}> = [
// ponytail: ETB-only for now — re-add the USD option when multi-currency billing returns.
{
value: "ETB",
label: "ETB",
description: "Ethiopian Birr — local pricing and invoicing.",
description: "Ethiopian Birr — pay online through the payment gateway.",
},
// Import shipments only; the field is hidden on export/domestic traffic.
{
value: "USD",
label: "USD",
description: "US Dollar — paid by bank transfer, slip sent to Finance.",
},
];

View File

@@ -133,7 +133,11 @@ export function Step2ServiceType({
)}
/>
<PaymentCurrencyField control={form.control} />
{/* USD billing is import-only; export/intercity stay ETB. */}
<PaymentCurrencyField
control={form.control}
allowUsd={operationType === "import"}
/>
{showServiceSections && (
<Stack gap={12} mt={24}>

View File

@@ -1,11 +1,12 @@
import { Button, type ButtonProps } from "@mantine/core";
import { CreditCard } from "lucide-react";
import { Badge, Button, type ButtonProps } from "@mantine/core";
import { CreditCard, Landmark } from "lucide-react";
import { Freight } from "@edr/types";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
import { priceTotal } from "../BookingDetailPage/utils";
import { isUsdOfflineBooking } from "./offline-payment";
import { useBookingPayment } from "./useBookingPayment";
interface PayNowButtonProps {
@@ -29,6 +30,23 @@ export function PayNowButton({
const pay = useBookingPayment(booking.id);
const pricing = booking.pricingBreakdown;
// USD is paid by bank transfer and confirmed by Finance — no online payment.
if (isUsdOfflineBooking(booking)) {
return (
<Badge
size={size === "xs" ? "md" : "lg"}
radius="md"
variant="light"
color="yellow"
fullWidth={fullWidth}
leftSection={<Landmark size={12} />}
styles={{ label: { textTransform: "none", fontWeight: 700 } }}
>
Pay by bank transfer
</Badge>
);
}
return (
<ModalSafeWrapper>
<Button

View File

@@ -0,0 +1,16 @@
import type { Freight } from "@edr/types";
/**
* USD bookings are never paid online: the customer pays by bank transfer and
* the Finance department confirms the payment from the slip. Phase 1 is
* portal-only — Finance's confirm flow lands in the backoffice later.
*/
export function isUsdCurrency(currency?: string | null): boolean {
return currency?.toUpperCase() === "USD";
}
export function isUsdOfflineBooking(booking: Freight.IBooking): boolean {
return isUsdCurrency(
booking.pricingBreakdown?.currency ?? booking.paymentCurrency,
);
}

View File

@@ -64,6 +64,7 @@ import {
} from "@/pages/bookings/booking-display";
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import { formatAmount } from "./new-shipment-form/total";
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
@@ -719,7 +720,7 @@ export default function ContractDetailPage() {
)}
</Box>
<Text fz={14} fw={700} style={{ color: GREEN }}>
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}
{formatAmount(item.unitPrice)} {pricing.currency}{" "}
<Text span fz={12} fw={600} c="dimmed">
/ {formatRateUnit(item.unit)}
</Text>
@@ -1336,9 +1337,7 @@ export default function ContractDetailPage() {
whiteSpace: "nowrap",
}}
>
{amount > 0
? `ETB ${amount.toLocaleString()}`
: "—"}
{amount > 0 ? `ETB ${formatAmount(amount)}` : "—"}
</Text>
</Table.Td>
</Table.Tr>

View File

@@ -75,7 +75,7 @@ import {
createShipmentFormSchema,
initialShipmentFormValues,
} from "./new-shipment-form/schema";
import { computeShipmentTotal } from "./new-shipment-form/total";
import { computeShipmentTotal, formatAmount } from "./new-shipment-form/total";
import {
downloadContainerImportTemplate,
parseContainerExcel,
@@ -1014,7 +1014,7 @@ function PriceConfirmModal({
))}
<Text fz="xs" c="#9A5B00" mt={2}>
{overweightSurchargeAmount > 0
? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${
? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${
validation?.currency ?? total?.currency ?? ""
} applies (included in the total below). You can still submit, or go back and adjust weights.`
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
@@ -1038,7 +1038,7 @@ function PriceConfirmModal({
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()} {total.currency} ·{" "}
{formatAmount(line.unitPrice)} {total.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
@@ -1048,7 +1048,7 @@ function PriceConfirmModal({
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{line.amount.toLocaleString()} {total.currency}
{formatAmount(line.amount)} {total.currency}
</Text>
</Group>
))}
@@ -1070,7 +1070,7 @@ function PriceConfirmModal({
Total
</Text>
<Text fw={800} fz={28} c="#10202F">
{total.total.toLocaleString()}{" "}
{formatAmount(total.total)}{" "}
<Text span fz={16} fw={700} c="edr-muted">
{total.currency}
</Text>
@@ -1215,6 +1215,9 @@ function ScheduleStep({
})();
const isIntercity = contract.tradeDirection === "DOMESTIC";
// USD billing is offered on import traffic only — export and domestic
// shipments are always invoiced in ETB.
const isImport = contract.tradeDirection === "IMPORT";
const { data: availableDays, isLoading } = useQuery({
...api.bookings.getAvailableDaysForCargo.queryOptions({
input:
@@ -1310,12 +1313,15 @@ function ScheduleStep({
<Box mb="lg">
<StepLabel>Billing currency *</StepLabel>
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
Shipments are invoiced in ETB.
{isImport
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
: "Shipments are invoiced in ETB."}
</Text>
<CurrencySelector
value={field.value || ""}
onChange={(v) => field.onChange(v)}
error={fieldState.error?.message}
allowUsd={isImport}
/>
</Box>
)}

View File

@@ -15,6 +15,19 @@ export interface ShipmentTotal {
total: number;
}
/**
* Money always prints its cents. Bare toLocaleString() defaults to
* maximumFractionDigits: 0, which rounded the total away from the line items it
* sums (118,171.21 shown as 118,171) — and the customer is billed the exact
* amount, so the shown figure must match to the cent.
*/
export function formatAmount(amount: number | string | null | undefined) {
return Number(amount ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
/**
* Quantity a bulk rate bills, in ITS OWN unit. PER_ITEM cargo carries both
* figures — the item count prices the booking, the tonnage sizes the wagons —

View File

@@ -89,7 +89,6 @@ export default function TabCompanyProfile({
profile,
mode = "edit",
onCreateSuccess,
user,
}: TabCompanyProfileProps) {
const queryClient = useQueryClient();
const isCreate = mode === "create";

View File

@@ -8,17 +8,27 @@ export interface CurrencySelectorProps {
disabled?: boolean;
/** Validation error shown under the cards. */
error?: string;
/**
* Offer USD alongside ETB. Import shipments only — export and domestic
* traffic is invoiced in ETB, so the option stays hidden everywhere else.
* USD is settled by bank transfer, never through the online gateway.
*/
allowUsd?: boolean;
}
// ponytail: ETB-only for now — restore the USD entry when multi-currency billing returns.
const OPTIONS = [
{
code: "ETB",
symbol: "Br",
name: "Ethiopian Birr",
hint: "All shipments are invoiced in ETB",
},
] as const;
const ETB_OPTION = {
code: "ETB",
symbol: "Br",
name: "Ethiopian Birr",
hint: "Pay online through the payment gateway",
} as const;
const USD_OPTION = {
code: "USD",
symbol: "$",
name: "US Dollar",
hint: "Paid by bank transfer — send the slip to Finance",
} as const;
/**
* Card-style USD/ETB billing-currency picker. Renders unselected when `value`
@@ -29,7 +39,9 @@ export function CurrencySelector({
onChange,
disabled = false,
error,
allowUsd = false,
}: CurrencySelectorProps) {
const options = allowUsd ? [ETB_OPTION, USD_OPTION] : [ETB_OPTION];
return (
<Box>
<Box
@@ -39,7 +51,7 @@ export function CurrencySelector({
gap: 10,
}}
>
{OPTIONS.map((o) => {
{options.map((o) => {
const selected = value === o.code;
return (
<button