diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index c148dd226..39eed76ea 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -22,6 +22,7 @@ "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", + "seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts", "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index b090624ff..c2a3a5abb 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -64,6 +64,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; +import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { VerifaydaModule } from './modules/verifayda/verifayda.module'; @@ -170,6 +171,7 @@ import { LoggerMiddleware } from "./logger.middleware"; ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, + PaidImportExportMileDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { diff --git a/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts new file mode 100644 index 000000000..6b2a422e8 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { PaidImportExportMileDemoSeeder } from '../seed/paid-import-export-mile-demo.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(PaidImportExportMileDemoSeeder); + await seeder.run(); + console.log('Paid import/export mile demo bookings seeded.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Paid import/export mile demo booking seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts new file mode 100644 index 000000000..708afb86b --- /dev/null +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -0,0 +1,299 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { DataSource } from 'typeorm'; + +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; +import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; +import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; + +const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE'; +const COMPANY_TIN = 'PAIDMILE001'; +const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; + +const YARDS = [ + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, +]; + +const CONTAINER_TYPES = [ + { code: '20FT', label: '20FT', sizeFt: 20 }, + { code: '40FT', label: '40FT', sizeFt: 40 }, +]; + +/** + * Six paid, approved container bookings that mirror the real trucking legs: + * - EXPORT (Ethiopia -> Djibouti) carries a FIRST-MILE leg (factory -> rail terminal). + * - IMPORT (Djibouti -> Ethiopia) carries a LAST-MILE leg (dry port -> final delivery). + * Each booking is paymentStatus PAID and its single mile leg is marked paid + ready to transit. + */ +const DEMO_BOOKINGS = [ + // ── IMPORT: last mile only ───────────────────────────────────────────── + { + reference: 'PAID-IMP-001', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 8, + totalWeightTons: 224, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-01T08:00:00.000Z', + lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa', + lastMileDeliveryLat: 8.8808, + lastMileDeliveryLng: 38.7876, + }, + { + reference: 'PAID-IMP-002', + tradeDirection: 'IMPORT', + containerCode: '20FT', + quantity: 12, + totalWeightTons: 240, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-02T08:00:00.000Z', + lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa', + lastMileDeliveryLat: 8.9137, + lastMileDeliveryLng: 38.7815, + }, + { + reference: 'PAID-IMP-003', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 6, + totalWeightTons: 180, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-03T08:00:00.000Z', + lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa', + lastMileDeliveryLat: 8.9806, + lastMileDeliveryLng: 38.8736, + }, + // ── EXPORT: first mile only ──────────────────────────────────────────── + { + reference: 'PAID-EXP-001', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 7, + totalWeightTons: 196, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-01T10:00:00.000Z', + firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa', + firstMilePickupLat: 8.9806, + firstMilePickupLng: 38.8736, + }, + { + reference: 'PAID-EXP-002', + tradeDirection: 'EXPORT', + containerCode: '20FT', + quantity: 11, + totalWeightTons: 220, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-02T10:00:00.000Z', + firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa', + firstMilePickupLat: 8.8808, + firstMilePickupLng: 38.7876, + }, + { + reference: 'PAID-EXP-003', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 4, + totalWeightTons: 128, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-03T10:00:00.000Z', + firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa', + firstMilePickupLat: 8.9137, + firstMilePickupLng: 38.7815, + }, +] as const; + +@Injectable() +export class PaidImportExportMileDemoSeeder { + private readonly logger = new Logger(PaidImportExportMileDemoSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Yard).upsert( + YARDS.map((yard) => ({ ...yard, isActive: true })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + { + code: SERVICE_TYPE_CODE, + serviceName: 'Rail Container with Paid First/Last Mile', + description: 'Demo service type for paid import/export bookings with a single mile leg', + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 11, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + CONTAINER_TYPES.map((containerType, index) => ({ + ...containerType, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: index + 1, + })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Company).upsert( + { + name: 'Paid Import/Export Mile Demo Customer', + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: COMPANY_TIN, + vatNumber: COMPANY_TIN, + fanNumber: 'PMD0000000000001', + country: 'Ethiopia', + address: 'Addis Ababa', + phone: '251900000202', + email: COMPANY_EMAIL, + website: null, + contactPersonName: 'Paid Mile Demo', + contactPersonPhone: '251900000202', + generalManagerName: 'Demo Manager', + generalManagerEmail: COMPANY_EMAIL, + generalManagerPhone: '251900000202', + }, + { conflictPaths: { tin: true } }, + ); + + const [serviceType, company, yards, containerTypes] = await Promise.all([ + manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }), + manager.getRepository(Yard).find(), + manager.getRepository(ContainerType).find(), + ]); + + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); + const containerTypeByCode = new Map( + containerTypes.map((containerType) => [containerType.code, containerType]), + ); + + for (const demoBooking of DEMO_BOOKINGS) { + const origin = yardByCode.get(demoBooking.originCode); + const destination = yardByCode.get(demoBooking.destinationCode); + const containerType = containerTypeByCode.get(demoBooking.containerCode); + + if (!origin || !destination || !containerType) { + throw new Error(`paid_import_export_mile_demo_dependency_missing:${demoBooking.reference}`); + } + + const isImport = demoBooking.tradeDirection === 'IMPORT'; + const wagonsRequired = + Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; + + await manager.getRepository(Booking).upsert( + { + reference: demoBooking.reference, + companyId: company.id, + status: 'APPROVED', + scheduledDate: new Date(demoBooking.scheduledDate), + estimatedShipmentDate: new Date(demoBooking.scheduledDate), + totalAmount: demoBooking.totalWeightTons * 25, + paymentStatus: 'PAID', + contractType: 'NEW', + serviceTypeId: serviceType.id, + // Only the leg that matches the trade direction carries an address. + firstMilePickupAddress: isImport ? null : demoBooking.firstMilePickupAddress, + firstMilePickupLat: isImport ? null : demoBooking.firstMilePickupLat, + firstMilePickupLng: isImport ? null : demoBooking.firstMilePickupLng, + lastMileDeliveryAddress: isImport ? demoBooking.lastMileDeliveryAddress : null, + lastMileDeliveryLat: isImport ? demoBooking.lastMileDeliveryLat : null, + lastMileDeliveryLng: isImport ? demoBooking.lastMileDeliveryLng : null, + equipmentReturn: 'WITHOUT_RETURN', + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: demoBooking.tradeDirection, + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: 'Demo container cargo', + shippingLineId: null, + cargoTotalWeightVgm: demoBooking.totalWeightTons, + isHazardous: false, + isReefer: false, + paymentCurrency: 'ETB', + approvedByStaffAt: new Date(), + priorityScore: 20, + wagonsRequired, + schedulingStatus: 'NOT_SCHEDULED', + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await manager.getRepository(Booking).findOneByOrFail({ + reference: demoBooking.reference, + }); + + await manager.getRepository(BookingContainer).delete({ bookingId: booking.id }); + await manager.getRepository(BookingContainer).insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + quantity: demoBooking.quantity, + vgmPerUnitTons, + totalVgmTons: demoBooking.totalWeightTons, + wagonsRequired, + weightLimitRuleId: null, + isOverweight: vgmPerUnitTons > 35, + overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null, + }); + + // Reset any existing legs for idempotency, then create the single paid leg. + await manager.getRepository(FirstMile).delete({ bookingId: booking.id }); + await manager.getRepository(LastMile).delete({ bookingId: booking.id }); + + const paidAmount = demoBooking.totalWeightTons * 25; + + if (isImport) { + await manager.getRepository(LastMile).insert({ + bookingId: booking.id, + status: 'READY_TO_TRANSIT', + advancedPayment: paidAmount, + remainingPayment: 0, + paid: true, + estimatedKm: 22, + exactKm: null, + vehicleId: null, + }); + } else { + await manager.getRepository(FirstMile).insert({ + bookingId: booking.id, + status: 'READY_TO_TRANSIT', + advancedPayment: paidAmount, + remainingPayment: 0, + paid: true, + estimatedKm: 18, + exactKm: null, + vehicleId: null, + }); + } + } + }); + + this.logger.log( + 'Seeded 6 paid bookings: 3 import (last-mile) + 3 export (first-mile).', + ); + } +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index a4e20b029..ee8abe106 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -172,5 +172,6 @@ export const URL_CONSTANTS = { BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`, DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`, RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`, + PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`, }, }; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx index 146e31c1e..acd4dbb25 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -1,16 +1,24 @@ -import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; -import { Download, Receipt } from "lucide-react"; +import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { CreditCard, Download, Receipt } from "lucide-react"; +import { useState } from "react"; import toast from "react-hot-toast"; +import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import { warehouseInvoicesService, type PortalWarehouseInvoice, } from "@/services/warehouse-invoices.service"; import { saveBlob } from "@/utils/download"; +import { PaymentMethodModal } from "./PaymentMethodModal"; import { CardTitle, SectionCard } from "./layout"; +/** Warehouse fee invoices the customer can still settle online. */ +const PAYABLE_STATUSES = new Set(["ISSUED", "PARTIALLY_PAID"]); +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}`; @@ -44,10 +52,12 @@ function StatusPill({ status }: { status: string }) { } /** - * Warehouse fee invoices linked to this booking — display + PDF download only. - * Paying them online is tracked separately (in-system demurrage/storage - * payment). Renders nothing when the booking has no warehouse fees. Carries - * `id="warehouse-payments"` so the invoice detail page can deep-link here. + * Warehouse fee invoices linked to this booking. Customers can pay outstanding + * demurrage/storage invoices online (Telebirr/Waafi) so they can then sign the + * delivery handover; paid invoices expose the receipt PDF. The backoffice cash + * `/pay` (record-a-payment) path is unaffected. Renders nothing when the booking + * has no warehouse fees. Carries `id="warehouse-payments"` so the invoice detail + * page can deep-link here. */ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { const { data: invoices = [] } = useQuery({ @@ -55,6 +65,41 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { queryFn: () => warehouseInvoicesService.listForBooking(bookingId), }); + const [payInvoice, setPayInvoice] = useState(null); + + const payMutation = useMutation({ + mutationFn: (method: PaymentMethod) => { + if (!payInvoice) throw new Error("No invoice selected for payment."); + return warehouseInvoicesService.payOnline(payInvoice.id, { + method, + platform: "web", + }); + }, + onSuccess: (data, method) => { + if (!payInvoice) return; + // Redirect to the provider (or the fallback checkout page) — same as the + // booking "Pay now" flow, so behaviour is identical everywhere. + const redirectUrl = + data?.clientAction?.type === "REDIRECT" && data.clientAction.url + ? data.clientAction.url + : paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method }); + window.location.href = redirectUrl; + }, + }); + + const payError = payMutation.isError + ? payMutation.error instanceof Error + ? payMutation.error.message + : "Could not start payment. Please try again." + : null; + + const closePayModal = () => { + if (!payMutation.isPending) { + setPayInvoice(null); + payMutation.reset(); + } + }; + if (invoices.length === 0) return null; const download = async (inv: PortalWarehouseInvoice) => { @@ -128,6 +173,17 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { + {isPayable(inv) && ( + + )} + + payMutation.mutate(method)} + processing={payMutation.isPending} + error={payError} + /> ); } diff --git a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts index f1fa4e841..91cd6a0d2 100644 --- a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts +++ b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts @@ -1,5 +1,10 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import { client } from "../utils/api"; +import type { + InitiateResponse, + PaymentMethod, + PaymentPlatform, +} from "./payments.service"; const W = URL_CONSTANTS.WAREHOUSE_INVOICES; @@ -51,4 +56,27 @@ export const warehouseInvoicesService = { const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" }); return data; }, + + /** + * Initiate a Telebirr/Waafi online payment for a warehouse demurrage/storage + * invoice. Returns the payment intent + `clientAction` to redirect the browser + * to the provider (mirrors the booking `/pay` flow). The backoffice cash + * `/pay` (record-a-payment) path is unaffected. + */ + payOnline: async ( + id: string, + payload: { + method: PaymentMethod; + platform?: PaymentPlatform; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; + }, + ): Promise => { + const { data } = await client.post(W.PAY_ONLINE(id), { + platform: "web", + ...payload, + }); + return data.data ?? data; + }, };