diff --git a/.guide-shots2/backoffice-01-overview.png b/.guide-shots2/backoffice-01-overview.png new file mode 100644 index 000000000..907f47853 Binary files /dev/null and b/.guide-shots2/backoffice-01-overview.png differ diff --git a/.guide-shots2/backoffice-02-customers-list.png b/.guide-shots2/backoffice-02-customers-list.png new file mode 100644 index 000000000..763c07c12 Binary files /dev/null and b/.guide-shots2/backoffice-02-customers-list.png differ diff --git a/.guide-shots2/backoffice-03-booking-requests.png b/.guide-shots2/backoffice-03-booking-requests.png new file mode 100644 index 000000000..24221fab0 Binary files /dev/null and b/.guide-shots2/backoffice-03-booking-requests.png differ diff --git a/.guide-shots2/backoffice-04-booking-detail.png b/.guide-shots2/backoffice-04-booking-detail.png new file mode 100644 index 000000000..620c4fc1e Binary files /dev/null and b/.guide-shots2/backoffice-04-booking-detail.png differ diff --git a/.guide-shots2/backoffice-05-contract-requests.png b/.guide-shots2/backoffice-05-contract-requests.png new file mode 100644 index 000000000..d8c2d3056 Binary files /dev/null and b/.guide-shots2/backoffice-05-contract-requests.png differ diff --git a/.guide-shots2/backoffice-06-clearance-documents.png b/.guide-shots2/backoffice-06-clearance-documents.png new file mode 100644 index 000000000..8c5430a80 Binary files /dev/null and b/.guide-shots2/backoffice-06-clearance-documents.png differ diff --git a/.guide-shots2/backoffice-07-transactions.png b/.guide-shots2/backoffice-07-transactions.png new file mode 100644 index 000000000..72f176330 Binary files /dev/null and b/.guide-shots2/backoffice-07-transactions.png differ diff --git a/.guide-shots2/backoffice-08-train-schedules.png b/.guide-shots2/backoffice-08-train-schedules.png new file mode 100644 index 000000000..f72ccb363 Binary files /dev/null and b/.guide-shots2/backoffice-08-train-schedules.png differ diff --git a/.guide-shots2/backoffice-09-first-mile.png b/.guide-shots2/backoffice-09-first-mile.png new file mode 100644 index 000000000..d5100d546 Binary files /dev/null and b/.guide-shots2/backoffice-09-first-mile.png differ diff --git a/.guide-shots2/backoffice-10-wagons.png b/.guide-shots2/backoffice-10-wagons.png new file mode 100644 index 000000000..3bcdd179b Binary files /dev/null and b/.guide-shots2/backoffice-10-wagons.png differ diff --git a/.guide-shots2/backoffice-11-arrival-queue.png b/.guide-shots2/backoffice-11-arrival-queue.png new file mode 100644 index 000000000..832a948f0 Binary files /dev/null and b/.guide-shots2/backoffice-11-arrival-queue.png differ diff --git a/.guide-shots2/backoffice-12-loading-queue.png b/.guide-shots2/backoffice-12-loading-queue.png new file mode 100644 index 000000000..f41b2074d Binary files /dev/null and b/.guide-shots2/backoffice-12-loading-queue.png differ diff --git a/.guide-shots2/backoffice-13-warehouses.png b/.guide-shots2/backoffice-13-warehouses.png new file mode 100644 index 000000000..777f508b0 Binary files /dev/null and b/.guide-shots2/backoffice-13-warehouses.png differ diff --git a/.guide-shots2/backoffice-14-reports.png b/.guide-shots2/backoffice-14-reports.png new file mode 100644 index 000000000..aa89f7107 Binary files /dev/null and b/.guide-shots2/backoffice-14-reports.png differ diff --git a/.guide-shots2/backoffice-15-staff.png b/.guide-shots2/backoffice-15-staff.png new file mode 100644 index 000000000..c6ea661ea Binary files /dev/null and b/.guide-shots2/backoffice-15-staff.png differ diff --git a/.guide-shots2/backoffice-16-shipping-lines.png b/.guide-shots2/backoffice-16-shipping-lines.png new file mode 100644 index 000000000..7cdd3239a Binary files /dev/null and b/.guide-shots2/backoffice-16-shipping-lines.png differ diff --git a/.guide-shots2/backoffice-17-configuration.png b/.guide-shots2/backoffice-17-configuration.png new file mode 100644 index 000000000..ae8c46a28 Binary files /dev/null and b/.guide-shots2/backoffice-17-configuration.png differ diff --git a/.guide-shots2/backoffice-18-fleet-dashboard.png b/.guide-shots2/backoffice-18-fleet-dashboard.png new file mode 100644 index 000000000..8f28b2692 Binary files /dev/null and b/.guide-shots2/backoffice-18-fleet-dashboard.png differ diff --git a/.guide-shots2/chat.png b/.guide-shots2/chat.png new file mode 100644 index 000000000..b7cecfa5b Binary files /dev/null and b/.guide-shots2/chat.png differ diff --git a/.guide-shots2/config-audit-logs.png b/.guide-shots2/config-audit-logs.png new file mode 100644 index 000000000..9ea84ed85 Binary files /dev/null and b/.guide-shots2/config-audit-logs.png differ diff --git a/.guide-shots2/config-contract-templates.png b/.guide-shots2/config-contract-templates.png new file mode 100644 index 000000000..34142e792 Binary files /dev/null and b/.guide-shots2/config-contract-templates.png differ diff --git a/.guide-shots2/config-dropdown-settings.png b/.guide-shots2/config-dropdown-settings.png new file mode 100644 index 000000000..051225351 Binary files /dev/null and b/.guide-shots2/config-dropdown-settings.png differ diff --git a/.guide-shots2/config-exchange-rate.png b/.guide-shots2/config-exchange-rate.png new file mode 100644 index 000000000..b0482f47e Binary files /dev/null and b/.guide-shots2/config-exchange-rate.png differ diff --git a/.guide-shots2/config-file-settings.png b/.guide-shots2/config-file-settings.png new file mode 100644 index 000000000..d4f3d5371 Binary files /dev/null and b/.guide-shots2/config-file-settings.png differ diff --git a/.guide-shots2/config-logo-settings.png b/.guide-shots2/config-logo-settings.png new file mode 100644 index 000000000..b448745e1 Binary files /dev/null and b/.guide-shots2/config-logo-settings.png differ diff --git a/.guide-shots2/config-manual-payments.png b/.guide-shots2/config-manual-payments.png new file mode 100644 index 000000000..0cbd4ac20 Binary files /dev/null and b/.guide-shots2/config-manual-payments.png differ diff --git a/.guide-shots2/config-operations-standards.png b/.guide-shots2/config-operations-standards.png new file mode 100644 index 000000000..5bbe252ed Binary files /dev/null and b/.guide-shots2/config-operations-standards.png differ diff --git a/.guide-shots2/config-portal-content.png b/.guide-shots2/config-portal-content.png new file mode 100644 index 000000000..a21dcf23a Binary files /dev/null and b/.guide-shots2/config-portal-content.png differ diff --git a/.guide-shots2/config-rules.png b/.guide-shots2/config-rules.png new file mode 100644 index 000000000..0ff76dd32 Binary files /dev/null and b/.guide-shots2/config-rules.png differ diff --git a/.guide-shots2/config-stamp-settings.png b/.guide-shots2/config-stamp-settings.png new file mode 100644 index 000000000..50fa702ac Binary files /dev/null and b/.guide-shots2/config-stamp-settings.png differ diff --git a/.guide-shots2/config-trade-access.png b/.guide-shots2/config-trade-access.png new file mode 100644 index 000000000..db8d6d6e4 Binary files /dev/null and b/.guide-shots2/config-trade-access.png differ diff --git a/.guide-shots2/config-train-scheduling-rules.png b/.guide-shots2/config-train-scheduling-rules.png new file mode 100644 index 000000000..2d0e0e175 Binary files /dev/null and b/.guide-shots2/config-train-scheduling-rules.png differ diff --git a/.guide-shots2/consolidation-approvals.png b/.guide-shots2/consolidation-approvals.png new file mode 100644 index 000000000..afe2900b5 Binary files /dev/null and b/.guide-shots2/consolidation-approvals.png differ diff --git a/.guide-shots2/contract-detail.png b/.guide-shots2/contract-detail.png new file mode 100644 index 000000000..48e4f31b3 Binary files /dev/null and b/.guide-shots2/contract-detail.png differ diff --git a/.guide-shots2/export-dispatch-queue.png b/.guide-shots2/export-dispatch-queue.png new file mode 100644 index 000000000..0afd875b8 Binary files /dev/null and b/.guide-shots2/export-dispatch-queue.png differ diff --git a/.guide-shots2/export-djibouti-unloading.png b/.guide-shots2/export-djibouti-unloading.png new file mode 100644 index 000000000..8af605671 Binary files /dev/null and b/.guide-shots2/export-djibouti-unloading.png differ diff --git a/.guide-shots2/export-interchange-documents.png b/.guide-shots2/export-interchange-documents.png new file mode 100644 index 000000000..7234b9c16 Binary files /dev/null and b/.guide-shots2/export-interchange-documents.png differ diff --git a/.guide-shots2/export-loaded-inventory.png b/.guide-shots2/export-loaded-inventory.png new file mode 100644 index 000000000..754c17399 Binary files /dev/null and b/.guide-shots2/export-loaded-inventory.png differ diff --git a/.guide-shots2/export-overview.png b/.guide-shots2/export-overview.png new file mode 100644 index 000000000..933303583 Binary files /dev/null and b/.guide-shots2/export-overview.png differ diff --git a/.guide-shots2/fleet-compliance.png b/.guide-shots2/fleet-compliance.png new file mode 100644 index 000000000..a8b63af50 Binary files /dev/null and b/.guide-shots2/fleet-compliance.png differ diff --git a/.guide-shots2/fleet-drivers.png b/.guide-shots2/fleet-drivers.png new file mode 100644 index 000000000..55306d313 Binary files /dev/null and b/.guide-shots2/fleet-drivers.png differ diff --git a/.guide-shots2/fleet-financial-reports.png b/.guide-shots2/fleet-financial-reports.png new file mode 100644 index 000000000..9b062a483 Binary files /dev/null and b/.guide-shots2/fleet-financial-reports.png differ diff --git a/.guide-shots2/fleet-fuel-purchases.png b/.guide-shots2/fleet-fuel-purchases.png new file mode 100644 index 000000000..910807906 Binary files /dev/null and b/.guide-shots2/fleet-fuel-purchases.png differ diff --git a/.guide-shots2/fleet-fuel-stats.png b/.guide-shots2/fleet-fuel-stats.png new file mode 100644 index 000000000..c90a0ba9d Binary files /dev/null and b/.guide-shots2/fleet-fuel-stats.png differ diff --git a/.guide-shots2/fleet-incidents.png b/.guide-shots2/fleet-incidents.png new file mode 100644 index 000000000..67fc4a20f Binary files /dev/null and b/.guide-shots2/fleet-incidents.png differ diff --git a/.guide-shots2/fleet-locomotives.png b/.guide-shots2/fleet-locomotives.png new file mode 100644 index 000000000..72931c30f Binary files /dev/null and b/.guide-shots2/fleet-locomotives.png differ diff --git a/.guide-shots2/fleet-maintenance.png b/.guide-shots2/fleet-maintenance.png new file mode 100644 index 000000000..1aacc326d Binary files /dev/null and b/.guide-shots2/fleet-maintenance.png differ diff --git a/.guide-shots2/fleet-procurement.png b/.guide-shots2/fleet-procurement.png new file mode 100644 index 000000000..90e744638 Binary files /dev/null and b/.guide-shots2/fleet-procurement.png differ diff --git a/.guide-shots2/fleet-routes.png b/.guide-shots2/fleet-routes.png new file mode 100644 index 000000000..c7011ebb9 Binary files /dev/null and b/.guide-shots2/fleet-routes.png differ diff --git a/.guide-shots2/fleet-tracking.png b/.guide-shots2/fleet-tracking.png new file mode 100644 index 000000000..7e7feb080 Binary files /dev/null and b/.guide-shots2/fleet-tracking.png differ diff --git a/.guide-shots2/fleet-train-builder.png b/.guide-shots2/fleet-train-builder.png new file mode 100644 index 000000000..8af485eb1 Binary files /dev/null and b/.guide-shots2/fleet-train-builder.png differ diff --git a/.guide-shots2/fleet-vehicles.png b/.guide-shots2/fleet-vehicles.png new file mode 100644 index 000000000..5293d87e1 Binary files /dev/null and b/.guide-shots2/fleet-vehicles.png differ diff --git a/.guide-shots2/fleet-wagon-transfers.png b/.guide-shots2/fleet-wagon-transfers.png new file mode 100644 index 000000000..37690ea08 Binary files /dev/null and b/.guide-shots2/fleet-wagon-transfers.png differ diff --git a/.guide-shots2/fleet-work-orders.png b/.guide-shots2/fleet-work-orders.png new file mode 100644 index 000000000..7f54ce2bc Binary files /dev/null and b/.guide-shots2/fleet-work-orders.png differ diff --git a/.guide-shots2/import-container-returns.png b/.guide-shots2/import-container-returns.png new file mode 100644 index 000000000..d70d5840b Binary files /dev/null and b/.guide-shots2/import-container-returns.png differ diff --git a/.guide-shots2/import-inventory-inquiry.png b/.guide-shots2/import-inventory-inquiry.png new file mode 100644 index 000000000..be6aff949 Binary files /dev/null and b/.guide-shots2/import-inventory-inquiry.png differ diff --git a/.guide-shots2/import-overview.png b/.guide-shots2/import-overview.png new file mode 100644 index 000000000..0192cc87d Binary files /dev/null and b/.guide-shots2/import-overview.png differ diff --git a/.guide-shots2/import-terminal-inventory.png b/.guide-shots2/import-terminal-inventory.png new file mode 100644 index 000000000..66abaa1c1 Binary files /dev/null and b/.guide-shots2/import-terminal-inventory.png differ diff --git a/.guide-shots2/import-trucks.png b/.guide-shots2/import-trucks.png new file mode 100644 index 000000000..c780ae7cd Binary files /dev/null and b/.guide-shots2/import-trucks.png differ diff --git a/.guide-shots2/intercity.png b/.guide-shots2/intercity.png new file mode 100644 index 000000000..ec855fc60 Binary files /dev/null and b/.guide-shots2/intercity.png differ diff --git a/.guide-shots2/ops-batch-board.png b/.guide-shots2/ops-batch-board.png new file mode 100644 index 000000000..438e96724 Binary files /dev/null and b/.guide-shots2/ops-batch-board.png differ diff --git a/.guide-shots2/ops-clearance.png b/.guide-shots2/ops-clearance.png new file mode 100644 index 000000000..2d9b60bcd Binary files /dev/null and b/.guide-shots2/ops-clearance.png differ diff --git a/.guide-shots2/ops-gl-djibouti.png b/.guide-shots2/ops-gl-djibouti.png new file mode 100644 index 000000000..a577f4648 Binary files /dev/null and b/.guide-shots2/ops-gl-djibouti.png differ diff --git a/.guide-shots2/ops-last-mile.png b/.guide-shots2/ops-last-mile.png new file mode 100644 index 000000000..8f4bcaec5 Binary files /dev/null and b/.guide-shots2/ops-last-mile.png differ diff --git a/.guide-shots2/portal-01-signup.png b/.guide-shots2/portal-01-signup.png new file mode 100644 index 000000000..8942e1b48 Binary files /dev/null and b/.guide-shots2/portal-01-signup.png differ diff --git a/.guide-shots2/portal-02-otp.png b/.guide-shots2/portal-02-otp.png new file mode 100644 index 000000000..1011c9eea Binary files /dev/null and b/.guide-shots2/portal-02-otp.png differ diff --git a/.guide-shots2/portal-03-onboarding-company-type-coop.png b/.guide-shots2/portal-03-onboarding-company-type-coop.png new file mode 100644 index 000000000..2c735d656 Binary files /dev/null and b/.guide-shots2/portal-03-onboarding-company-type-coop.png differ diff --git a/.guide-shots2/portal-05-onboarding-company-info.png b/.guide-shots2/portal-05-onboarding-company-info.png new file mode 100644 index 000000000..cf96b7792 Binary files /dev/null and b/.guide-shots2/portal-05-onboarding-company-info.png differ diff --git a/.guide-shots2/portal-06-onboarding-owner.png b/.guide-shots2/portal-06-onboarding-owner.png new file mode 100644 index 000000000..9d0ed28fd Binary files /dev/null and b/.guide-shots2/portal-06-onboarding-owner.png differ diff --git a/.guide-shots2/portal-07-onboarding-poa.png b/.guide-shots2/portal-07-onboarding-poa.png new file mode 100644 index 000000000..eb4ba62c2 Binary files /dev/null and b/.guide-shots2/portal-07-onboarding-poa.png differ diff --git a/.guide-shots2/portal-08-onboarding-fayda.png b/.guide-shots2/portal-08-onboarding-fayda.png new file mode 100644 index 000000000..b5f6c820f Binary files /dev/null and b/.guide-shots2/portal-08-onboarding-fayda.png differ diff --git a/.guide-shots2/shipping-line-credits.png b/.guide-shots2/shipping-line-credits.png new file mode 100644 index 000000000..c23a40612 Binary files /dev/null and b/.guide-shots2/shipping-line-credits.png differ diff --git a/.guide-shots2/support.png b/.guide-shots2/support.png new file mode 100644 index 000000000..b1d6a2876 Binary files /dev/null and b/.guide-shots2/support.png differ diff --git a/.guide-shots2/trucks-on-site.png b/.guide-shots2/trucks-on-site.png new file mode 100644 index 000000000..7f79f0767 Binary files /dev/null and b/.guide-shots2/trucks-on-site.png differ diff --git a/.guide-shots2/wagon-cancellations.png b/.guide-shots2/wagon-cancellations.png new file mode 100644 index 000000000..b4a362dab Binary files /dev/null and b/.guide-shots2/wagon-cancellations.png differ diff --git a/.guide-shots2/warehouse-allocation-fees.png b/.guide-shots2/warehouse-allocation-fees.png new file mode 100644 index 000000000..9dd50cf78 Binary files /dev/null and b/.guide-shots2/warehouse-allocation-fees.png differ diff --git a/.guide-shots2/warehouse-dashboard.png b/.guide-shots2/warehouse-dashboard.png new file mode 100644 index 000000000..23b6a50e8 Binary files /dev/null and b/.guide-shots2/warehouse-dashboard.png differ diff --git a/.guide-shots2/warehouse-detail.png b/.guide-shots2/warehouse-detail.png new file mode 100644 index 000000000..ec34963d8 Binary files /dev/null and b/.guide-shots2/warehouse-detail.png differ diff --git a/.guide-shots2/warehouse-fee-invoices.png b/.guide-shots2/warehouse-fee-invoices.png new file mode 100644 index 000000000..9054e6a6e Binary files /dev/null and b/.guide-shots2/warehouse-fee-invoices.png differ diff --git a/EDR-Freight-User-Guide.pdf b/EDR-Freight-User-Guide.pdf new file mode 100644 index 000000000..452cf10e9 Binary files /dev/null and b/EDR-Freight-User-Guide.pdf differ diff --git a/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts b/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts new file mode 100644 index 000000000..8dcda8181 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Optional payment due date finance can set on an additional charge. */ +export class AdditionalChargeDueAt3650000000000 implements MigrationInterface { + name = 'AdditionalChargeDueAt3650000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."additional_charge" + ADD COLUMN IF NOT EXISTS "due_at" timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."additional_charge" DROP COLUMN IF EXISTS "due_at" + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts index bb5836d1a..012d5f8db 100644 --- a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -1,6 +1,7 @@ import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { DataSource, EntityManager } from 'typeorm'; +import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; @@ -35,6 +36,7 @@ export class AdditionalChargeService { private readonly repository: AdditionalChargeRepository, private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, + private readonly exchangeService: ExchangeService, private readonly billing: BillingService, private readonly bookingsService: BookingsService, private readonly notifications: NotificationsService, @@ -74,6 +76,7 @@ export class AdditionalChargeService { reason: dto.reason.trim(), amount: dto.amount.toFixed(2), currency: dto.currency.trim().toUpperCase(), + dueAt: dto.dueDate ? new Date(dto.dueDate) : null, status: 'DRAFT', createdByStaffId: staffId, }), @@ -132,6 +135,8 @@ export class AdditionalChargeService { companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency: charge.currency, + // Unset falls through to BillingService's own DEFAULT_DUE_DAYS (14). + dueAt: charge.dueAt ?? undefined, lines: [ { chargeType: 'ADDITIONAL_CHARGE', @@ -254,9 +259,12 @@ export class AdditionalChargeService { ? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) }) : []; const invoiceById = new Map(invoices.map((i) => [i.id, i])); + const converted = await Promise.all(rows.map((r) => this.convertAmount(r))); + const convertedById = new Map(rows.map((r, i) => [r.id, converted[i]])); return rows.map((r) => { const file = filesByCharge.get(r.id)?.[0]; + const fx = convertedById.get(r.id) ?? null; return { id: r.id, bookingId: r.bookingId, @@ -264,6 +272,9 @@ export class AdditionalChargeService { status: r.status, amount: Number(r.amount), currency: r.currency, + convertedAmount: fx?.amount ?? null, + convertedCurrency: fx?.currency ?? null, + dueAt: r.dueAt?.toISOString() ?? null, file: file ? { id: file.id, name: file.name, url: file.url } : null, invoiceId: r.invoiceId ?? null, invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null, @@ -278,4 +289,26 @@ export class AdditionalChargeService { }; }); } + + /** + * Amount converted to the other of ETB/USD, via the existing shared + * `ExchangeService` (CBE rate, falls back to the stored `exchange_settings` + * rate) — same mechanism `booking-wagon-cancellation.service.ts` and + * warehouse fee pricing already use. Null on anything but ETB/USD, or if + * the rate feed is down — this is a display convenience, not the payable + * amount, so a failure here must never break the charge list. + */ + private async convertAmount( + charge: AdditionalCharge, + ): Promise<{ amount: number; currency: string } | null> { + if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null; + const target = charge.currency === 'ETB' ? 'USD' : 'ETB'; + try { + const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target); + return { amount: Math.round(amount * 100) / 100, currency: target }; + } catch (err) { + this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`); + return null; + } + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts index eb4d095bf..baf135876 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts @@ -1,6 +1,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsPositive, IsString, Length } from 'class-validator'; +import { + IsDateString, + IsIn, + IsNumber, + IsOptional, + IsPositive, + IsString, + Length, +} from 'class-validator'; export class CreateAdditionalChargeDto { @ApiProperty({ example: 'Re-weighing fee at Mojo dry port' }) @@ -24,6 +32,12 @@ export class CreateAdditionalChargeDto { @IsOptional() @IsIn(['draft', 'send']) action?: 'draft' | 'send'; + + /** Payment due date; omit to fall back to the invoice's own default term (14 days) on send. */ + @ApiPropertyOptional({ example: '2026-09-01' }) + @IsOptional() + @IsDateString() + dueDate?: string; } export class CancelAdditionalChargeDto { diff --git a/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts index 11ea0bdb1..ba29f0118 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts @@ -39,6 +39,10 @@ export class AdditionalCharge extends BaseEntity { @Column({ name: 'currency', type: 'varchar', length: 8 }) currency!: string; + /** Optional payment due date; unset falls back to the invoice's own default term on send. */ + @Column({ name: 'due_at', type: 'timestamptz', nullable: true }) + dueAt?: Date | null; + /** The supporting attachment (FileRecord), if any. */ @Column({ name: 'file_record_id', type: 'uuid', nullable: true }) fileRecordId?: string | null; diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts index c631bef92..ae80ea9c8 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -1,8 +1,13 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, 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 { BookingStaff, MixedAudience } from '../../common/booking-guards'; +import { hasFreightPermission } from '../../common/freight-permission.util'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingsService } from '../bookings/bookings.service'; import { AssignCustomsRiskDto, CreateDjiboutiIncidentDto, @@ -19,30 +24,38 @@ import { ImportOperationsService } from './import-operations.service'; @ApiBearerAuth() @Controller('import-operations') // Post-booking customs / import-operations actions are GL/Ops work, mirroring the -// contracts controller's GL operational endpoints (risk, duty, milestones). -@BookingStaff(FREIGHT_PERMS.bookings.operations) +// contracts controller's GL operational endpoints (risk, duty, milestones). No +// class-level guard: the equipment interchange receipt below is customer-reachable, +// every other route here stays staff-only via its own @BookingStaff. export class ImportOperationsController { - constructor(private readonly service: ImportOperationsService) {} + constructor( + private readonly service: ImportOperationsService, + private readonly bookingsService: BookingsService, + ) {} @Get('djibouti-incidents') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' }) listIncidents(@Query('bookingId') bookingId?: string) { return this.service.listIncidents(bookingId); } @Post('djibouti-incidents') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' }) createIncident(@Body() dto: CreateDjiboutiIncidentDto) { return this.service.createIncident(dto); } @Get('customs/:bookingId') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: import customs finalization state' }) getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.service.getCustoms(bookingId); } @Post('customs/:bookingId/documents') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' }) uploadCustomsDocument( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -52,6 +65,7 @@ export class ImportOperationsController { } @Post('customs/:bookingId/declaration') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: record declaration serial number' }) recordDeclaration( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -61,6 +75,7 @@ export class ImportOperationsController { } @Post('customs/:bookingId/notify-duties-taxes') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: notify duties and taxes' }) notifyDutiesTaxes( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -70,6 +85,7 @@ export class ImportOperationsController { } @Post('customs/:bookingId/duties-taxes-paid') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' }) markDutiesTaxesPaid( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -79,12 +95,14 @@ export class ImportOperationsController { } @Post('customs/:bookingId/risk') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: assign customs risk' }) assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) { return this.service.assignRisk(bookingId, dto); } @Post('customs/:bookingId/release-permitted') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: mark import release permitted' }) markReleasePermitted( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -94,18 +112,21 @@ export class ImportOperationsController { } @Get('empty-container-returns') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 16: list empty container returns' }) listEmptyReturns() { return this.service.listEmptyReturns(); } @Post('empty-container-returns') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 16: create an empty container return record' }) createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) { return this.service.createEmptyReturn(dto); } @Post('empty-container-returns/load-on-train') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)', }) @@ -114,6 +135,7 @@ export class ImportOperationsController { } @Post('empty-container-returns/:id/status') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 16: advance empty container return workflow' }) updateEmptyReturnStatus( @Param('id', ParseUUIDPipe) id: string, @@ -121,4 +143,53 @@ export class ImportOperationsController { ) { return this.service.updateEmptyReturnStatus(id, dto); } + + @Get('bookings/:bookingId/empty-container-returns') + @MixedAudience(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'List empty container returns for a booking (customer portal)' }) + async listEmptyReturnsForBooking( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertCanAccessBooking(user, bookingId); + return this.service.listEmptyReturnsForBooking(bookingId); + } + + @Get('empty-container-returns/:id/document') + @MixedAudience(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'Download the equipment interchange receipt PDF (customer portal)' }) + async equipmentInterchangeDocument( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const row = await this.service.getEmptyReturnOrThrow(id); + // A standalone (no-booking) return has no owner to check against, so it + // stays staff-only. + if (!row.bookingId) { + await this.assertCanAccessBooking(user, null); + } else { + await this.assertCanAccessBooking(user, row.bookingId); + } + + const { filename, buffer } = await this.service.equipmentInterchangeDocument(row); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + + /** + * Staff pass on permission alone. A customer must own the booking; `null` + * (a standalone, booking-less return) has no owner for a customer to match, + * so it 404s them the same way a foreign booking would. + */ + private async assertCanAccessBooking(user: TCurrentUser, bookingId: string | null): Promise { + if (hasFreightPermission(user, FREIGHT_PERMS.bookings.operations)) return; + if (!bookingId) { + throw new NotFoundException('Not found'); + } + const booking = await this.bookingsService.findById(bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } } diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts index fb4c6e896..21e0dd9a0 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsModule } from '../bookings/bookings.module'; +import { WarehousesModule } from '../warehouses/warehouses.module'; import { DjiboutiIncident } from './entities/djibouti-incident.entity'; import { EmptyContainerReturn } from './entities/empty-container-return.entity'; import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity'; @@ -14,6 +16,11 @@ import { ImportOperationsService } from './import-operations.service'; ImportCustomsFinalization, EmptyContainerReturn, ]), + // WarehouseReleaseDocumentService (the shared PDF renderer) for the + // equipment interchange receipt; BookingsModule for the customer + // ownership check on that same route. + WarehousesModule, + BookingsModule, ], controllers: [ImportOperationsController], providers: [ImportOperationsService], diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index 28eb4e44f..ccec40139 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -2,6 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; +import { LogoSettingsService } from '../logo-settings/logo-settings.service'; +import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util'; +import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { CreateDjiboutiIncidentDto, CreateEmptyContainerReturnDto, @@ -39,6 +42,8 @@ export class ImportOperationsService { private readonly customs: Repository, @InjectRepository(EmptyContainerReturn) private readonly emptyReturns: Repository, + private readonly pdfDocuments: WarehouseReleaseDocumentService, + private readonly logoSettings: LogoSettingsService, ) {} listIncidents(bookingId?: string) { @@ -150,6 +155,10 @@ export class ImportOperationsService { return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never }); } + listEmptyReturnsForBooking(bookingId: string) { + return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never }); + } + async createEmptyReturn(dto: CreateEmptyContainerReturnDto) { const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date(); return this.emptyReturns.save( @@ -248,6 +257,142 @@ export class ImportOperationsService { return this.emptyReturns.findOneOrFail({ where: { id } }); } + async getEmptyReturnOrThrow(id: string): Promise { + const row = await this.emptyReturns.findOne({ where: { id } }); + if (!row) { + throw new NotFoundException(`Empty container return ${id} not found`); + } + return row; + } + + /** + * Equipment Interchange Receipt — container number/size, exact return + * timestamp, depot, condition, and the carrier/booking reference that ties + * the box back to its bill of lading. Handed to the customer to download. + */ + async equipmentInterchangeDocument( + row: EmptyContainerReturn, + ): Promise<{ filename: string; buffer: Buffer }> { + const booking = row.bookingId + ? (( + await this.emptyReturns.manager.query( + `SELECT b.reference, c.name AS company_name + FROM freight.bookings b + LEFT JOIN freight.companies c ON c.id = b.company_id + WHERE b.id = $1`, + [row.bookingId], + ) + )[0] as { reference: string; company_name: string | null } | undefined) + : undefined; + + const html = this.buildEquipmentInterchangeHtml(row, booking, { + logoImageUrl: await this.logoSettings.getLogoImageUrl(), + }); + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Equipment interchange receipt'); + return { + filename: `equipment-interchange-${row.containerNumber || row.id.slice(0, 8)}.pdf`, + buffer, + }; + } + + private buildEquipmentInterchangeHtml( + row: EmptyContainerReturn, + booking: { reference: string; company_name: string | null } | undefined, + opts: { logoImageUrl?: string | null }, + ): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const dateTime = (value: unknown) => + value ? new Date(value as string | Date).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : '-'; + const carrier = + row.returnedBy === 'EDR' + ? 'EDR Last Mile' + : row.returnedBy === 'CUSTOMER' + ? 'Customer Self-Haul' + : '-'; + + const rows: Array<[string, string]> = [ + ['Container Number', row.containerNumber], + ['Container Size', row.containerSize ? `${row.containerSize}ft` : 'Not recorded'], + ['Date & Time of Return', dateTime(row.returnDate)], + ['Depot / Location', [row.facility, row.yard, row.zone].filter(Boolean).join(' — ') || '-'], + ['Condition Status', row.condition || 'Good — no exceptions noted'], + ['Carrier', carrier], + ['Booking / BOL Reference', booking?.reference || 'Standalone — no booking'], + ['Shipping Line / Customer', booking?.company_name || '-'], + ['Current Status', row.status.replace(/_/g, ' ')], + ['Handover Note', row.handoverNote || '-'], + ]; + + const rowsHtml = rows + .map( + ([label, value]) => + `${esc(label)}${esc(value)}`, + ) + .join(''); + + return ` + + + + Equipment Interchange Receipt + + + +
+
+ ${logoMarkup(opts.logoImageUrl)} +
Ethio-Djibouti Railway S.C.
+

Equipment Interchange Receipt

+
+
+ Receipt No. + ${esc(`EIR-${row.id.slice(0, 8).toUpperCase()}`)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+
+ + + + ${rowsHtml} + +
+ +
+ This receipt confirms the physical interchange of the equipment described above at the + depot/location and time stated. Both parties should verify the container number, size, + and condition recorded here before signing. +
+ +
+
Depot officer name / signature / date
+
Customer or driver name / signature / date
+
+ +`; + } + private async getOrCreateCustoms(bookingId: string) { const existing = await this.customs.findOne({ where: { bookingId } }); if (existing) return existing; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx index 38fda81e1..e6eb81a02 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx @@ -16,6 +16,7 @@ import { Textarea, Tooltip, } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; import { Ban, Download, @@ -32,7 +33,7 @@ import { isViewable } from "@edr/ui-common"; import { bookingsService } from "@/services/bookings.service"; import { downloadBookingFile, fetchViewableFile } from "@/services/files.service"; -import { formatDateTime } from "@/lib/format"; +import { formatDate, formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; const CURRENCIES = ["ETB", "USD"]; @@ -75,6 +76,7 @@ export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPayme currency: string; action: "draft" | "send"; file?: File | null; + dueDate?: string | null; }) => bookingsService.createAdditionalCharge(bookingId, p), onSuccess: (next, p) => { toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved"); @@ -203,16 +205,29 @@ function ChargeCard({ {charge.cancelReason ? ` — ${charge.cancelReason}` : ""} )} + {charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && ( + + Due {formatDate(charge.dueAt)} + + )} - - - {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} - {charge.currency} - - - {meta.label} - + + + + {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} + {charge.currency} + + + {meta.label} + + + {charge.convertedAmount != null && ( + + ≈ {charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} + {charge.convertedCurrency} + + )} @@ -297,12 +312,14 @@ function AddChargeModal({ currency: string; action: "draft" | "send"; file?: File | null; + dueDate?: string | null; }) => void; }) { const [reason, setReason] = useState(""); const [amount, setAmount] = useState(""); const [currency, setCurrency] = useState("ETB"); const [file, setFile] = useState(null); + const [dueDate, setDueDate] = useState(null); const valid = reason.trim().length > 0 && Number(amount) > 0; @@ -311,11 +328,23 @@ function AddChargeModal({ setAmount(""); setCurrency("ETB"); setFile(null); + setDueDate(null); }; const submit = (action: "draft" | "send") => { if (!valid) return; - onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file }); + onSubmit({ + reason: reason.trim(), + amount: Number(amount), + currency, + action, + file, + // Local calendar date, not a UTC-shifted ISO timestamp — toISOString() can + // roll the date back a day for evening local time in a positive-offset zone. + dueDate: dueDate + ? `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, "0")}-${String(dueDate.getDate()).padStart(2, "0")}` + : null, + }); }; return ( @@ -355,6 +384,14 @@ function AddChargeModal({ w={100} /> + setDueDate(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> {(props) => (