From a42d32c27c654bc459af3c2fbfcb9a1c32ffa61a Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 8 Aug 2026 13:37:05 +0000 Subject: [PATCH] feat(billing): USD offline bank-transfer payments --- .../src/modules/billing/billing.controller.ts | 45 +- .../src/modules/billing/billing.module.ts | 2 + .../modules/billing/billing.service.spec.ts | 9 + .../src/modules/billing/billing.service.ts | 150 ++++++ .../contract-onetime-paid-gate.spec.ts | 38 ++ .../modules/contracts/contracts.repository.ts | 8 +- .../scheduling-reschedule.service.ts | 4 + .../train-scheduling.controller.ts | 18 + .../train-checkpoint-events.repository.ts | 2 +- .../train-scheduling.service.spec.ts | 7 + .../train-scheduling.service.ts | 58 ++- .../train-scheduling.module.ts | 6 + .../derive-schedule-direction.util.spec.ts | 0 .../derive-schedule-direction.util.ts | 0 .../{ => utils}/fleet-plan.util.spec.ts | 4 +- .../{ => utils}/fleet-plan.util.ts | 5 + .../{ => utils}/wagon-plan.util.spec.ts | 4 +- .../{ => utils}/wagon-plan.util.ts | 5 + .../{ => utils}/wagon-readiness.util.spec.ts | 0 .../{ => utils}/wagon-readiness.util.ts | 0 .../utils/wagon-type-resolver.util.ts | 49 ++ .../src/seed/freight-permissions.registry.ts | 8 + apps/edr-freight-web/backoffice/src/App.tsx | 9 + .../components/layout/sidebar-sections.tsx | 7 + .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../backoffice/src/constants/URLS.ts | 2 + .../backoffice/src/lib/permissions.ts | 1 + .../src/pages/invoices/UsdPaymentsPage.tsx | 426 ++++++++++++++++++ .../backoffice/src/services/api.ts | 24 + .../src/services/invoices.service.ts | 22 + .../backoffice/src/types/invoice.ts | 19 + 31 files changed, 923 insertions(+), 11 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-onetime-paid-gate.spec.ts rename apps/edr-freight-api/src/modules/train-scheduling/{ => controllers}/train-scheduling.controller.ts (96%) rename apps/edr-freight-api/src/modules/train-scheduling/{ => repositories}/train-checkpoint-events.repository.ts (89%) rename apps/edr-freight-api/src/modules/train-scheduling/{ => services}/train-scheduling.service.spec.ts (99%) rename apps/edr-freight-api/src/modules/train-scheduling/{ => services}/train-scheduling.service.ts (98%) rename apps/edr-freight-api/src/modules/train-scheduling/{ => utils}/derive-schedule-direction.util.spec.ts (100%) rename apps/edr-freight-api/src/modules/train-scheduling/{ => utils}/derive-schedule-direction.util.ts (100%) rename apps/edr-freight-api/src/modules/train-scheduling/{ => utils}/fleet-plan.util.spec.ts (96%) rename apps/edr-freight-api/src/modules/train-scheduling/{ => utils}/fleet-plan.util.ts (95%) rename apps/edr-freight-api/src/modules/train-scheduling/{ => utils}/wagon-plan.util.spec.ts (98%) rename apps/edr-freight-api/src/modules/train-scheduling/{ => utils}/wagon-plan.util.ts (98%) rename apps/edr-freight-api/src/modules/train-scheduling/{ => utils}/wagon-readiness.util.spec.ts (100%) rename apps/edr-freight-api/src/modules/train-scheduling/{ => utils}/wagon-readiness.util.ts (100%) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-type-resolver.util.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index ea43c9c74..5bf1448fd 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -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" }) diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index a67e6f6ba..7ec5c333b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 6e25c72aa..9cc5e9315 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -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 }; }; @@ -703,6 +711,7 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => { payment as never, {} as never, {} as never, + {} as never, ); return { service, repo }; }; diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 5051afd6e..ef4b30f8c 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 { + 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 { const invoice = await this.invoices.findById(id, { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-onetime-paid-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-onetime-paid-gate.spec.ts new file mode 100644 index 000000000..0b2a16026 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-onetime-paid-gate.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index a7fddd816..2ab7e3d7e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -104,15 +104,19 @@ export class ContractsRepository extends BaseRepository { .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(); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index fefbbc868..61cbbed7f 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -11,8 +11,12 @@ 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'; +<<<<<<< Updated upstream import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingNotifierService } from '../train-scheduling/booking-notifier.service'; +======= +import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; +>>>>>>> Stashed changes import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts similarity index 96% rename from apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts rename to apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 2c92ba00d..3aa8f546e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -60,7 +60,25 @@ import { BookingWindowService } from "./booking-window.service"; import { IntercityService } from "./intercity.service"; import { BillingService } from "../billing/billing.service"; +<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @ApiTags("train-scheduling") +======= +import { TrainSchedulingManage, TrainSchedulingView } from '../../../common/booking-guards'; +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 { PinWagonsDto } from '../dto/pin-wagons.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 { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto'; +import { TrainSchedulingService } from '../services/train-scheduling.service'; + +@ApiTags('train-scheduling') +>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @ApiBearerAuth() @Controller("train-scheduling") export class TrainSchedulingController { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts b/apps/edr-freight-api/src/modules/train-scheduling/repositories/train-checkpoint-events.repository.ts similarity index 89% rename from apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts rename to apps/edr-freight-api/src/modules/train-scheduling/repositories/train-checkpoint-events.repository.ts index 210de382e..195b2e596 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/repositories/train-checkpoint-events.repository.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts similarity index 99% rename from apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts rename to apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 455dc3d9c..5740b92f9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -1,11 +1,18 @@ import { BadRequestException, ConflictException } from '@nestjs/common'; import { WagonStatus } from '@edr/types'; +<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts 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'; +>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts import { TrainSchedulingService } from './train-scheduling.service'; const nw5 = { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts similarity index 98% rename from apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts rename to apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 9a03f665c..555a34f9d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -34,6 +34,7 @@ import { Raw, } from 'typeorm'; +<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts import { buildPaginationMeta, normalizePagination, @@ -86,6 +87,39 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d 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 { BookingsRepository } from '../../bookings/bookings.repository'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { BookingContainer } from '../../bookings/entities/booking-container.entity'; +import { Container } from '../../container-management/entities/container.entity'; +import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { LocomotivesRepository } from '../../locomotives/locomotives.repository'; +import { Route } from '../../routes/entities/route.entity'; +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../../train-sets/entities/train-set.entity'; +import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.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 { 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 { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +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 { PinWagonsDto } from '../dto/pin-wagons.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'; +>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts import { ImportDjiboutiOperation, type ImportDjiboutiDocumentType, @@ -110,7 +144,7 @@ import { type BookingWagonShortage, type DeferredBookingRow, type FleetAvailabilityRow, -} from './fleet-plan.util'; +} from '../utils/fleet-plan.util'; import { applyWagonOrderReversal, planWagonsWithStock, @@ -130,6 +164,7 @@ import { validateMixedTrainLimitsPerEdge, type ContainerPlacementInput, type WagonPlanSlot, +<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts } from './wagon-plan.util'; import { CorridorBudget } from './corridor-capacity.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; @@ -179,6 +214,19 @@ import { placementsForBookings, type ContainerUnitForPlacement, } from './container-placement.util'; +======= +} from '../utils/wagon-plan.util'; +import { + getDefaultContainerWagonTypeCode, + pickBulkWagonType, +} from '../utils/wagon-type-resolver.util'; +import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util'; +import { flipReadiness, wagonReadinessMatchesSchedule } from '../utils/wagon-readiness.util'; +import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity'; +import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository'; +import { RecordCheckpointDto } from '../dto/record-checkpoint.dto'; +import { RouteMilestone } from '../../routes/entities/route-milestone.entity'; +>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; @@ -5767,7 +5815,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 ?? []) @@ -5779,6 +5827,7 @@ export class TrainSchedulingService { return null; } +<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts /** * Insert a schedule with a freshly generated S--NNNNN reference, retrying * past a concurrent insert that grabbed the same sequence (the unique index @@ -5810,6 +5859,9 @@ export class TrainSchedulingService { } private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) { +======= + private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) { +>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts return { id: schedule.id, reference: schedule.reference ?? null, @@ -7555,7 +7607,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 ?? [], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 56f2cff00..5335997a3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -24,6 +24,7 @@ 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'; +<<<<<<< Updated upstream import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingService } from './train-scheduling.service'; @@ -41,6 +42,11 @@ import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { ContractsModule } from '../contracts/contracts.module'; +======= +import { TrainCheckpointEventsRepository } from './repositories/train-checkpoint-events.repository'; +import { TrainSchedulingController } from './controllers/train-scheduling.controller'; +import { TrainSchedulingService } from './services/train-scheduling.service'; +>>>>>>> Stashed changes @Module({ imports: [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/derive-schedule-direction.util.spec.ts similarity index 100% rename from apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/derive-schedule-direction.util.spec.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/derive-schedule-direction.util.ts similarity index 100% rename from apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/derive-schedule-direction.util.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.spec.ts similarity index 96% rename from apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.spec.ts index ff685d30c..23a432a1c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.spec.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts similarity index 95% rename from apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts index 2769c212b..807218b13 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts @@ -1,6 +1,11 @@ +<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts 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 type { Booking } from '../../bookings/entities/booking.entity'; +import type { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts import { buildBulkWagonPlan, buildContainerWagonPlan, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts similarity index 98% rename from apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts index 43c9d0f31..52176662a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts similarity index 98% rename from apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index 9720e46c4..d0d11182b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -1,5 +1,6 @@ import { AllocationLoadType } from '@edr/types'; +<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts 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'; @@ -11,6 +12,10 @@ import { bulkTonWagonsRequired, consistViolations, } from './train-capacity.util'; +======= +import { Booking } from '../../bookings/entities/booking.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-readiness.util.spec.ts similarity index 100% rename from apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-readiness.util.spec.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-readiness.util.ts similarity index 100% rename from apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-readiness.util.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-type-resolver.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-type-resolver.util.ts new file mode 100644 index 000000000..6ce404ed1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-type-resolver.util.ts @@ -0,0 +1,49 @@ +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; + +const CARGO_CODE_TO_WAGON_TYPE: Record = { + 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; +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 46da39aad..3f9eecf98 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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 @@ -1630,6 +1637,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", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 07299462c..82e7836be 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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"; @@ -265,6 +266,14 @@ const App = () => { } /> + + + + } + /> , permission: FREIGHT_PERMS.invoices.view, }, + { + label: "USD Payments", + href: "/dashboard/usd-payments", + icon: , + permission: FREIGHT_PERMS.invoices.view, + }, { label: "Support", href: "/dashboard/support", diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index f34fa6470..ab76dff3e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -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, }, BOOKINGS: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index cb45a34ae..c8bc52e29 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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`, }, CUSTOMERS_API: { diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 8e58d642d..99529285c 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -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", }, firstMile: { view: "edr_freight_app:first_mile:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx new file mode 100644 index 000000000..585abea21 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx @@ -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 ( + + — + + ); + } + + const remaining = formatRemaining(new Date(deadline).getTime(), now); + if (!remaining) { + return ( + + Window closed + + ); + } + + return ( + + {remaining} + + ); +} + +/** 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(null); + const [slip, setSlip] = useState(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[] = useMemo( + () => [ + { + id: "invoiceNumber", + header: "Invoice", + cell: ({ row }) => ( + + {row.original.invoiceNumber} + + ), + }, + { + id: "billedTo", + header: "Customer", + cell: ({ row }) => ( + + {row.original.company?.name ?? "—"} + + ), + }, + { + id: "booking", + header: "Booking", + cell: ({ row }) => { + const booking = row.original.booking; + if (!booking) { + return ( + + {humanize(row.original.source)} + + ); + } + return ( + + ); + }, + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.totalAmount, row.original.currency)} + + ), + }, + { + id: "balance", + header: "Balance", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.balanceAmount, row.original.currency)} + + ), + }, + { + id: "payWindow", + header: "Pay window", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + ), + }, + { + id: "action", + header: "", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => { + const paid = row.original.status === "PAID"; + if (paid || !canConfirm) return null; + return ( + + ); + }, + }, + ], + [canConfirm, navigate], + ); + + return ( + + void refetch()} + > + + + } + /> + + + + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + style={{ flex: 1, minWidth: "240px" }} + radius="lg" + /> + { + 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" }, + ]} + /> + + {total} record{total !== 1 ? "s" : ""} + + + + + + + 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} + /> + + + + + + Confirm bank transfer payment + } + radius="md" + size="md" + > + {confirming && ( + + + Confirming settles {confirming.invoiceNumber} in full ( + {formatMoney(confirming.balanceAmount, confirming.currency)}) and + marks the booking as paid. Upload the customer's bank slip + first — this cannot be undone. + + + + + setReference(e.target.value)} + /> + + + + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index e3f8dbf15..119732fa9 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -41,6 +41,7 @@ import type { Invoice, InvoiceListFilter, PaginatedInvoices, + PaginatedOfflineUsdInvoices, } from "@/types/invoice"; import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; import { @@ -2914,6 +2915,29 @@ export const api = { ({ id }) => invoicesService.getById(id), ({ 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], + ), }, overview: { diff --git a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts index a4abc6ea2..18febdf7b 100644 --- a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts @@ -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 { + return apiClient + .get(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 { + const body = new FormData(); + body.append("file", file); + if (reference) body.append("reference", reference); + return apiClient + .post(URL_CONSTANTS.BILLING.CONFIRM_OFFLINE(id), body) + .then((r) => r.data); + }, }; diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts index 26ecfcafa..14c99eb50 100644 --- a/apps/edr-freight-web/backoffice/src/types/invoice.ts +++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts @@ -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; +}