From 69df391f9052c572de02e4fd77c6d1f802ed8c58 Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 29 Jun 2026 16:03:53 +0300 Subject: [PATCH 001/401] Update payment service and API base URL for local development --- .../modules/payment/payment-client.service.ts | 4 +- .../src/modules/payment/payment.service.ts | 75 +++++++------------ .../backoffice/src/constants/apiConfig.ts | 4 +- .../portal/src/constants/apiConfig.ts | 4 +- 4 files changed, 35 insertions(+), 52 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 4c2ebe971..68ed249a5 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -19,8 +19,8 @@ export class PaymentClientService { private readonly logger = new Logger(PaymentClientService.name); private readonly baseUrl = ( // process.env.PAYMENT_API_URL ?? - "https://paymentcallback.triaplc.com" - // "http://localhost:3003" + // "https://paymentcallback.triaplc.com" + "http://localhost:3003" ).replace(/\/$/, ""); private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 9f216bc60..dbd635d86 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -163,10 +163,6 @@ export class PaymentService { failureUrl: 'https://edrfreight.triaplc.com/payment/failure', }); - await this.datasource.getRepository(Booking).update( - { id: dto.bookingId }, - { paymentStatus: "PAID", status: "PAID" }, - ); const intent = await this.syncIntentProjection(booking.id, booking, snapshot); if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { @@ -293,21 +289,20 @@ export class PaymentService { providerTxnId?: string; paidAt?: Date; }): Promise<{ alreadyFinalized: boolean }> { - // const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); - // if (!intent) throw new NotFoundException("PaymentIntent not found"); - // if (intent.status === "success") return { alreadyFinalized: true }; + const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success") return { alreadyFinalized: true }; const paidAt = input.paidAt ?? new Date(); // Every booking is a real shipment now (contracts are a separate aggregate), // so payment always settles the booking to PAID and enters allocation. await this.datasource.transaction(async (mg) => { - // await mg.update( - // PaymentEntity, - // // { id: intent.id }, - // {id:input.intentId}, - // { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, - // ); + await mg.update( + PaymentEntity, + { id: intent.id }, + { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, + ); await mg.update( Booking, { id: input.bookingId }, @@ -403,46 +398,34 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - const { alreadyFinalized } = await this.finalizePaymentSuccess({ - intentId:event.intentId, + if (event.eventType === "payment.succeeded") { + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + if (!intent) { + return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + } + const { alreadyFinalized } = await this.finalizePaymentSuccess({ + intentId: intent.id, bookingId: event.referenceId, providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, }); - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); return { processed: true, alreadyFinalized }; - // console.log(`Received payment event: ${JSON.stringify(event)}`); - // if (event.eventType === "payment.succeeded") { - // console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`); - // const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); - // if (!intent) { - // return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; - // } - // console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`); - // const { alreadyFinalized } = await this.finalizePaymentSuccess({ - // intentId: intent.id, - // bookingId: event.referenceId, - // providerTxnId: event.providerTxnId, - // paidAt: event.paidAt ? new Date(event.paidAt) : undefined, - // }); - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - // return { processed: true, alreadyFinalized }; - // } + } - // if (event.eventType === "payment.failed") { - // const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); - // if (!intent) { - // return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; - // } - // await this.markPaymentFailed({ - // intentId: intent.id, - // failureCode: event.failureCode, - // failureMessage: event.failureMessage, - // }); - // return { processed: true }; - // } + if (event.eventType === "payment.failed") { + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + if (!intent) { + return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + } + await this.markPaymentFailed({ + intentId: intent.id, + failureCode: event.failureCode, + failureMessage: event.failureMessage, + }); + return { processed: true }; + } - // return { processed: false, reason: `Unknown event type: ${event.eventType}` }; + return { processed: false, reason: `Unknown event type: ${event.eventType}` }; } private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] { diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 1217b8762..7a7604cc7 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index 1b070d87d..a24cb4a6d 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,5 +1,5 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the From a072f0445068263a5e212b238514775f4c1e85d6 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 30 Jun 2026 02:20:32 +0000 Subject: [PATCH 002/401] Enhance contract and booking request handling --- .../contracts/booking-request.repository.ts | 12 +- .../src/modules/payment/payment.service.ts | 25 +- .../contracts/detail/RequestDetailCards.tsx | 275 ++++++++++++++++ .../bookings/BookingRequestDetailPage.tsx | 22 +- .../contracts/ShipmentRequestDetailPage.tsx | 111 ++++--- .../ContractChangesRequestedView.tsx | 309 ++++++++++++++++++ .../pages/contracts/ContractDetailPage.tsx | 7 + packages/types/src/freight/contracts.ts | 36 ++ 8 files changed, 735 insertions(+), 62 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 9529cabbe..7a3695768 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -34,7 +34,17 @@ export class BookingRequestRepository extends BaseRepository { async findById(id: string): Promise { return this.repository.findOne({ where: { id }, - relations: { contract: true }, + // Load the contract with the bits the detail page surfaces: customer + // (company), service type (mile/customs flags), routes (with yard labels) + // and cargo scope. + relations: { + contract: { + company: true, + serviceType: true, + routes: { originYard: true, destinationYard: true }, + cargoScope: true, + }, + }, }); } diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 347fedc1e..23376820f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -17,12 +17,14 @@ import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; import { Booking } from "../bookings/entities/booking.entity"; +import { Invoice } from "../billing/entities/invoice.entity"; import { ClientAction, ProviderPaymentStatus, } from "@edr/payment-providers"; import { + Freight, PaymentService as PaymentServiceEnum, PaymentReferenceType, PaymentIntentSnapshot, @@ -462,30 +464,37 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { + console.log(`Payment succeeded event received for reference ${event.referenceId}`); const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { + console.warn(`No local intent found for reference ${event.referenceId}`); return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } - console.log(`Processing payment succeeded event for intent: }`,intent); + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, notify: true, }); - console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + console.log(`Payment intent ${intent.id} marked as succeeded (alreadyFinalized=${alreadyFinalized})`); - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { + // The invoice the intent settled is the authority on what was paid for. + // Its `paymentId` links 1:1 to this intent; when its source is a booking, + // `sourceId` holds that booking id — flip the booking itself paid. + const invoice = await this.datasource.manager.findOneBy(Invoice, { + paymentId: intent.id, + }); + console.log(`Invoice lookup for payment intent ${intent.id} returned invoice ${invoice?.id} (source=${invoice?.source}, sourceId=${invoice?.sourceId})`); + if (invoice?.source === Freight.InvoiceSource.Booking) { + console.log(`Marking booking ${invoice.sourceId} as PAID due to invoice ${invoice.id} settlement`); await this.datasource.manager.update( Booking, - { id: intent.refId }, + { id: invoice.sourceId }, { status: "PAID", paymentStatus: "PAID" }, ); } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + return { processed: true, alreadyFinalized }; } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx new file mode 100644 index 000000000..595252cbf --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx @@ -0,0 +1,275 @@ +import type { LucideIcon } from "lucide-react"; +import { + Building2, + FileCheck, + FileText, + Mail, + MapPin, + Package, + Phone, + Ship, + Truck, + User, + Warehouse, +} from "lucide-react"; +import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core"; +import type { Freight } from "@edr/types"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; + +type ReqContract = NonNullable; + +interface InfoRowProps { + icon: LucideIcon; + label: string; + value?: string | null; +} + +function InfoRow({ icon: Icon, label, value }: InfoRowProps) { + return ( + + + + + {label} + + + + {value || "—"} + + + ); +} + +function InfoRows({ rows }: { rows: InfoRowProps[] }) { + const visible = rows.filter((r) => r.value); + if (visible.length === 0) { + return ( + + No details available. + + ); + } + return ( + + {visible.map((row, i) => ( +
+ {i > 0 && } + +
+ ))} +
+ ); +} + +/** Customer (company) on the request's contract. */ +export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) { + const company = contract?.company; + if (!company) { + return ( + + + No customer linked to this request. + + + ); + } + return ( + + + + ); +} + +const fmtDate = (iso?: string | null) => + iso + ? new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }).format(new Date(iso)) + : "—"; + +const titleCase = (s?: string | null) => + s ? s.charAt(0) + s.slice(1).toLowerCase() : "—"; + +/** Contract identity + commercial terms. */ +export function RequestContractSummaryCard({ + contract, +}: { + contract?: ReqContract | null; +}) { + if (!contract) return null; + return ( + + + + ); +} + +/** Routes + cargo scope of the contract. */ +export function RequestRouteCargoCard({ + contract, +}: { + contract?: ReqContract | null; +}) { + const routes = contract?.routes ?? []; + const cargo = contract?.cargoScope ?? []; + const isContainer = contract?.freightType === "CONTAINER"; + return ( + + + + + Routes + + {routes.length === 0 ? ( + + No routes recorded. + + ) : ( + + {routes.map((r) => ( + + + + {r.originYard?.label ?? r.originYardId} →{" "} + {r.destinationYard?.label ?? r.destinationYardId} + + + ))} + + )} + + + + Cargo scope + + {cargo.length === 0 ? ( + + No cargo scope recorded. + + ) : ( + + {cargo.map((c) => ( + } + > + {c.containerSize ?? + c.cargoFreeText ?? + (isContainer ? "Container" : "Bulk commodity")} + + ))} + + )} + + + + ); +} + +/** Service type — what the contracted service bundles (rail-only vs logistics/customs). */ +export function RequestServiceTypeCard({ + contract, +}: { + contract?: ReqContract | null; +}) { + const st = contract?.serviceType; + if (!st) return null; + + const firstMile = st.includesFirstMile ?? false; + const lastMile = st.includesLastMile ?? false; + const customs = st.includesCustoms ?? false; + const railOnly = !firstMile && !lastMile && !customs; + + const chips: Array<{ label: string; color: string; icon: LucideIcon }> = []; + if (railOnly) chips.push({ label: "Rail only", color: "blue", icon: Ship }); + if (firstMile) + chips.push({ label: "First-mile pickup", color: "teal", icon: Truck }); + if (lastMile) + chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse }); + if (customs) + chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck }); + + return ( + + + + {chips.map((c) => ( + } + > + {c.label} + + ))} + + {st.description ? ( + + {st.description} + + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 75badc5b3..8cc6655dd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -289,16 +289,18 @@ export default function BookingRequestDetailPage() { booking={booking} mutations={mutations} /> - + {booking.customsClearingEnabled && ( + + )} {showContractButton && ( +
+ + {contract.reference} + + + Changes requested — update your documents and resubmit. + +
+ + + } + title="A reviewer asked for changes" + > + Update the documents below — replace anything that needs to change and + attach any required document that isn't on file yet — then resubmit the + contract for review. + + + + {onFile.length > 0 && ( + + + Already on file + + {onFile.map((file) => ( + + + + + + + {labelForDocCode(file.code)} + + + {file.name} + + + + + + + On file + + + + + + ))} + + )} + + + Update documents + + + Replace any document you need to change. Documents marked required + must be on file before you can resubmit. + + + {settingQuery.isLoading ? ( + + + + ) : settingQuery.data ? ( + + ) : ( + + No document requirements are configured for your account. You can + resubmit using the documents already on file. + + )} + + {error && ( + } + mt="md" + > + {error} + + )} + {(updateMutation.isError || submitMutation.isError) && ( + } + mt="md" + > + Couldn't resubmit. Please try again. + + )} + + + + + + ); +} + +export default ContractChangesRequestedView; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 4e40313c9..bd7e1bafe 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -46,6 +46,7 @@ import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; import { labelForDocCode } from "@/pages/bookings/resubmit"; import { ContractClearancePanel } from "./ContractClearancePanel"; +import { ContractChangesRequestedView } from "./ContractChangesRequestedView"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { BORDER, @@ -193,6 +194,12 @@ export default function ContractDetailPage() { ); } + // Staff returned the contract for changes — show the edit-and-resubmit view + // (update documents → resubmit) instead of the read-only detail. + if (contract.status === "CHANGES_REQUESTED") { + return ; + } + const isContainer = contract.freightType === "CONTAINER"; const isGeneral = contract.contractKind === "GENERAL"; const routes = contract.routes ?? []; diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 0dbadc50f..197df32a7 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -411,7 +411,10 @@ export interface IContract extends BaseEntity { id: string; code: string; serviceName: string; + description?: string | null; canBeBookedAlone: boolean; + includesFirstMile?: boolean; + includesLastMile?: boolean; includesCustoms: boolean; } | null; paymentCurrency: string; @@ -614,6 +617,39 @@ export interface IBookingRequest extends BaseEntity { reviewedByStaffId?: string | null; reviewedAt?: string | null; reviewNote?: string | null; + /** Loaded contract relation (request detail response includes it). */ + contract?: BookingRequestContract | null; +} + +/** Customer (company) summary carried on a request's contract. */ +export interface BookingRequestCompany { + id: string; + name?: string | null; + tin?: string | null; + email?: string | null; + phone?: string | null; + address?: string | null; + contactPersonName?: string | null; + contactPersonPhone?: string | null; +} + +/** + * The slice of the contract surfaced on the shipment-request detail page: + * identity, service type (mile/customs flags), customer, routes and cargo scope. + */ +export interface BookingRequestContract { + id: string; + reference: string; + contractKind: ContractKind; + tradeDirection: ContractTradeDirection; + freightType: ContractFreightType; + customsClearingEnabled: boolean; + paymentCurrency: string; + contractValidUntil?: string | null; + company?: BookingRequestCompany | null; + serviceType?: IContract["serviceType"]; + routes?: IContractRoute[]; + cargoScope?: IContractCargoScope[]; } export interface CreateBookingRequestDto { From 9674c9394d91a951d80f27ee55cec48b89a83fe8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 30 Jun 2026 02:52:19 +0000 Subject: [PATCH 003/401] Update logging configuration to use TYPEORM_LOGGING environment variable --- apps/edr-freight-api/src/config/database.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 0e7375b19..a75731cfd 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -118,6 +118,6 @@ export default registerAs("database", (): TypeOrmModuleOptions => { migrationsRun: true, // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, - logging: process.env.NODE_ENV === "development", + logging: process.env.TYPEORM_LOGGING === "true", }; }); From f51c0158141c839b98e94836af4542572522678c Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 30 Jun 2026 03:13:18 +0000 Subject: [PATCH 004/401] Enhance payment module --- .../src/config/database.config.ts | 3 ++- .../src/modules/payment/payment.module.ts | 4 ++++ .../src/modules/payment/payment.service.ts | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index a75731cfd..c27d381e2 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -118,6 +118,7 @@ export default registerAs("database", (): TypeOrmModuleOptions => { migrationsRun: true, // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, - logging: process.env.TYPEORM_LOGGING === "true", + logging: + process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"], }; }); diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 330521218..532e1c06b 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -13,6 +13,8 @@ import { import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { BillingModule } from "../billing/billing.module"; +import { FirstMileModule } from "../first-mile/first-mile.module"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentEntity } from "./entities/payment.entity"; @@ -57,6 +59,8 @@ function rabbitMQImport(): DynamicModule[] { HttpModule.register({ timeout: 10_000 }), ConfigModule, forwardRef(() => BillingModule), + forwardRef(() => TrainSchedulingModule), + FirstMileModule, TypeOrmModule.forFeature([ PaymentEntity, PaymentWebhookEventEntity, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 23376820f..e03d15d83 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -12,6 +12,8 @@ import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; import { BillingService } from "../billing/billing.service"; +import { FirstMileService } from "../first-mile/first-mile.service"; +import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import * as fs from "fs"; import * as path from "path"; @@ -106,6 +108,9 @@ export class PaymentService { private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BillingService)) private readonly billing: BillingService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, + private readonly firstMileService: FirstMileService, ) { } async getAll(filters: { @@ -212,6 +217,20 @@ export class PaymentService { failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", }); + //////////////// fake + + await this.datasource.manager.update( + Booking, + { id: input.referenceId }, + { status: "PAID", paymentStatus: "PAID" }, + ); + await this.firstMileService.acceptBooking(input.referenceId); + await this.bookingBatchService.ensurePaidBookingAllocated(input.referenceId); + + + //////////////// fake + + //update the booking heer for now the staus anf const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED; const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; From dd9a7e639aa4ea8c2d33a34263f04757b8986e07 Mon Sep 17 00:00:00 2001 From: marshal Date: Tue, 30 Jun 2026 06:15:57 +0300 Subject: [PATCH 005/401] console --- apps/edr-freight-api/src/modules/billing/billing.service.ts | 3 ++- apps/edr-freight-api/src/modules/payment/payment.service.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) 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 01b057a76..fa5681b06 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -492,7 +492,8 @@ export class BillingService { // the domain never supplies it. New sources add their uppercased value to // the PaymentReferenceType enum. referenceType: invoice.source.toUpperCase() as PaymentReferenceType, - orderRef: invoice.invoiceNumber, + // orderRef: invoice.invoiceNumber, + orderRef:Date.now().toString(), amountMinor: Math.round(Number(invoice.totalAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1ea41cee9..a6bd457fb 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -465,6 +465,7 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { + console.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { console.log(`Payment succeeded event received for reference ${event.referenceId}`); const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); From 14dde418f7ab43802b46c765a9400cb09b3ac159 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 03:55:57 +0000 Subject: [PATCH 006/401] fix(migrations): check if tables exist before CREATE TABLE Make invoices migration idempotent - skip table/index creation if they already exist. Prevents 'relation already exists' errors on redeployment. Co-Authored-By: Claude Haiku 4.5 --- .../1821000000002-CreateInvoices.ts | 134 ++++++++++-------- 1 file changed, 73 insertions(+), 61 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 93196578d..a36ffa5da 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -32,71 +32,83 @@ export class CreateInvoices1821000000002 implements MigrationInterface { `); } - await queryRunner.query(` - CREATE TABLE freight.invoices ( - id uuid NOT NULL DEFAULT uuid_generate_v4(), - invoice_number varchar(64) NOT NULL, - company_id uuid NOT NULL, - company_profile_id uuid NOT NULL, - total_amount numeric(14, 2) NOT NULL, - currency varchar(8) NOT NULL DEFAULT 'ETB', - status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', - source varchar(255) NOT NULL, - source_id varchar(255) NOT NULL, - type varchar(255) NOT NULL, - issued_at timestamptz, - payment_id uuid, - due_at timestamptz NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - deleted_at timestamptz, - CONSTRAINT pk_invoices PRIMARY KEY (id), - CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number), - CONSTRAINT fk_invoices_company FOREIGN KEY (company_id) - REFERENCES freight.companies (id) ON DELETE RESTRICT, - CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id) - REFERENCES freight.company_profiles (id) ON DELETE RESTRICT, - CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id) - REFERENCES freight.payments (id) ON DELETE SET NULL + const invoicesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'invoices';`, + ); + + if (!invoicesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_number varchar(64) NOT NULL, + company_id uuid NOT NULL, + company_profile_id uuid NOT NULL, + total_amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', + source varchar(255) NOT NULL, + source_id varchar(255) NOT NULL, + type varchar(255) NOT NULL, + issued_at timestamptz, + payment_id uuid, + due_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoices PRIMARY KEY (id), + CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number), + CONSTRAINT fk_invoices_company FOREIGN KEY (company_id) + REFERENCES freight.companies (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id) + REFERENCES freight.company_profiles (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id) + REFERENCES freight.payments (id) ON DELETE SET NULL + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, ); - `); - - await queryRunner.query( - `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, - ); - await queryRunner.query( - `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, - ); - await queryRunner.query( - `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, - ); - await queryRunner.query( - `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, - ); - - await queryRunner.query(` - CREATE TABLE freight.invoice_lines ( - id uuid NOT NULL DEFAULT uuid_generate_v4(), - invoice_id uuid NOT NULL, - charge_type varchar NOT NULL, - description varchar(255), - quantity numeric(12, 2) NOT NULL DEFAULT 1, - unit_rate numeric(14, 2) NOT NULL DEFAULT 0, - amount numeric(14, 2) NOT NULL, - currency varchar(8) NOT NULL DEFAULT 'ETB', - metadata jsonb, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - deleted_at timestamptz, - CONSTRAINT pk_invoice_lines PRIMARY KEY (id), - CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id) - REFERENCES freight.invoices (id) ON DELETE CASCADE + await queryRunner.query( + `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, ); - `); + await queryRunner.query( + `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, + ); + } - await queryRunner.query( - `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + const invoiceLinesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'invoice_lines';`, ); + + if (!invoiceLinesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.invoice_lines ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_id uuid NOT NULL, + charge_type varchar NOT NULL, + description varchar(255), + quantity numeric(12, 2) NOT NULL DEFAULT 1, + unit_rate numeric(14, 2) NOT NULL DEFAULT 0, + amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoice_lines PRIMARY KEY (id), + CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id) + REFERENCES freight.invoices (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + ); + } } public async down(queryRunner: QueryRunner): Promise { From a3741c45bf8522b3596a5adc17d865e0d9c1a031 Mon Sep 17 00:00:00 2001 From: yaschalew Date: Tue, 30 Jun 2026 07:01:32 +0300 Subject: [PATCH 007/401] fix --- apps/edr-freight-web/backoffice/src/constants/apiConfig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 1217b8762..7a7604cc7 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the From 0bc95341720c116bd962e94f703e3f1c949f47de Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 04:03:29 +0000 Subject: [PATCH 008/401] fix: replace apiClient with api in FirstMilePage Use correct import 'api' from '@/auth/http' instead of undefined 'apiClient'. Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 569511d30..164d88f0a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -441,7 +441,7 @@ const FirstMilePage = () => { }); const allocateMutation = useMutation({ - mutationFn: (data) => apiClient.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), + mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), onSuccess: () => { toast({ title: "Containers allocated" }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") }); From 360e61ac15b07ab5da2691d11dd776fcd5030e71 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 04:04:56 +0000 Subject: [PATCH 009/401] fix: use byId() instead of detail() in QUERY_KEYS QUERY_KEYS.FIRST_MILE and QUERY_KEYS.LAST_MILE have byId() not detail(). Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- .../backoffice/src/pages/operations/LastMilePage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 164d88f0a..52b3d7139 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -444,7 +444,7 @@ const FirstMilePage = () => { mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), onSuccess: () => { toast({ title: "Containers allocated" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") }); setContainerAllocationOpen(false); setContainerAllocationFirstMileId(null); }, diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 3de5e578e..a40721a1c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -395,7 +395,7 @@ const LastMilePage = () => { api.post(`/last-mile/${activeId}/allocate-containers`, data), onSuccess: () => { toast({ title: "Containers allocated", variant: "default" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.detail(activeId ?? "") }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.byId(activeId ?? "") }); closeAllocation(); }, onError: () => { From df6e417a56405221da559af0d34821a7bee12a43 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:07:13 +0000 Subject: [PATCH 010/401] feat: add fuel management system backend - FuelPurchase entity: record fuel purchases with cost tracking - FuelConsumption entity: monthly aggregation of fuel metrics - FuelService: record purchases, calculate stats, efficiency - FuelController: REST API for fuel operations - FuelModule: integrated into app - Migration: create fuel_purchases and fuel_consumption tables Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-api/src/app.module.ts | 2 + .../1840000000000-CreateFuelTables.ts | 79 +++++++++++++++++ .../fuel/dto/create-fuel-purchase.dto.ts | 40 +++++++++ .../fuel/entities/fuel-consumption.entity.ts | 35 ++++++++ .../fuel/entities/fuel-purchase.entity.ts | 51 +++++++++++ .../src/modules/fuel/fuel.controller.ts | 48 ++++++++++ .../src/modules/fuel/fuel.module.ts | 15 ++++ .../src/modules/fuel/fuel.repository.ts | 68 +++++++++++++++ .../src/modules/fuel/fuel.service.ts | 87 +++++++++++++++++++ 9 files changed, 425 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.controller.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.module.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.repository.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index db05ae26f..cbc8ce22a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -69,6 +69,7 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module'; import { OverviewModule } from './modules/overview/overview.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { DriversModule } from './modules/drivers/drivers.module'; +import { FuelModule } from './modules/fuel/fuel.module'; import { FirstMileModule } from './modules/first-mile/first-mile.module'; import { LastMileModule } from './modules/last-mile/last-mile.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; @@ -133,6 +134,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera OverviewModule, VehiclesModule, DriversModule, + FuelModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, diff --git a/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts new file mode 100644 index 000000000..a74a58f8e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateFuelTables1840000000000 implements MigrationInterface { + name = "CreateFuelTables1840000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const fuelPurchasesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`, + ); + + if (!fuelPurchasesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_purchases ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + purchase_date timestamptz NOT NULL, + liters numeric(10, 2) NOT NULL, + cost_per_liter numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + fuel_station varchar(255) NULL, + payment_method varchar(50) DEFAULT 'CASH', + odometer_reading numeric(10, 2) NULL, + driver_id uuid NULL, + receipt_number varchar(255) NULL, + notes text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_purchases PRIMARY KEY (id), + CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`, + ); + } + + const fuelConsumptionExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`, + ); + + if (!fuelConsumptionExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_consumption ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + month date NOT NULL, + total_liters numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + total_distance_km numeric(10, 2) NOT NULL, + fuel_efficiency_km_per_l numeric(10, 2) NULL, + number_of_purchases integer DEFAULT 0, + average_cost_per_liter numeric(10, 2) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_consumption PRIMARY KEY (id), + CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE, + CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month) + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts new file mode 100644 index 000000000..254af7104 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts @@ -0,0 +1,40 @@ +import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator'; +import { PaymentMethod } from '../entities/fuel-purchase.entity'; + +export class CreateFuelPurchaseDto { + @IsUUID() + vehicleId!: string; + + @IsDateString() + purchaseDate!: string; + + @IsNumber() + liters!: number; + + @IsNumber() + costPerLiter!: number; + + @IsOptional() + @IsString() + fuelStation?: string; + + @IsEnum(PaymentMethod) + @IsOptional() + paymentMethod?: PaymentMethod; + + @IsOptional() + @IsNumber() + odometerReading?: number; + + @IsOptional() + @IsUUID() + driverId?: string; + + @IsOptional() + @IsString() + receiptNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts new file mode 100644 index 000000000..002eca861 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts @@ -0,0 +1,35 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'fuel_consumption', schema: 'freight' }) +@Index(['vehicleId', 'month']) +export class FuelConsumption extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'month', type: 'date' }) + month!: Date; + + @Column({ name: 'total_liters', type: 'numeric', precision: 10, scale: 2 }) + totalLiters!: number; + + @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) + totalCost!: number; + + @Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2 }) + totalDistanceKm!: number; + + @Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true }) + fuelEfficiencyKmPerL?: number; + + @Column({ name: 'number_of_purchases', type: 'integer', default: 0 }) + numberOfPurchases!: number; + + @Column({ name: 'average_cost_per_liter', type: 'numeric', precision: 10, scale: 2, nullable: true }) + averageCostPerLiter?: number; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts new file mode 100644 index 000000000..163618d0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum PaymentMethod { + CASH = 'CASH', + CARD = 'CARD', + FUEL_CARD = 'FUEL_CARD', + TRANSFER = 'TRANSFER', + CHEQUE = 'CHEQUE', +} + +@Entity({ name: 'fuel_purchases', schema: 'freight' }) +export class FuelPurchase extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'purchase_date', type: 'timestamptz' }) + purchaseDate!: Date; + + @Column({ name: 'liters', type: 'numeric', precision: 10, scale: 2 }) + liters!: number; + + @Column({ name: 'cost_per_liter', type: 'numeric', precision: 10, scale: 2 }) + costPerLiter!: number; + + @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) + totalCost!: number; + + @Column({ name: 'fuel_station', nullable: true }) + fuelStation?: string; + + @Column({ name: 'payment_method', type: 'varchar', default: PaymentMethod.CASH }) + paymentMethod!: PaymentMethod; + + @Column({ name: 'odometer_reading', type: 'numeric', nullable: true }) + odometerReading?: number; + + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string; + + @Column({ name: 'receipt_number', nullable: true }) + receiptNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts new file mode 100644 index 000000000..b9d19626a --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { FuelService } from './fuel.service'; +import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; + +@ApiTags('Fuel Management') +@Controller('fuel') +export class FuelController { + constructor(private readonly fuelService: FuelService) {} + + @Post('purchases') + @ApiOperation({ summary: 'Record fuel purchase' }) + async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) { + return this.fuelService.recordFuelPurchase(dto); + } + + @Get('purchases/:vehicleId') + @ApiOperation({ summary: 'Get fuel purchases for vehicle' }) + async getFuelPurchases( + @Param('vehicleId') vehicleId: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + return this.fuelService.getFuelPurchases( + vehicleId, + new Date(startDate), + new Date(endDate), + ); + } + + @Get('consumption/:vehicleId/:month') + @ApiOperation({ summary: 'Get monthly fuel consumption' }) + async getMonthlyConsumption( + @Param('vehicleId') vehicleId: string, + @Param('month') month: string, + ) { + return this.fuelService.getMonthlyConsumption(vehicleId, new Date(month)); + } + + @Get('stats/:vehicleId') + @ApiOperation({ summary: 'Get fuel statistics for vehicle' }) + async getVehicleFuelStats( + @Param('vehicleId') vehicleId: string, + @Query('months') months: number = 12, + ) { + return this.fuelService.getVehicleFuelStats(vehicleId, months); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.module.ts b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts new file mode 100644 index 000000000..258350f1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FuelController } from './fuel.controller'; +import { FuelService } from './fuel.service'; +import { FuelRepository } from './fuel.repository'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([FuelPurchase, FuelConsumption])], + controllers: [FuelController], + providers: [FuelService, FuelRepository], + exports: [FuelService], +}) +export class FuelModule {} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts new file mode 100644 index 000000000..6e7c7901e --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from '@edr/api-common'; +import { DataSource } from 'typeorm'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Injectable() +export class FuelRepository extends BaseRepository { + constructor(dataSource: DataSource) { + super(FuelPurchase, dataSource.createEntityManager()); + } + + async findByVehicleAndDateRange( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.find({ + where: { + vehicleId, + purchaseDate: { + $gte: startDate, + $lte: endDate, + }, + }, + order: { purchaseDate: 'DESC' }, + }); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + const consumptionRepository = this.manager.getRepository(FuelConsumption); + return consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + } + + async updateMonthlyConsumption( + vehicleId: string, + month: Date, + data: Partial, + ): Promise { + const consumptionRepository = this.manager.getRepository(FuelConsumption); + let consumption = await consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + + if (!consumption) { + consumption = consumptionRepository.create({ + vehicleId, + month, + ...data, + }); + } else { + Object.assign(consumption, data); + } + + return consumptionRepository.save(consumption); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts new file mode 100644 index 000000000..e8d0f6d04 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { FuelRepository } from './fuel.repository'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; +import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; + +@Injectable() +export class FuelService { + constructor(private readonly fuelRepository: FuelRepository) {} + + async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise { + const totalCost = dto.liters * dto.costPerLiter; + + const purchase = this.fuelRepository.create({ + ...dto, + totalCost, + }); + + const saved = await this.fuelRepository.save(purchase); + + // Update monthly consumption + await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate)); + + return saved; + } + + async getFuelPurchases( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.fuelRepository.findByVehicleAndDateRange(vehicleId, startDate, endDate); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + return this.fuelRepository.getMonthlyConsumption(vehicleId, month); + } + + async getVehicleFuelStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate); + + const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0); + const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0; + + return { + vehicleId, + totalPurchases: purchases.length, + totalLiters, + totalCost, + averagePricePerLiter: averagePrice, + dateRange: { startDate, endDate }, + }; + } + + private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise { + const monthStart = new Date(date.getFullYear(), date.getMonth(), 1); + + const purchases = await this.fuelRepository.find({ + where: { + vehicleId, + purchaseDate: { + $gte: monthStart, + $lt: new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1), + }, + }, + }); + + const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0); + const numberOfPurchases = purchases.length; + const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0; + + await this.fuelRepository.updateMonthlyConsumption(vehicleId, monthStart, { + totalLiters, + totalCost, + numberOfPurchases, + averageCostPerLiter, + } as Partial); + } +} From 7b9cd8871298b29805062113e72dc5d1a0f4dab5 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:08:56 +0000 Subject: [PATCH 011/401] fix: correct fuel module TypeScript errors - Use @InjectRepository decorators for proper dependency injection - Fix BaseRepository initialization with Repository instance - Remove unnecessary DataSource references - Add proper type annotations to reduce handlers Co-Authored-By: Claude Haiku 4.5 --- .../src/modules/fuel/fuel.repository.ts | 36 +++++++++++------- .../src/modules/fuel/fuel.service.ts | 37 ++++++++++--------- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts index 6e7c7901e..d062c38e1 100644 --- a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts +++ b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts @@ -1,13 +1,19 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { BaseRepository } from '@edr/api-common'; -import { DataSource } from 'typeorm'; +import { Repository, Between } from 'typeorm'; import { FuelPurchase } from './entities/fuel-purchase.entity'; import { FuelConsumption } from './entities/fuel-consumption.entity'; @Injectable() export class FuelRepository extends BaseRepository { - constructor(dataSource: DataSource) { - super(FuelPurchase, dataSource.createEntityManager()); + constructor( + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + @InjectRepository(FuelConsumption) + private readonly consumptionRepository: Repository, + ) { + super(purchaseRepository); } async findByVehicleAndDateRange( @@ -15,13 +21,10 @@ export class FuelRepository extends BaseRepository { startDate: Date, endDate: Date, ): Promise { - return this.find({ + return this.purchaseRepository.find({ where: { vehicleId, - purchaseDate: { - $gte: startDate, - $lte: endDate, - }, + purchaseDate: Between(startDate, endDate), }, order: { purchaseDate: 'DESC' }, }); @@ -31,8 +34,7 @@ export class FuelRepository extends BaseRepository { vehicleId: string, month: Date, ): Promise { - const consumptionRepository = this.manager.getRepository(FuelConsumption); - return consumptionRepository.findOne({ + return this.consumptionRepository.findOne({ where: { vehicleId, month, @@ -45,8 +47,7 @@ export class FuelRepository extends BaseRepository { month: Date, data: Partial, ): Promise { - const consumptionRepository = this.manager.getRepository(FuelConsumption); - let consumption = await consumptionRepository.findOne({ + let consumption = await this.consumptionRepository.findOne({ where: { vehicleId, month, @@ -54,7 +55,7 @@ export class FuelRepository extends BaseRepository { }); if (!consumption) { - consumption = consumptionRepository.create({ + consumption = this.consumptionRepository.create({ vehicleId, month, ...data, @@ -63,6 +64,13 @@ export class FuelRepository extends BaseRepository { Object.assign(consumption, data); } - return consumptionRepository.save(consumption); + return this.consumptionRepository.save(consumption); + } + + async findPurchasesByVehicle(vehicleId: string): Promise { + return this.purchaseRepository.find({ + where: { vehicleId }, + order: { purchaseDate: 'DESC' }, + }); } } diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts index e8d0f6d04..9241f9975 100644 --- a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts +++ b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts @@ -1,4 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { FuelRepository } from './fuel.repository'; import { FuelPurchase } from './entities/fuel-purchase.entity'; import { FuelConsumption } from './entities/fuel-consumption.entity'; @@ -6,17 +8,21 @@ import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; @Injectable() export class FuelService { - constructor(private readonly fuelRepository: FuelRepository) {} + constructor( + private readonly fuelRepository: FuelRepository, + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + ) {} async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise { const totalCost = dto.liters * dto.costPerLiter; - const purchase = this.fuelRepository.create({ + const purchase = this.purchaseRepository.create({ ...dto, totalCost, }); - const saved = await this.fuelRepository.save(purchase); + const saved = await this.purchaseRepository.save(purchase); // Update monthly consumption await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate)); @@ -45,8 +51,8 @@ export class FuelService { const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate); - const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0); - const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0); + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0; return { @@ -61,19 +67,16 @@ export class FuelService { private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise { const monthStart = new Date(date.getFullYear(), date.getMonth(), 1); + const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1); - const purchases = await this.fuelRepository.find({ - where: { - vehicleId, - purchaseDate: { - $gte: monthStart, - $lt: new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1), - }, - }, - }); + const purchases = await this.fuelRepository.findByVehicleAndDateRange( + vehicleId, + monthStart, + monthEnd, + ); - const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0); - const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0); + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); const numberOfPurchases = purchases.length; const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0; @@ -82,6 +85,6 @@ export class FuelService { totalCost, numberOfPurchases, averageCostPerLiter, - } as Partial); + }); } } From 3f81bb77adfcdd8de95e99ad351f5d469f918708 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:11:13 +0000 Subject: [PATCH 012/401] feat: add fuel management frontend pages - FuelPurchasePage: Record fuel purchases, calculate total costs - FuelStatsPage: View fuel consumption stats, efficiency metrics - Routes: /dashboard/fuel-purchases and /dashboard/fuel-stats - Sidebar menu items in Fleet Management section Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-web/backoffice/src/App.tsx | 30 ++ .../src/pages/fleet/FuelPurchasePage.tsx | 320 ++++++++++++++++++ .../src/pages/fleet/FuelStatsPage.tsx | 207 +++++++++++ 3 files changed, 557 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 542a848a4..8d5b568dc 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -57,6 +57,8 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import RoutesPage from "./pages/fleet/RoutesPage"; +import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; +import FuelStatsPage from "./pages/fleet/FuelStatsPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -200,6 +202,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, // { // label: "Containers", // href: "/dashboard/containers", @@ -725,6 +739,22 @@ const App = () => { } /> + + + + } + /> + + + + } + /> { + const res = await api.get("/vehicles?pageSize=1000"); + return res.data?.data || []; + }, + }); + + // Fetch fuel purchases + const { data: purchasesData = [] } = useQuery({ + queryKey: ["fuel-purchases"], + queryFn: async () => { + const res = await api.get("/fuel/purchases"); + return res.data || []; + }, + }); + + // Record purchase mutation + const recordMutation = useMutation({ + mutationFn: async (data: typeof formData) => { + const res = await api.post("/fuel/purchases", { + ...data, + liters: parseFloat(data.liters.toString()), + costPerLiter: parseFloat(data.costPerLiter.toString()), + }); + return res.data; + }, + onSuccess: () => { + toast({ title: "Fuel purchase recorded" }); + setModalOpen(false); + setFormData({ + vehicleId: "", + purchaseDate: new Date().toISOString().split("T")[0], + liters: 0, + costPerLiter: 0, + fuelStation: "", + paymentMethod: "CASH", + odometerReading: undefined, + receiptNumber: "", + notes: "", + }); + qc.invalidateQueries({ queryKey: ["fuel-purchases"] }); + }, + onError: (error: any) => { + toast({ + title: "Error recording purchase", + message: error?.response?.data?.message || "Failed to record fuel purchase", + color: "red", + }); + }, + }); + + const vehicleOptions = + vehiclesData?.map((v: Vehicle) => ({ + value: v.id, + label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, + })) || []; + + const totalCost = formData.liters * formData.costPerLiter; + + return ( + + + + + Fuel Purchases + + + + {/* Stats Cards */} + + + + + Total Purchases + + + {purchasesData.length} + + + + + + + Total Liters + + + {purchasesData + .reduce((sum: number, p: FuelPurchase) => sum + p.liters, 0) + .toFixed(2)}{" "} + L + + + + + + + Total Cost + + + ETB {purchasesData + .reduce((sum: number, p: FuelPurchase) => sum + p.totalCost, 0) + .toLocaleString("en-US", { maximumFractionDigits: 2 })} + + + + + + + Avg Price/L + + + ETB{" "} + {( + purchasesData.reduce((sum: number, p: FuelPurchase) => sum + p.totalCost, 0) / + purchasesData.reduce((sum: number, p: FuelPurchase) => sum + p.liters, 0) || 0 + ).toFixed(2)} + + + + + + {/* Purchases Table */} + + + + + Vehicle + Date + Liters + Cost/L + Total + Station + Payment + + + + {(purchasesData as FuelPurchase[])?.map((purchase) => ( + + {purchase.vehicleName || purchase.vehicleId} + {new Date(purchase.purchaseDate).toLocaleDateString()} + {purchase.liters.toFixed(2)} + ETB {purchase.costPerLiter.toFixed(2)} + ETB {purchase.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + {purchase.fuelStation || "—"} + + {purchase.paymentMethod} + + + ))} + +
+
+ + {/* Modal */} + setModalOpen(false)} title="Record Fuel Purchase" size="lg"> + + setFormData({ ...formData, paymentMethod: val || "CASH" })} + /> + + setFormData({ ...formData, odometerReading: val as number | undefined })} + decimalScale={0} + min={0} + /> + + setFormData({ ...formData, receiptNumber: e.currentTarget.value })} + /> + + setFormData({ ...formData, notes: e.currentTarget.value })} + /> + + + + + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx new file mode 100644 index 000000000..b2efb85be --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -0,0 +1,207 @@ +import { useQuery } from "@tanstack/react-query"; +import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, Badge } from "@mantine/core"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { api } from "@/auth/http"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { useState } from "react"; + +interface FuelStats { + vehicleId: string; + totalPurchases: number; + totalLiters: number; + totalCost: number; + averagePricePerLiter: number; + dateRange: { startDate: string; endDate: string }; +} + +interface Vehicle { + id: string; + plateNumber: string; + manufacturer: string; + model: string; + actualDistanceKm?: number; +} + +export default function FuelStatsPage() { + const [selectedVehicleId, setSelectedVehicleId] = useState(""); + const [monthsBack, setMonthsBack] = useState("12"); + + // Fetch vehicles + const { data: vehiclesData } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: async () => { + const res = await api.get("/vehicles?pageSize=1000"); + return res.data?.data || []; + }, + }); + + // Fetch fuel stats + const { data: statsData } = useQuery({ + queryKey: ["fuel-stats", selectedVehicleId, monthsBack], + queryFn: async () => { + if (!selectedVehicleId) return null; + const res = await api.get(`/fuel/stats/${selectedVehicleId}?months=${monthsBack}`); + return res.data; + }, + enabled: !!selectedVehicleId, + }); + + const vehicleOptions = + vehiclesData?.map((v: Vehicle) => ({ + value: v.id, + label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, + })) || []; + + const selectedVehicle = vehiclesData?.find((v: Vehicle) => v.id === selectedVehicleId); + + const costPerKm = + statsData && selectedVehicle?.actualDistanceKm + ? (statsData.totalCost / selectedVehicle.actualDistanceKm).toFixed(2) + : "—"; + + const efficiency = statsData + ? (statsData.totalLiters > 0 ? (selectedVehicle?.actualDistanceKm || 0) / statsData.totalLiters : 0).toFixed(2) + : "—"; + + return ( + + + + + Fuel Consumption Analysis + + + {/* Filters */} + + + + setMonthsBack(val || "12")} + /> + + + + + {selectedVehicleId && statsData ? ( + <> + {/* Stats Cards */} + + + + + Total Purchases + + + {statsData.totalPurchases} + + + + + + + Total Fuel + + + {statsData.totalLiters.toFixed(2)} L + + + + + + + Total Cost + + + ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + + + + + + + Avg Price/L + + + ETB {statsData.averagePricePerLiter.toFixed(2)} + + + + + + {/* Efficiency Metrics */} + + + + + Fuel Efficiency + + + {efficiency} km/L + + + + + + + Cost per KM + + + ETB {costPerKm} + + + + + + {/* Summary */} + + +
+ + Summary + + + {selectedVehicle?.plateNumber} consumed{" "} + + {statsData.totalLiters.toFixed(2)} liters + {" "} + over the last {monthsBack} months, costing{" "} + + ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + + . Average fuel price was{" "} + + ETB {statsData.averagePricePerLiter.toFixed(2)} per liter + + . + +
+
+
+ + ) : ( + + + Select a vehicle to view fuel consumption statistics + + + )} +
+ ); +} From f27f00d4806787911047ff70a65a006ed477be53 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:15:43 +0000 Subject: [PATCH 013/401] fix: use vehiclesService and add VEHICLES QUERY_KEYS - Add VEHICLES to QUERY_KEYS constant - Use vehiclesService.getAll() instead of direct API call - Remove duplicate Vehicle type definitions - Fix TypeScript type references Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/constants/QUERY_KEYS.ts | 6 ++++++ .../src/pages/fleet/FuelPurchasePage.tsx | 13 ++++--------- .../src/pages/fleet/FuelStatsPage.tsx | 17 +++++------------ 3 files changed, 15 insertions(+), 21 deletions(-) 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 59b493bf3..c47f8e2e5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -98,6 +98,12 @@ export const QUERY_KEYS = { list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const, }, + VEHICLES: { + ROOT: ["vehicles"] as const, + list: (filter?: Record) => ["vehicles", "list", filter ?? {}] as const, + byId: (id: string) => ["vehicles", "detail", id] as const, + }, + FIRST_MILE: { ROOT: ["first-mile"] as const, list: (filter?: Record) => ["first-mile", "list", filter ?? {}] as const, diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx index deaca7041..0869ceb26 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -22,6 +22,7 @@ import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { useToast } from "@/hooks/use-toast"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; interface FuelPurchase { id: string; @@ -38,12 +39,6 @@ interface FuelPurchase { notes?: string; } -interface Vehicle { - id: string; - plateNumber: string; - manufacturer: string; - model: string; -} export default function FuelPurchasePage() { const { toast } = useToast(); @@ -65,8 +60,8 @@ export default function FuelPurchasePage() { const { data: vehiclesData } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), queryFn: async () => { - const res = await api.get("/vehicles?pageSize=1000"); - return res.data?.data || []; + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; }, }); @@ -115,7 +110,7 @@ export default function FuelPurchasePage() { }); const vehicleOptions = - vehiclesData?.map((v: Vehicle) => ({ + vehiclesData?.map((v: VehicleType) => ({ value: v.id, label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, })) || []; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx index b2efb85be..78ed36498 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -3,6 +3,7 @@ import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, B import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; import { useState } from "react"; interface FuelStats { @@ -14,14 +15,6 @@ interface FuelStats { dateRange: { startDate: string; endDate: string }; } -interface Vehicle { - id: string; - plateNumber: string; - manufacturer: string; - model: string; - actualDistanceKm?: number; -} - export default function FuelStatsPage() { const [selectedVehicleId, setSelectedVehicleId] = useState(""); const [monthsBack, setMonthsBack] = useState("12"); @@ -30,8 +23,8 @@ export default function FuelStatsPage() { const { data: vehiclesData } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), queryFn: async () => { - const res = await api.get("/vehicles?pageSize=1000"); - return res.data?.data || []; + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; }, }); @@ -47,12 +40,12 @@ export default function FuelStatsPage() { }); const vehicleOptions = - vehiclesData?.map((v: Vehicle) => ({ + vehiclesData?.map((v: VehicleType) => ({ value: v.id, label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, })) || []; - const selectedVehicle = vehiclesData?.find((v: Vehicle) => v.id === selectedVehicleId); + const selectedVehicle = vehiclesData?.find((v: VehicleType) => v.id === selectedVehicleId); const costPerKm = statsData && selectedVehicle?.actualDistanceKm From 21cf24950de5ed7af87032b12c28835254a361ac Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 10:50:43 +0000 Subject: [PATCH 014/401] feat: add partial payment to match the warehouse invoice before migration --- ...0000000-ExtendInvoicesForPartialPayment.ts | 71 ++++++++++ .../modules/billing/billing.service.spec.ts | 82 ++++++++++++ .../src/modules/billing/billing.service.ts | 124 +++++++++++++++++- .../billing/entities/invoice.entity.ts | 33 +++++ packages/types/src/freight/index.ts | 4 + 5 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts diff --git a/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts new file mode 100644 index 000000000..55239c13f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Extend `freight.invoices` into the billing record of record for every source + * (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be + * centralized onto it instead of the parallel `warehouse_fee_invoices` table. + * + * Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`), + * a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID` + * statuses the warehouse flow uses. + * + * Matches billing/entities/invoice.entity.ts. All columns are additive with + * defaults, so existing booking/demurrage rows are unaffected. + */ +export class ExtendInvoicesForPartialPayment1828000000000 + implements MigrationInterface +{ + name = "ExtendInvoicesForPartialPayment1828000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long + // as the value is not referenced in the same transaction (it is not here). + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`, + ); + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`, + ); + + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz, + ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]'; + `); + + // Backfill existing rows: subtotal mirrors the total (no tax was modeled), + // the outstanding balance is the full total for unpaid invoices. + await queryRunner.query(` + UPDATE freight.invoices + SET subtotal_amount = total_amount, + balance_amount = total_amount; + `); + + // Already-settled invoices: fully paid, zero balance, stamped from updated_at. + await queryRunner.query(` + UPDATE freight.invoices + SET paid_amount = total_amount, + balance_amount = 0, + paid_at = updated_at + WHERE status = 'PAID'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS payments, + DROP COLUMN IF EXISTS paid_at, + DROP COLUMN IF EXISTS balance_amount, + DROP COLUMN IF EXISTS paid_amount, + DROP COLUMN IF EXISTS tax_amount, + DROP COLUMN IF EXISTS subtotal_amount; + `); + // Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are + // left on freight.invoices_status_enum (harmless, unused after down). + } +} 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 0e6d97de0..5d407d3cd 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 @@ -180,6 +180,88 @@ describe("BillingService.markInvoiceAsPaid", () => { }); }); +describe("BillingService.recordPayment", () => { + function serviceFor(invoice: Record | null) { + const mg = { + findOne: jest.fn().mockResolvedValue(invoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + ); + return { service, mg, events }; + } + + const openInvoice = (overrides: Record = {}) => ({ + id: "inv-1", + status: Freight.InvoiceStatus.Issued, + source: "warehouse", + sourceId: "inv-item-1", + totalAmount: 1000, + paidAmount: 0, + balanceAmount: 1000, + payments: [], + paidAt: null, + ...overrides, + }); + + it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => { + const { service, mg, events } = serviceFor(openInvoice()); + + const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" }); + + expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid); + expect(updated.paidAmount).toBe(400); + expect(updated.balanceAmount).toBe(600); + expect(updated.payments).toHaveLength(1); + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + expect.objectContaining({ + status: Freight.InvoiceStatus.PartiallyPaid, + paidAmount: 400, + balanceAmount: 600, + }), + ); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => { + const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 })); + + const updated = await service.recordPayment("inv-1", { amount: 600 }); + + expect(updated.status).toBe(Freight.InvoiceStatus.Paid); + expect(updated.balanceAmount).toBe(0); + expect(updated.paidAt).toBeInstanceOf(Date); + expect(mg.update).toHaveBeenCalled(); + expect(events.emit).toHaveBeenCalledWith( + "warehouse.invoice.paid", + expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }), + ); + }); + + it("rejects a non-positive amount", async () => { + const { service, mg } = serviceFor(openInvoice()); + await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); + + it("rejects payment against a cancelled invoice", async () => { + const { service, mg } = serviceFor( + openInvoice({ status: Freight.InvoiceStatus.Cancelled }), + ); + await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); +}); + describe("BillingService.settlePayable", () => { it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => { const open = { 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 01b057a76..ce4341486 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,9 +1,16 @@ -import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { Freight, PaymentReferenceType } from "@edr/types"; import { DataSource, EntityManager, In } from "typeorm"; -import { Invoice } from "./entities/invoice.entity"; +import { Invoice, InvoicePayment } from "./entities/invoice.entity"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { InvoiceRepository } from "./invoice.repository"; import { InvoiceLineRepository } from "./invoice-line.repository"; @@ -20,16 +27,32 @@ export interface PayInvoiceOptions { failureUrl?: string; } +/** A single manual/offline settlement to record against an invoice. */ +export interface RecordPaymentInput { + /** Amount settled by this payment; must be > 0. */ + amount: number; + method?: string | null; + reference?: string | null; + /** When the settlement occurred; defaults to now. */ + paidAt?: Date; + metadata?: Record | null; +} + /** Default invoice payment-term window, in days, used to compute `dueAt`. */ const DEFAULT_DUE_DAYS = 14; /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ const OPEN_STATUSES: Freight.InvoiceStatus[] = [ Freight.InvoiceStatus.Draft, + Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.PartiallyPaid, Freight.InvoiceStatus.Overdue, ]; +/** Round to 2 decimals, avoiding binary float drift. */ +const round2 = (n: number): number => Math.round(n * 100) / 100; + /** A single line to bill on a generated invoice. */ export interface InvoiceLineInput { chargeType: string; @@ -56,7 +79,11 @@ export interface GenerateInvoiceInput { companyProfileId: string; lines: InvoiceLineInput[]; currency?: string; - /** Explicit total; defaults to the sum of line amounts. */ + /** Explicit pre-tax subtotal; defaults to the sum of line amounts. */ + subtotalAmount?: number; + /** Tax applied on top of the subtotal; defaults to 0. */ + taxAmount?: number; + /** Explicit total; defaults to `subtotalAmount + taxAmount`. */ totalAmount?: number; /** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */ dueAt?: Date; @@ -230,8 +257,12 @@ export class BillingService { }; }); + const subtotalAmount = + input.subtotalAmount ?? + lines.reduce((sum, l) => sum + Number(l.amount), 0); + const taxAmount = input.taxAmount ?? 0; const totalAmount = - input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); + input.totalAmount ?? round2(subtotalAmount + taxAmount); const dueAt = input.dueAt ?? @@ -250,7 +281,12 @@ export class BillingService { type: input.type, companyId: input.companyId, companyProfileId: input.companyProfileId, - totalAmount, + subtotalAmount: round2(subtotalAmount), + taxAmount: round2(taxAmount), + totalAmount: round2(totalAmount), + paidAmount: 0, + balanceAmount: round2(totalAmount), + payments: [], currency, status, issuedAt: issued ? new Date() : null, @@ -293,6 +329,84 @@ export class BillingService { ); } + /** + * Record a (possibly partial) settlement against an invoice and sync its + * status. Appends to the `payments` ledger, recomputes `paidAmount` / + * `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the + * balance reaches zero — PAID, stamping `paidAt` and emitting + * `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash + * at the warehouse counter); gateway settlement goes through + * {@link markInvoiceAsPaid}. + * + * Throws when the invoice is missing, cancelled, refunded, already fully paid, + * or when `amount` is not positive. Pass `manager` to enlist in a caller's + * transaction. + */ + async recordPayment( + invoiceId: string, + input: RecordPaymentInput, + manager?: EntityManager, + ): Promise { + if (!(input.amount > 0)) { + throw new BadRequestException("Payment amount must be greater than zero."); + } + + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + if (invoice.status === Freight.InvoiceStatus.Cancelled) { + throw new BadRequestException("Cannot pay a cancelled invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Refunded) { + throw new BadRequestException("Cannot pay a refunded invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException("Invoice is already fully paid."); + } + + const at = input.paidAt ?? new Date(); + const total = Number(invoice.totalAmount); + const paidAmount = round2(Number(invoice.paidAmount) + input.amount); + const balanceAmount = Math.max(0, round2(total - paidAmount)); + const fullyPaid = paidAmount >= total; + const status = fullyPaid + ? Freight.InvoiceStatus.Paid + : Freight.InvoiceStatus.PartiallyPaid; + + const entry: InvoicePayment = { + amount: round2(input.amount), + method: input.method ?? null, + reference: input.reference ?? null, + paidAt: at.toISOString(), + metadata: input.metadata ?? null, + }; + const payments = [...(invoice.payments ?? []), entry]; + + await mg.update( + Invoice, + { id: invoice.id }, + { + paidAmount, + balanceAmount, + status, + payments, + paidAt: fullyPaid ? at : invoice.paidAt ?? null, + } as never, + ); + + const updated = { + ...invoice, + paidAmount, + balanceAmount, + status, + payments, + paidAt: fullyPaid ? at : invoice.paidAt ?? null, + } as Invoice; + + if (fullyPaid) this.emitInvoiceEvent("paid", updated); + return updated; + } + /** * Mark an invoice refunded and emit `${source}.invoice.refunded`. * No-op when already refunded. diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 61bc9c16b..23c332f80 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -5,6 +5,16 @@ import { PaymentEntity } from "../../payment/entities/payment.entity"; import { Company } from "../../companies/entities/company.entity"; import { CompanyProfile } from "../../companies/entities/company-profile.entity"; +/** A single recorded settlement against an invoice (payment ledger entry). */ +export interface InvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + /** ISO timestamp of when the settlement was recorded. */ + paidAt: string; + metadata?: Record | null; +} + @Entity({ schema: "freight", name: "invoices" }) @Index(["companyId"]) @Index(["companyProfileId"]) @@ -28,9 +38,24 @@ export class Invoice extends BaseEntity { @JoinColumn({ name: "company_profile_id" }) companyProfile?: CompanyProfile; + /** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */ + @Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + subtotalAmount!: number; + + @Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + taxAmount!: number; + @Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 }) totalAmount!: number; + /** Cumulative amount settled so far (supports partial payment). */ + @Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + paidAmount!: number; + + /** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */ + @Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + balanceAmount!: number; + @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) currency!: string; @@ -62,6 +87,14 @@ export class Invoice extends BaseEntity { @Column({ name: "issued_at", type: "timestamptz", nullable: true }) issuedAt?: Date | null; + /** Set when the invoice is fully settled. */ + @Column({ name: "paid_at", type: "timestamptz", nullable: true }) + paidAt?: Date | null; + + /** Ledger of individual settlements (manual or gateway), newest last. */ + @Column({ name: "payments", type: "jsonb", default: () => "'[]'" }) + payments!: InvoicePayment[]; + /** The ID of the payment that generated this invoice. */ @Column({ name: "payment_id", type: "uuid", nullable: true }) paymentId?: string | null; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index dbfcf655d..171a294fb 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -131,7 +131,11 @@ export enum PaymentStatus { export enum InvoiceStatus { Draft = "DRAFT", + /** Issued and awaiting payment (alias of PENDING for fee invoices). */ + Issued = "ISSUED", Pending = "PENDING", + /** Some, but not all, of the balance has been settled. */ + PartiallyPaid = "PARTIALLY_PAID", Paid = "PAID", Overdue = "OVERDUE", Cancelled = "CANCELLED", From 3eb4a24199cceef5c4a4e68027fbbb2b56a5518f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 11:29:28 +0000 Subject: [PATCH 015/401] fix: terminal clearing int vite --- apps/edr-freight-web/backoffice/package.json | 2 +- apps/edr-freight-web/portal/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 93caae0a4..48b9bc0a9 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5183", + "dev": "vite --port 5183 --clearScreen false", "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", "preview": "vite preview --port 5183", diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 9f866d756..960531fda 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5173", + "dev": "vite --port 5173 --clearScreen false", "build": "tsc -b && vite build", "preview": "vite preview --port 5173", "lint": "eslint src", From 7fa18b8ee726ea41420afad945c73e48ba15a87a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 11:54:43 +0000 Subject: [PATCH 016/401] fix: reference type in payment service --- .../src/modules/payment/payment.service.ts | 988 ++++++++++-------- 1 file changed, 527 insertions(+), 461 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 347fedc1e..738a6d118 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -1,11 +1,11 @@ import { - BadRequestException, - forwardRef, - Inject, - Injectable, - InternalServerErrorException, - Logger, - NotFoundException, + BadRequestException, + forwardRef, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, } from "@nestjs/common"; import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; @@ -18,73 +18,70 @@ import * as path from "path"; import * as Handlebars from "handlebars"; import { Booking } from "../bookings/entities/booking.entity"; +import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers"; import { - ClientAction, - ProviderPaymentStatus, -} from "@edr/payment-providers"; -import { - PaymentService as PaymentServiceEnum, - PaymentReferenceType, - PaymentIntentSnapshot, - ProviderMethod, + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + PaymentIntentSnapshot, + ProviderMethod, } from "@edr/types"; import { - InitiateResponseDto, - IntentStatusDto, - PaymentPlatformDto, - RefundDto, + InitiateResponseDto, + IntentStatusDto, + PaymentPlatformDto, + RefundDto, } from "./payments.dto"; /** Everything the gateway needs to open an intent. Amount/currency are supplied by * the caller (billing) — this service never derives them from a domain record. */ export interface InitiateIntentInput { - /** Opaque domain reference (booking id, …). */ - referenceId: string; - /** Invoice source that owns the intent ('booking', …) — stored on the projection. */ - source: string; - /** Gateway reference type the intent is opened with (caller's domain decides it). */ - referenceType: PaymentReferenceType; - /** Human-readable order ref shown on provider pages. */ - orderRef: string; - /** Authoritative amount in minor units, computed by the caller. */ - amountMinor: number; - currency: string; - /** Stored on the intent projection for receipts/dashboards. */ - reason?: string; - /** Provider/method selector. */ - method: ProviderMethod | string; - platform?: PaymentPlatformDto; - payerAccount?: string; - returnUrl?: string; - failureUrl?: string; + /** Opaque domain reference (booking id, …). */ + referenceId: string; + /** Invoice source that owns the intent ('booking', …) — stored on the projection. */ + source: string; + /** Gateway reference type the intent is opened with (caller's domain decides it). */ + referenceType: PaymentReferenceType; + /** Human-readable order ref shown on provider pages. */ + orderRef: string; + /** Authoritative amount in minor units, computed by the caller. */ + amountMinor: number; + currency: string; + /** Stored on the intent projection for receipts/dashboards. */ + reason?: string; + /** Provider/method selector. */ + method: ProviderMethod | string; + platform?: PaymentPlatformDto; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; } export interface InitiateIntentResult { - intentId: string; - response: InitiateResponseDto; - /** True when the provider settled the charge synchronously during initiate. */ - immediateSuccess: boolean; - providerTxnId?: string; - paidAt?: Date; + intentId: string; + response: InitiateResponseDto; + /** True when the provider settled the charge synchronously during initiate. */ + immediateSuccess: boolean; + providerTxnId?: string; + paidAt?: Date; } const STATUS_MAP: Record = { - "action-required": ProviderPaymentStatus.REQUIRES_ACTION, - "processing": ProviderPaymentStatus.PROCESSING, - "success": ProviderPaymentStatus.SUCCEEDED, - "failed": ProviderPaymentStatus.FAILED, - "canceled": ProviderPaymentStatus.CANCELLED, - "refunded": ProviderPaymentStatus.CANCELLED, + "action-required": ProviderPaymentStatus.REQUIRES_ACTION, + processing: ProviderPaymentStatus.PROCESSING, + success: ProviderPaymentStatus.SUCCEEDED, + failed: ProviderPaymentStatus.FAILED, + canceled: ProviderPaymentStatus.CANCELLED, + refunded: ProviderPaymentStatus.CANCELLED, }; const PROVIDER_TO_METHOD: Record = { - TELEBIRR: "telebirr", - CBE_BIRR: "cbe-birr", - EBIRR: "ebirr", - WAAFI: "waafi", - CARD: "card", - DMONEY: "dmoney", - CAC_BANK: "cac-bank", + TELEBIRR: "telebirr", + CBE_BIRR: "cbe-birr", + EBIRR: "ebirr", + WAAFI: "waafi", + CARD: "card", + DMONEY: "dmoney", + CAC_BANK: "cac-bank", }; /** @@ -96,426 +93,495 @@ const PROVIDER_TO_METHOD: Record = { */ @Injectable() export class PaymentService { - private readonly logger = new Logger(PaymentService.name); + private readonly logger = new Logger(PaymentService.name); - constructor( - private readonly datasource: DataSource, - private readonly paymentRepo: PaymentRepository, - private readonly paymentClient: PaymentClientService, - @Inject(forwardRef(() => BillingService)) - private readonly billing: BillingService, - ) { } + constructor( + private readonly datasource: DataSource, + private readonly paymentRepo: PaymentRepository, + private readonly paymentClient: PaymentClientService, + @Inject(forwardRef(() => BillingService)) + private readonly billing: BillingService, + ) { } - async getAll(filters: { - search?: string; - status?: string; - method?: string; - page?: number; - pageSize?: number; - }) { - const { search, status, method, page = 1, pageSize = 10 } = filters; - const skip = (page - 1) * pageSize; + async getAll(filters: { + search?: string; + status?: string; + method?: string; + page?: number; + pageSize?: number; + }) { + const { search, status, method, page = 1, pageSize = 10 } = filters; + const skip = (page - 1) * pageSize; - const qb = this.paymentRepo.createQueryBuilder("payment"); + const qb = this.paymentRepo.createQueryBuilder("payment"); - if (search) { - qb.andWhere( - "(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)", - { search: `%${search}%` }, - ); - } - if (status) { - qb.andWhere("payment.status = :status", { status }); - } - if (method) { - qb.andWhere("payment.method = :method", { method }); - } + if (search) { + qb.andWhere( + "(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)", + { search: `%${search}%` }, + ); + } + if (status) { + qb.andWhere("payment.status = :status", { status }); + } + if (method) { + qb.andWhere("payment.method = :method", { method }); + } - const [items, total] = await qb - .orderBy("payment.createdAt", "DESC") - .skip(skip) - .take(pageSize) - .getManyAndCount(); + const [items, total] = await qb + .orderBy("payment.createdAt", "DESC") + .skip(skip) + .take(pageSize) + .getManyAndCount(); + return { + items: items.map((p) => ({ + id: p.id, + bookingId: p.refId, + amount: p.amount, + currency: p.currency, + method: p.method, + status: p.status, + merchantOrderId: p.merchantOrderId, + paidAt: p.paidAt, + createdAt: p.createdAt, + })), + total, + page, + pageSize, + }; + } + + /** Aggregate counts across ALL payments for the dashboard summary cards. */ + async getSummary() { + const rows = await this.paymentRepo + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .getRawMany<{ status: string; count: number }>(); + + const byStatus: Record = {}; + let total = 0; + for (const row of rows) { + byStatus[row.status] = row.count; + total += row.count; + } + + const paidAgg = await this.paymentRepo + .createQueryBuilder("payment") + .select("COALESCE(SUM(payment.amount), 0)", "sum") + .where("payment.status = :status", { status: "success" }) + .getRawOne<{ sum: string }>(); + + return { + total, + success: byStatus["success"] ?? 0, + processing: + (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), + failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), + refunded: byStatus["refunded"] ?? 0, + paidAmount: Number(paidAgg?.sum ?? 0), + }; + } + + /** + * Open a gateway intent for a caller-supplied amount/reference and project it + * locally. Returns the intent id (so billing can correlate the invoice) plus + * the client action. When the provider settles synchronously, the intent is + * marked paid WITHOUT emitting — the caller (billing) settles inline after it + * has stored the intent id, avoiding a settle-before-correlation race. + */ + async initiate(input: InitiateIntentInput): Promise { + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + orderRef: input.orderRef, + amountMinor: input.amountMinor, + currency: input.currency, + provider: input.method as ProviderMethod, + platform: input.platform, + payerAccount: input.payerAccount, + returnUrl: + input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + failureUrl: + input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", + }); + + const immediateSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED; + const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; + + const intent = await this.upsertIntent(input, snapshot); + + if (immediateSuccess) { + // Settle the projection but DO NOT notify billing — billing settles + // inline once it has stored intentId on the invoice (see payInvoice), + // avoiding a settle-before-correlation race. + await this.markIntentSucceeded(intent.id, { + providerTxnId: snapshot.providerTxnId, + paidAt, + notify: false, + }); + } + + return { + intentId: intent.id, + // `intent` still reflects the projection status ("processing" on immediate + // success — settlement is applied by the caller, not shown synchronously). + response: this.formatIntentResponse(intent), + immediateSuccess, + providerTxnId: snapshot.providerTxnId, + paidAt, + }; + } + + /** Create or update the local intent projection from a provider snapshot. */ + private async upsertIntent( + input: InitiateIntentInput, + snapshot: PaymentIntentSnapshot, + ): Promise { + const existing = await this.paymentRepo.findOneBy({ + refId: input.referenceId, + }); + + const method: PaymentEntity["method"] = + PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; + const status = + snapshot.status === ProviderPaymentStatus.SUCCEEDED + ? "processing" + : this.toLocalStatus(snapshot.status); + + const clientAction = (snapshot.clientAction ?? undefined) as + | Record + | undefined; + const data = { + status, + method, + merchantOrderId: + snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "", + transactionId: snapshot.providerTxnId ?? existing?.transactionId, + expiresAt: snapshot.expiresAt + ? new Date(snapshot.expiresAt) + : existing?.expiresAt, + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }; + + if (existing) { + await this.paymentRepo.update({ id: existing.id }, { + ...data, + clientAction, + } as any); + return { ...existing, ...data, clientAction } as PaymentEntity; + } + + return this.paymentRepo.create({ + refId: input.referenceId, + type: input.source, + referenceType: input.referenceType, + amount: input.amountMinor, + currency: input.currency as PaymentEntity["currency"], + reason: input.reason ?? `Payment for ${input.orderRef}`, + rawInitiation: snapshot as unknown as Record, + clientAction: clientAction ?? {}, + ...data, + } as any); + } + + /** + * Reconcile an intent's status with the gateway by reference. Read-only on the + * domain side: it syncs the local projection and, when the provider reports a + * newly-observed success, notifies billing to settle. `referenceId` is opaque + * (the booking id, but this service does not load it). + */ + async getIntentByBookingId(referenceId: string): Promise { + const local = await this.paymentRepo.findOneBy({ refId: referenceId }); + + let snapshot: PaymentIntentSnapshot | null = null; + try { + snapshot = await this.paymentClient.getIntentByReference( + (local?.referenceType as PaymentReferenceType) ?? + PaymentReferenceType.SHIPMENT, + referenceId, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `payment service lookup failed for reference ${referenceId}: ${message}; using local intent`, + ); + } + + if (!snapshot) { + if (!local) throw new NotFoundException("PaymentIntent not found"); + return this.formatIntentStatus(local); + } + if (!local) throw new NotFoundException("PaymentIntent not found"); + + // Sync local projection with provider-reported status. + const becameSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED && + local.status !== "success"; + + if (becameSuccess) { + await this.markIntentSucceeded(local.id, { + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + notify: true, + }); + } else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) { + await this.paymentRepo.update( + { id: local.id }, + { + status: this.toLocalStatus(snapshot.status), + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }, + ); + } + + const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); + return this.formatIntentStatus(refreshed ?? local); + } + + /** + * Mark a gateway intent paid and (by default) notify billing to settle the + * linked invoice. Idempotent — no-op when already success. Pass `notify: false` + * when the caller settles inline and will trigger settlement itself. + */ + async markIntentSucceeded( + intentId: string, + opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, + ): Promise<{ alreadyFinalized: boolean }> { + const intent = await this.paymentRepo.findOneBy({ id: intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success") return { alreadyFinalized: true }; + + const paidAt = opts.paidAt ?? new Date(); + await this.paymentRepo.update( + { id: intent.id }, + { + status: "success", + paidAt, + transactionId: opts.providerTxnId ?? intent.transactionId, + }, + ); + + if (opts.notify !== false) { + await this.billing.settleByPaymentId( + intent.id, + opts.providerTxnId, + paidAt, + ); + } + + return { alreadyFinalized: false }; + } + + async markPaymentFailed(input: { + intentId: string; + failureCode?: string; + failureMessage?: string; + }): Promise { + const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success" || intent.status === "canceled") return; + + await this.paymentRepo.update( + { id: intent.id }, + { + status: "failed", + failerCode: input.failureCode, + failureMessage: input.failureMessage, + }, + ); + + // Invoice stays open for retry — nothing to settle. Logged only. + this.logger.warn( + `Payment ${intent.id} failed for ${intent.refId}` + + (input.failureMessage ? `: ${input.failureMessage}` : ""), + ); + } + + async refund(dto: RefundDto) { + const intent = await this.paymentRepo.findOneBy({ + refId: dto.bookingId, + type: "booking", + }); + if (!intent || intent.status !== "success") { + throw new BadRequestException("No successful payment to refund"); + } + + // NOTE: refunding still mutates the booking directly — left intact pending + // the refund redesign. TODO: route refunds through billing.refundPayable + + // a `${source}.invoice.refunded` reaction, like settlement. + await this.datasource.transaction(async (mg) => { + await mg.update( + PaymentEntity, + { id: intent.id }, + { status: "refunded", refundedAt: new Date() }, + ); + await mg.update( + Booking, + { id: dto.bookingId }, + { paymentStatus: "FAILED", status: "CANCELLED" }, + ); + }); + + return { refunded: true, bookingId: dto.bookingId }; + } + + async getActivePaymentByOrderIdAndMethod( + orderId: string, + method: PaymentEntity["method"], + ): Promise { + return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method); + } + + async genReceiptHtml(orderId: string) { + const payment = await this.paymentRepo.findOneBy({ + merchantOrderId: orderId, + status: "success", + }); + if (!payment) + throw new BadRequestException( + "No successful payment found for this order", + ); + + const filePath = path.join(__dirname, "templates", "receipt.hbs"); + if (!fs.existsSync(filePath)) throw new InternalServerErrorException(); + + const source = fs.readFileSync(filePath, "utf8"); + const template = Handlebars.compile(source); + return template({ + vendorName: "Ethio Djibouti Railway Freight Booking", + vendorAddress: "Addis Ababa", + receiptDate: payment.paidAt, + paymentMethod: payment.method, + subtotal: payment.amount.toString(), + total: payment.amount.toString(), + currency: payment.currency, + reason: payment.reason, + }); + } + + findBookingById(id: string) { + return this.paymentRepo.findOneBy({ refId: id }); + } + + formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { + const clientAction = + intent.clientAction && typeof intent.clientAction === "object" + ? (intent.clientAction as unknown as ClientAction) + : undefined; + return { + intentId: intent.id, + status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, + clientAction, + merchantOrderId: intent.merchantOrderId ?? undefined, + }; + } + + private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { + return { + ...this.formatIntentResponse(intent), + paidAt: intent.paidAt?.toISOString(), + failureCode: intent.failerCode ?? undefined, + failureMessage: intent.failureMessage ?? undefined, + }; + } + + async handlePaymentEvent(event: { + eventType: string; + eventId: string; + referenceId: string; + intentId: string; + providerTxnId?: string; + paidAt?: string; + failureCode?: string; + failureMessage?: string; + }): Promise<{ + processed: boolean; + alreadyFinalized?: boolean; + reason?: string; + }> { + console.log(`Received payment event: ${JSON.stringify(event)}`); + if (event.eventType === "payment.succeeded") { + const intent = await this.paymentRepo.findOneBy({ + refId: event.referenceId, + }); + if (!intent) { return { - items: items.map((p) => ({ - id: p.id, - bookingId: p.refId, - amount: p.amount, - currency: p.currency, - method: p.method, - status: p.status, - merchantOrderId: p.merchantOrderId, - paidAt: p.paidAt, - createdAt: p.createdAt, - })), - total, - page, - pageSize, + processed: false, + reason: `No local intent for reference ${event.referenceId}`, }; - } + } + console.log(`Processing payment succeeded event for intent: }`, intent); + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { + providerTxnId: event.providerTxnId, + paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + notify: true, + }); + console.log( + `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, + ); - /** Aggregate counts across ALL payments for the dashboard summary cards. */ - async getSummary() { - const rows = await this.paymentRepo - .createQueryBuilder("payment") - .select("payment.status", "status") - .addSelect("COUNT(*)::int", "count") - .groupBy("payment.status") - .getRawMany<{ status: string; count: number }>(); - - const byStatus: Record = {}; - let total = 0; - for (const row of rows) { - byStatus[row.status] = row.count; - total += row.count; - } - - const paidAgg = await this.paymentRepo - .createQueryBuilder("payment") - .select("COALESCE(SUM(payment.amount), 0)", "sum") - .where("payment.status = :status", { status: "success" }) - .getRawOne<{ sum: string }>(); - - return { - total, - success: byStatus["success"] ?? 0, - processing: - (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), - failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), - refunded: byStatus["refunded"] ?? 0, - paidAmount: Number(paidAgg?.sum ?? 0), - }; - } - - /** - * Open a gateway intent for a caller-supplied amount/reference and project it - * locally. Returns the intent id (so billing can correlate the invoice) plus - * the client action. When the provider settles synchronously, the intent is - * marked paid WITHOUT emitting — the caller (billing) settles inline after it - * has stored the intent id, avoiding a settle-before-correlation race. - */ - async initiate(input: InitiateIntentInput): Promise { - const snapshot = await this.paymentClient.initiate({ - service: PaymentServiceEnum.FREIGHT, - referenceType: input.referenceType, - referenceId: input.referenceId, - orderRef: input.orderRef, - amountMinor: input.amountMinor, - currency: input.currency, - provider: input.method as ProviderMethod, - platform: input.platform, - payerAccount: input.payerAccount, - returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", - failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", - }); - - const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED; - const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; - - const intent = await this.upsertIntent(input, snapshot); - - if (immediateSuccess) { - // Settle the projection but DO NOT notify billing — billing settles - // inline once it has stored intentId on the invoice (see payInvoice), - // avoiding a settle-before-correlation race. - await this.markIntentSucceeded(intent.id, { - providerTxnId: snapshot.providerTxnId, - paidAt, - notify: false, - }); - } - - return { - intentId: intent.id, - // `intent` still reflects the projection status ("processing" on immediate - // success — settlement is applied by the caller, not shown synchronously). - response: this.formatIntentResponse(intent), - immediateSuccess, - providerTxnId: snapshot.providerTxnId, - paidAt, - }; - } - - /** Create or update the local intent projection from a provider snapshot. */ - private async upsertIntent( - input: InitiateIntentInput, - snapshot: PaymentIntentSnapshot, - ): Promise { - const existing = await this.paymentRepo.findOneBy({ - refId: input.referenceId, - }); - - const method: PaymentEntity["method"] = - PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; - const status = - snapshot.status === ProviderPaymentStatus.SUCCEEDED - ? "processing" - : this.toLocalStatus(snapshot.status); - - const clientAction = (snapshot.clientAction ?? undefined) as - | Record - | undefined; - const data = { - status, - method, - merchantOrderId: snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "", - transactionId: snapshot.providerTxnId ?? existing?.transactionId, - expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : existing?.expiresAt, - failerCode: snapshot.failureCode ?? undefined, - failureMessage: snapshot.failureMessage ?? undefined, - }; - - if (existing) { - await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any); - return { ...existing, ...data, clientAction } as PaymentEntity; - } - - return this.paymentRepo.create({ - refId: input.referenceId, - type: input.source, - referenceType: input.referenceType, - amount: input.amountMinor, - currency: input.currency as PaymentEntity["currency"], - reason: input.reason ?? `Payment for ${input.orderRef}`, - rawInitiation: snapshot as unknown as Record, - clientAction: clientAction ?? {}, - ...data, - } as any); - } - - /** - * Reconcile an intent's status with the gateway by reference. Read-only on the - * domain side: it syncs the local projection and, when the provider reports a - * newly-observed success, notifies billing to settle. `referenceId` is opaque - * (the booking id, but this service does not load it). - */ - async getIntentByBookingId(referenceId: string): Promise { - const local = await this.paymentRepo.findOneBy({ refId: referenceId }); - - let snapshot: PaymentIntentSnapshot | null = null; - try { - snapshot = await this.paymentClient.getIntentByReference( - (local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT, - referenceId, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.warn( - `payment service lookup failed for reference ${referenceId}: ${message}; using local intent`, - ); - } - - if (!snapshot) { - if (!local) throw new NotFoundException("PaymentIntent not found"); - return this.formatIntentStatus(local); - } - if (!local) throw new NotFoundException("PaymentIntent not found"); - - // Sync local projection with provider-reported status. - const becameSuccess = - snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success"; - - if (becameSuccess) { - await this.markIntentSucceeded(local.id, { - providerTxnId: snapshot.providerTxnId, - paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, - notify: true, - }); - } else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) { - await this.paymentRepo.update( - { id: local.id }, - { - status: this.toLocalStatus(snapshot.status), - failerCode: snapshot.failureCode ?? undefined, - failureMessage: snapshot.failureMessage ?? undefined, - }, - ); - } - - const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); - return this.formatIntentStatus(refreshed ?? local); - } - - /** - * Mark a gateway intent paid and (by default) notify billing to settle the - * linked invoice. Idempotent — no-op when already success. Pass `notify: false` - * when the caller settles inline and will trigger settlement itself. - */ - async markIntentSucceeded( - intentId: string, - opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, - ): Promise<{ alreadyFinalized: boolean }> { - const intent = await this.paymentRepo.findOneBy({ id: intentId }); - if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success") return { alreadyFinalized: true }; - - const paidAt = opts.paidAt ?? new Date(); - await this.paymentRepo.update( - { id: intent.id }, - { status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId }, + // When the intent references a booking, flip the booking itself paid. + // refId holds the booking id (the domain reference the intent opened with). + if (intent.referenceType === PaymentReferenceType.BOOKING) { + await this.datasource.manager.update( + Booking, + { id: intent.refId }, + { status: "PAID", paymentStatus: "PAID" }, ); - - if (opts.notify !== false) { - await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt); - } - - return { alreadyFinalized: false }; + } + // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + return { processed: true, alreadyFinalized }; } - async markPaymentFailed(input: { - intentId: string; - failureCode?: string; - failureMessage?: string; - }): Promise { - const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); - if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success" || intent.status === "canceled") return; - - await this.paymentRepo.update( - { id: intent.id }, - { status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage }, - ); - - // Invoice stays open for retry — nothing to settle. Logged only. - this.logger.warn( - `Payment ${intent.id} failed for ${intent.refId}` + - (input.failureMessage ? `: ${input.failureMessage}` : ""), - ); - } - - async refund(dto: RefundDto) { - const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); - if (!intent || intent.status !== "success") { - throw new BadRequestException("No successful payment to refund"); - } - - // NOTE: refunding still mutates the booking directly — left intact pending - // the refund redesign. TODO: route refunds through billing.refundPayable + - // a `${source}.invoice.refunded` reaction, like settlement. - await this.datasource.transaction(async (mg) => { - await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); - await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - - async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise { - return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method); - } - - async genReceiptHtml(orderId: string) { - const payment = await this.paymentRepo.findOneBy({ merchantOrderId: orderId, status: "success" }); - if (!payment) throw new BadRequestException("No successful payment found for this order"); - - const filePath = path.join(__dirname, "templates", "receipt.hbs"); - if (!fs.existsSync(filePath)) throw new InternalServerErrorException(); - - const source = fs.readFileSync(filePath, "utf8"); - const template = Handlebars.compile(source); - return template({ - vendorName: "Ethio Djibouti Railway Freight Booking", - vendorAddress: "Addis Ababa", - receiptDate: payment.paidAt, - paymentMethod: payment.method, - subtotal: payment.amount.toString(), - total: payment.amount.toString(), - currency: payment.currency, - reason: payment.reason, - }); - } - - findBookingById(id: string) { - return this.paymentRepo.findOneBy({ refId: id }); - } - - formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { - const clientAction = - intent.clientAction && typeof intent.clientAction === "object" - ? (intent.clientAction as unknown as ClientAction) - : undefined; + if (event.eventType === "payment.failed") { + const intent = await this.paymentRepo.findOneBy({ + refId: event.referenceId, + }); + if (!intent) { return { - intentId: intent.id, - status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, - clientAction, - merchantOrderId: intent.merchantOrderId ?? undefined, + processed: false, + reason: `No local intent for reference ${event.referenceId}`, }; + } + await this.markPaymentFailed({ + intentId: intent.id, + failureCode: event.failureCode, + failureMessage: event.failureMessage, + }); + return { processed: true }; } - private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { - return { - ...this.formatIntentResponse(intent), - paidAt: intent.paidAt?.toISOString(), - failureCode: intent.failerCode ?? undefined, - failureMessage: intent.failureMessage ?? undefined, - }; + return { + processed: false, + reason: `Unknown event type: ${event.eventType}`, + }; + } + + private toLocalStatus( + status: ProviderPaymentStatus, + ): PaymentEntity["status"] { + switch (status) { + case ProviderPaymentStatus.SUCCEEDED: + return "success"; + case ProviderPaymentStatus.FAILED: + return "failed"; + case ProviderPaymentStatus.CANCELLED: + return "canceled"; + case ProviderPaymentStatus.PROCESSING: + return "processing"; + default: + return "action-required"; } + } - async handlePaymentEvent(event: { - eventType: string; - eventId: string; - referenceId: string; - intentId: string; - providerTxnId?: string; - paidAt?: string; - failureCode?: string; - failureMessage?: string; - }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); - if (event.eventType === "payment.succeeded") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); - if (!intent) { - return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; - } - console.log(`Processing payment succeeded event for intent: }`,intent); - const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { - providerTxnId: event.providerTxnId, - paidAt: event.paidAt ? new Date(event.paidAt) : undefined, - notify: true, - }); - console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { - await this.datasource.manager.update( - Booking, - { id: intent.refId }, - { status: "PAID", paymentStatus: "PAID" }, - ); - } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - return { processed: true, alreadyFinalized }; - } - - if (event.eventType === "payment.failed") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); - if (!intent) { - return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; - } - await this.markPaymentFailed({ - intentId: intent.id, - failureCode: event.failureCode, - failureMessage: event.failureMessage, - }); - return { processed: true }; - } - - return { processed: false, reason: `Unknown event type: ${event.eventType}` }; - } - - private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] { - switch (status) { - case ProviderPaymentStatus.SUCCEEDED: return "success"; - case ProviderPaymentStatus.FAILED: return "failed"; - case ProviderPaymentStatus.CANCELLED: return "canceled"; - case ProviderPaymentStatus.PROCESSING: return "processing"; - default: return "action-required"; - } - } - - async findByCompanyId(companyId: string) { - return this.paymentRepo.findByCompanyId(companyId); - } + async findByCompanyId(companyId: string) { + return this.paymentRepo.findByCompanyId(companyId); + } } From 5ad4efd7eb7bd1b5d1d60779ed67df4bbfbe1137 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 11:58:28 +0000 Subject: [PATCH 017/401] feat: add pdf to the central invoice system --- .../src/modules/billing/billing.module.ts | 2 + .../modules/billing/billing.service.spec.ts | 6 + .../src/modules/billing/billing.service.ts | 95 +++++-- .../billing/documents/documents.module.ts | 16 ++ .../documents/invoice-document.service.ts | 179 +++++++++++++ .../billing/documents/pdf-render.service.ts | 160 ++++++++++++ .../modules/billing/invoice-numbering.util.ts | 44 ++++ .../billing/invoice-settlement.util.ts | 36 +++ .../warehouses/warehouse-invoice.service.ts | 240 ++++++------------ .../warehouse-release-document.service.ts | 104 +------- .../modules/warehouses/warehouses.module.ts | 2 + 11 files changed, 618 insertions(+), 266 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/billing/documents/documents.module.ts create mode 100644 apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts create mode 100644 apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts create mode 100644 apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts create mode 100644 apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts 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 551fae6bf..dc78cd6e9 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; import { PortalBillingController } from "./portal-billing.controller"; import { BillingService } from "./billing.service"; +import { DocumentsModule } from "./documents/documents.module"; import { Invoice } from "./entities/invoice.entity"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { InvoiceRepository } from "./invoice.repository"; @@ -16,6 +17,7 @@ import { CompaniesModule } from "../companies/companies.module"; TypeOrmModule.forFeature([Invoice, InvoiceLine]), forwardRef(() => PaymentModule), CompaniesModule, + DocumentsModule, ], controllers: [BillingController, PortalBillingController], 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 5d407d3cd..e52dfafa1 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 @@ -76,6 +76,7 @@ describe("BillingService.generateInvoice", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); }); @@ -134,6 +135,7 @@ describe("BillingService.markInvoiceAsPaid", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -171,6 +173,7 @@ describe("BillingService.markInvoiceAsPaid", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -194,6 +197,7 @@ describe("BillingService.recordPayment", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); return { service, mg, events }; } @@ -282,6 +286,7 @@ describe("BillingService.settlePayable", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); const settled = await service.settlePayable( @@ -316,6 +321,7 @@ describe("BillingService.settlePayable", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); const settled = await service.settlePayable( 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 ce4341486..edacc2c79 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -14,6 +14,12 @@ import { Invoice, InvoicePayment } from "./entities/invoice.entity"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { InvoiceRepository } from "./invoice.repository"; import { InvoiceLineRepository } from "./invoice-line.repository"; +import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; +import { applySettlement, round2 } from "./invoice-settlement.util"; +import { + InvoiceDocumentModel, + InvoiceDocumentService, +} from "./documents/invoice-document.service"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto } from "../payment/payments.dto"; import { CompaniesService } from "../companies/companies.service"; @@ -50,9 +56,6 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [ Freight.InvoiceStatus.Overdue, ]; -/** Round to 2 decimals, avoiding binary float drift. */ -const round2 = (n: number): number => Math.round(n * 100) / 100; - /** A single line to bill on a generated invoice. */ export interface InvoiceLineInput { chargeType: string; @@ -122,6 +125,7 @@ export class BillingService { @Inject(forwardRef(() => PaymentService)) private readonly payment: PaymentService, private readonly companies: CompaniesService, + private readonly invoiceDocuments: InvoiceDocumentService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -142,6 +146,69 @@ export class BillingService { return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; } + // ── Documents (central PDF) ────────────────────────────────────────────────── + + /** Sealed PDF invoice for any source, rendered by the shared document service. */ + async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + return this.invoiceDocuments.render(this.toDocumentModel(invoice, "INVOICE")); + } + + /** Sealed PDF receipt; available once any payment has been recorded. */ + async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + if (Number(invoice.paidAmount) <= 0) { + throw new BadRequestException("A receipt is available only after payment is recorded."); + } + return this.invoiceDocuments.render(this.toDocumentModel(invoice, "RECEIPT")); + } + + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ + private toDocumentModel( + invoice: Invoice & { lines: InvoiceLine[] }, + kind: "INVOICE" | "RECEIPT", + ): InvoiceDocumentModel { + const title = invoice.source + ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1) + : "EDR"; + const totals: InvoiceDocumentModel["totals"] = [ + { label: "Subtotal", amount: Number(invoice.subtotalAmount) }, + ]; + if (Number(invoice.taxAmount) > 0) { + totals.push({ label: "Tax", amount: Number(invoice.taxAmount) }); + } + totals.push({ label: "Total", amount: Number(invoice.totalAmount), grand: true }); + totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); + totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); + + return { + kind, + title, + documentNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt ?? invoice.createdAt, + status: invoice.status, + currency: invoice.currency, + summary: [ + { label: "Status", value: invoice.status }, + { label: "Type", value: invoice.type }, + { label: "Reference", value: invoice.sourceId }, + { label: "Currency", value: invoice.currency }, + { label: "Issued", value: invoice.issuedAt ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") : null }, + { label: "Due", value: invoice.dueAt ? new Date(invoice.dueAt).toLocaleDateString("en-GB") : null }, + ], + categoryHeader: "Charge type", + lines: invoice.lines.map((l) => ({ + description: l.description ?? l.chargeType, + category: l.chargeType, + quantity: l.quantity, + unitRate: l.unitRate, + amount: l.amount, + currency: l.currency, + })), + totals, + }; + } + // ── Customer-scoped reads (portal) ─────────────────────────────────────────── /** Resolve the customer's company id from their IAM user id (null if none). */ @@ -203,17 +270,8 @@ export class BillingService { // ── Generation ─────────────────────────────────────────────────────────────── /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */ - private async nextInvoiceNumber(mg: EntityManager): Promise { - const now = new Date(); - const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; - const prefix = `FRT-${ymd}-`; - const [row] = await mg.query( - `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq - FROM freight.invoices WHERE invoice_number LIKE $1`, - [`${prefix}%`], - ); - const next = Number(row?.seq ?? 0) + 1; - return `${prefix}${String(next).padStart(5, "0")}`; + private nextInvoiceNumber(mg: EntityManager): Promise { + return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code: "FRT" }); } /** @@ -365,10 +423,11 @@ export class BillingService { } const at = input.paidAt ?? new Date(); - const total = Number(invoice.totalAmount); - const paidAmount = round2(Number(invoice.paidAmount) + input.amount); - const balanceAmount = Math.max(0, round2(total - paidAmount)); - const fullyPaid = paidAmount >= total; + const { paidAmount, balanceAmount, fullyPaid } = applySettlement( + invoice.totalAmount, + invoice.paidAmount, + input.amount, + ); const status = fullyPaid ? Freight.InvoiceStatus.Paid : Freight.InvoiceStatus.PartiallyPaid; diff --git a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts new file mode 100644 index 000000000..c320a5d44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts @@ -0,0 +1,16 @@ +import { Module } from "@nestjs/common"; + +import { InvoiceDocumentService } from "./invoice-document.service"; +import { PdfRenderService } from "./pdf-render.service"; + +/** + * Standalone document infrastructure — generic HTML→PDF plus the shared + * invoice/receipt renderer. Has no domain dependencies, so any module (billing, + * warehouses, …) can import it to print invoices without coupling to the + * billing payment graph. + */ +@Module({ + providers: [PdfRenderService, InvoiceDocumentService], + exports: [PdfRenderService, InvoiceDocumentService], +}) +export class DocumentsModule {} diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts new file mode 100644 index 000000000..a07087f8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -0,0 +1,179 @@ +import { Injectable } from "@nestjs/common"; + +import { PdfRenderService } from "./pdf-render.service"; + +export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; + +/** One billed line on the document (charge type / fee type agnostic). */ +export interface InvoiceDocumentLine { + description: string | null; + /** Optional categorisation column (e.g. "Fee type" / "Charge type"). */ + category?: string | null; + quantity?: number | null; + unitRate?: number | null; + amount?: number | null; + currency?: string | null; +} + +/** A labelled total row in the totals box; mark `grand` for the headline total. */ +export interface InvoiceDocumentTotal { + label: string; + amount: number; + grand?: boolean; +} + +/** + * Source-agnostic description of a printable invoice/receipt. Each billing + * source maps its own entity onto this shape; the renderer owns the layout so + * every EDR invoice document looks identical regardless of source. + */ +export interface InvoiceDocumentModel { + kind: InvoiceDocumentKind; + /** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */ + title: string; + documentNumber: string; + issuedAt?: Date | string | null; + status: string; + currency: string; + /** Free-form summary grid (label/value pairs). */ + summary: Array<{ label: string; value: string | null }>; + /** Header for the line-item category column; column hidden when omitted. */ + categoryHeader?: string; + lines: InvoiceDocumentLine[]; + totals: InvoiceDocumentTotal[]; + /** Override the round seal text; defaults from kind/status. */ + sealText?: string; +} + +/** + * Central invoice/receipt PDF renderer shared by every billing source. Turns a + * {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it + * via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in + * `WarehouseInvoiceService`; it now serves all invoices. + */ +@Injectable() +export class InvoiceDocumentService { + constructor(private readonly pdf: PdfRenderService) {} + + async render( + model: InvoiceDocumentModel, + ): Promise<{ filename: string; buffer: Buffer }> { + const html = this.buildHtml(model); + const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; + return { + filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, + buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }), + }; + } + + buildHtml(model: InvoiceDocumentModel): string { + const esc = (value: unknown) => + String(value ?? "-") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + const money = (amount: unknown, currency = model.currency) => + `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; + + const showCategory = Boolean(model.categoryHeader); + const sealText = + model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + + const summaryRows = model.summary + .map((row) => `
${esc(row.label)}${esc(row.value)}
`) + .join(""); + + const itemRows = model.lines + .map( + (item) => ` + ${esc(item.description)} + ${showCategory ? `${esc((item.category ?? "").replace(/_/g, " "))}` : ""} + ${esc(item.quantity ?? 0)} + ${esc(money(item.unitRate, item.currency ?? model.currency))} + ${esc(money(item.amount, item.currency ?? model.currency))} + `, + ) + .join(""); + + const totalRows = model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount))}
`, + ) + .join(""); + + return ` + + + + ${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"} + + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}

+
+
+ Document no. + ${esc(model.documentNumber)} + Issued: ${esc(date(model.issuedAt))} +
+
+
${esc(sealText)}
+
${summaryRows}
+ + + + + ${showCategory ? `` : ""} + + + + + + + ${itemRows} + +
Description${esc(model.categoryHeader)}QtyRateAmount
+
${totalRows}
+ +
+ +`; + } + + safeFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, "-"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts new file mode 100644 index 000000000..447bc2516 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -0,0 +1,160 @@ +import { existsSync } from "fs"; + +import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; + +const MIN_VALID_PDF_BYTES = 2_000; + +const PDF_PRINT_STYLES = ` +`; + +export interface PdfRenderOptions { + /** Label used in logs to identify the document kind. */ + label?: string; + /** + * Degraded renderer used when Chromium is unavailable. Receives the + * print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-` + * header). When omitted, a generic single-page fallback is produced. + */ + fallback?: (preparedHtml: string) => Buffer; +} + +/** + * Generic HTML → PDF renderer shared by every document producer (invoices, + * receipts, warehouse release orders). Renders via headless Chromium when + * available and degrades to a caller-supplied (or generic) hand-built PDF + * otherwise. This is pure infrastructure — it knows nothing about invoices. + */ +@Injectable() +export class PdfRenderService { + private readonly logger = new Logger(PdfRenderService.name); + + async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise { + const label = opts.label ?? "document"; + const preparedHtml = this.injectPdfPrintStyles(html); + const executablePath = this.resolveExecutablePath(); + + try { + const puppeteer = await import("puppeteer"); + const launchOptions: import("puppeteer").LaunchOptions = { + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"], + ...(executablePath ? { executablePath } : {}), + }; + + const browser = await puppeteer.default.launch(launchOptions); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); + await page.emulateMediaType("print"); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const pdf = await page.pdf({ + format: "A4", + printBackground: true, + margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, + }); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`); + } + this.logger.log( + `${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`, + ); + return buffer; + } finally { + await browser.close(); + } + } catch (error) { + this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`); + const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } + throw new InternalServerErrorException( + `${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`, + ); + } + } + + private injectPdfPrintStyles(html: string): string { + if (html.includes("edr-pdf-print-fix")) return html; + if (html.includes("")) { + return html.replace("", `${PDF_PRINT_STYLES}`); + } + return `${PDF_PRINT_STYLES}${html}`; + } + + private resolveExecutablePath(): string | undefined { + const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + + const candidates = [ + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + ]; + return candidates.find((path) => existsSync(path)); + } + + isValidPdf(buffer: Buffer): boolean { + return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-"; + } + + /** Minimal valid one-page PDF carrying a plain-text rendering of the document. */ + private genericFallbackPdf(html: string): Buffer { + const text = html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/[^\x20-\x7e]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 900); + + const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); + const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40); + const stream = + "BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" + + lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") + + "ET"; + + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = []; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n"; + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts new file mode 100644 index 000000000..d36788600 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts @@ -0,0 +1,44 @@ +/** + * Shared per-day sequential invoice numbering, used by every billing source + * (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the + * `MAX(seq)+1` allocation live in one place instead of being copy-pasted per + * service. + * + * Produces `-YYYYMMDD-00001`: the sequence is the max existing suffix for + * the day + 1. Run inside the caller's transaction (pass that transaction's + * manager) so concurrent generation within a transaction stays consistent. + */ + +/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */ +export interface SqlRunner { + query(sql: string, params?: unknown[]): Promise>; +} + +export interface InvoiceNumberOptions { + /** Schema-qualified table to scan, e.g. `freight.invoices`. */ + table: string; + /** Document code prefix, e.g. `FRT` or `WHF`. */ + code: string; + /** Column holding the number; defaults to `invoice_number`. */ + column?: string; + /** Clock injection point (tests); defaults to now. */ + now?: Date; +} + +export async function nextDailyInvoiceNumber( + runner: SqlRunner, + opts: InvoiceNumberOptions, +): Promise { + const now = opts.now ?? new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const prefix = `${opts.code}-${ymd}-`; + const column = opts.column ?? "invoice_number"; + + const [row] = await runner.query( + `SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq + FROM ${opts.table} WHERE ${column} LIKE $1`, + [`${prefix}%`], + ); + const next = Number(row?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, "0")}`; +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts new file mode 100644 index 000000000..ab1e27b1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -0,0 +1,36 @@ +/** + * Shared payment/settlement math for invoices. Both the global + * `BillingService.recordPayment` and the warehouse fee invoice flow apply a + * payment the same way — accumulate `paidAmount`, derive the outstanding + * `balanceAmount`, and decide whether the invoice is now fully settled. Keeping + * it here means the two flows can never drift on rounding or the + * partial-vs-full threshold. + */ + +/** Round to 2 decimals, avoiding binary float drift. */ +export const round2 = (n: number): number => Math.round(n * 100) / 100; + +export interface SettlementResult { + /** New cumulative amount paid. */ + paidAmount: number; + /** Remaining balance (0 once fully paid). */ + balanceAmount: number; + /** True once the balance reaches zero. */ + fullyPaid: boolean; +} + +/** + * Apply a single payment of `amount` to an invoice with `totalAmount` already + * carrying `currentPaid`. Caller is responsible for validating `amount > 0` and + * the invoice being in a payable state. + */ +export function applySettlement( + totalAmount: number, + currentPaid: number, + amount: number, +): SettlementResult { + const total = Number(totalAmount); + const paidAmount = round2(Number(currentPaid) + Number(amount)); + const balanceAmount = Math.max(0, round2(total - paidAmount)); + return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total }; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 1fe184662..904728251 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,6 +1,12 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { + InvoiceDocumentModel, + InvoiceDocumentService, +} from '../billing/documents/invoice-document.service'; +import { nextDailyInvoiceNumber } from '../billing/invoice-numbering.util'; +import { applySettlement } from '../billing/invoice-settlement.util'; import { NotificationsService } from '../notifications/notifications.service'; import { WarehouseFeeInvoice, @@ -11,7 +17,6 @@ import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity'; import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; import { WarehouseFeeService } from './warehouse-fee.service'; -import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; interface GenerateOptions { confirmZero?: boolean; @@ -56,7 +61,7 @@ export class WarehouseInvoiceService { private readonly invoiceRepository: WarehouseFeeInvoiceRepository, private readonly itemRepository: WarehouseFeeInvoiceItemRepository, private readonly feeService: WarehouseFeeService, - private readonly documents: WarehouseReleaseDocumentService, + private readonly invoiceDocuments: InvoiceDocumentService, private readonly notifications: NotificationsService, ) {} @@ -161,18 +166,12 @@ export class WarehouseInvoiceService { return saved; } - /** WHF-YYYYMMDD-00001 — sequential per day. */ - private async nextInvoiceNumber(): Promise { - const now = new Date(); - const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; - const prefix = `WHF-${ymd}-`; - const [row] = await this.dataSource.query( - `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq - FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`, - [`${prefix}%`], - ); - const next = Number(row?.seq ?? 0) + 1; - return `${prefix}${String(next).padStart(5, '0')}`; + /** WHF-YYYYMMDD-00001 — sequential per day (shared billing numbering). */ + private nextInvoiceNumber(): Promise { + return nextDailyInvoiceNumber(this.dataSource, { + table: 'freight.warehouse_fee_invoices', + code: 'WHF', + }); } // ── Reads ──────────────────────────────────────────────────────────────── @@ -186,12 +185,7 @@ export class WarehouseInvoiceService { async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - const details = await this.getInvoiceDocumentDetails(invoice); - const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details); - return { - filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`, - buffer: await this.documents.htmlToPdfBuffer(html), - }; + return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE')); } async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { @@ -199,11 +193,69 @@ export class WarehouseInvoiceService { if (Number(invoice.paidAmount) <= 0) { throw new BadRequestException('A receipt is available only after payment is recorded.'); } - const details = await this.getInvoiceDocumentDetails(invoice); - const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details); + return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT')); + } + + /** Map a warehouse fee invoice (with display details + items) onto the shared document model. */ + private toDocumentModel( + invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, + kind: 'INVOICE' | 'RECEIPT', + ): InvoiceDocumentModel { + const items = invoice.items as Array<{ + description?: string; + feeType?: string; + quantity?: number; + unitRate?: number; + amount?: number; + currency?: string; + chargeableDays?: number | null; + }>; + const lastPayment = [...(invoice.payments ?? [])].pop(); + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null; + return { - filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`, - buffer: await this.documents.htmlToPdfBuffer(html), + kind, + title: 'Warehouse Fee', + documentNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt ?? invoice.createdAt, + status: invoice.status, + currency: invoice.currency, + summary: [ + { label: 'Status', value: invoice.status.replace(/_/g, ' ') }, + { label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') }, + { label: 'Booking reference', value: invoice.bookingReference ?? null }, + { label: 'Customer', value: invoice.customerName ?? null }, + { label: 'Inventory reference', value: invoice.inventoryReference ?? null }, + { label: 'Inventory info', value: invoice.inventoryInfo ?? null }, + { label: 'Clearance', value: invoice.clearanceStatus ?? null }, + { label: 'Warehouse', value: invoice.warehouseName ?? null }, + { + label: 'Yard / Zone', + value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null, + }, + { label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` }, + { + label: 'Payment', + value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null, + }, + ], + categoryHeader: 'Fee type', + lines: items.map((item) => ({ + description: item.description ?? null, + category: item.feeType ?? null, + quantity: item.quantity ?? item.chargeableDays ?? 0, + unitRate: item.unitRate, + amount: item.amount, + currency: item.currency ?? invoice.currency, + })), + totals: [ + { label: 'Subtotal', amount: Number(invoice.subtotalAmount) }, + { label: 'Tax', amount: Number(invoice.taxAmount) }, + { label: 'Total', amount: Number(invoice.totalAmount), grand: true }, + { label: 'Paid', amount: Number(invoice.paidAmount) }, + { label: 'Balance', amount: Number(invoice.balanceAmount) }, + ], }; } @@ -237,10 +289,11 @@ export class WarehouseInvoiceService { if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.'); if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.'); - const paidAmount = Number(invoice.paidAmount) + dto.amount; - const total = Number(invoice.totalAmount); - const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100); - const fullyPaid = paidAmount >= total; + const { paidAmount, balanceAmount, fullyPaid } = applySettlement( + invoice.totalAmount, + invoice.paidAmount, + dto.amount, + ); const payments = [ ...(invoice.payments ?? []), @@ -248,8 +301,8 @@ export class WarehouseInvoiceService { ]; const updated = await this.invoiceRepository.update(id, { - paidAmount: Math.round(paidAmount * 100) / 100, - balanceAmount: balance, + paidAmount, + balanceAmount, status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID', paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, payments, @@ -460,131 +513,4 @@ export class WarehouseInvoiceService { await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`); } - - private buildInvoiceDocumentHtml( - invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, - kind: 'INVOICE' | 'RECEIPT', - details: InvoiceDocumentDetails, - ): string { - const esc = (value: unknown) => - String(value ?? '-') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - const money = (amount: unknown, currency = invoice.currency) => - `${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; - const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-'); - const items = invoice.items as Array<{ - id?: string; - description?: string; - feeType?: string; - quantity?: number; - unitRate?: number; - amount?: number; - currency?: string; - chargeableDays?: number | null; - }>; - const lastPayment = [...(invoice.payments ?? [])].pop(); - const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR'; - - return ` - - - - Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'} - - - -
-
-
-
Ethio-Djibouti Railway S.C.
-

Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}

-
-
- Document no. - ${esc(invoice.invoiceNumber)} - Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))} -
-
-
${esc(sealText)}
-
-
Status${esc(invoice.status.replace(/_/g, ' '))}
-
Invoice type${esc(invoice.invoiceType.replace(/_/g, ' '))}
-
Booking reference${esc(details.bookingReference)}
-
Customer${esc(details.customerName)}
-
Inventory reference${esc(details.inventoryReference)}
-
Inventory info${esc(details.inventoryInfo)}
-
Clearance${esc(details.clearanceStatus)}
-
Warehouse${esc(details.warehouseName)}
-
Yard / Zone${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}
-
Period${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}
-
Payment${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}
-
- - - - - - - - - - - - ${items - .map( - (item) => ` - - - - - - `, - ) - .join('')} - -
DescriptionFee typeQtyRateAmount
${esc(item.description)}${esc((item.feeType ?? '').replace(/_/g, ' '))}${esc(item.quantity ?? item.chargeableDays ?? 0)}${esc(money(item.unitRate, item.currency ?? invoice.currency))}${esc(money(item.amount, item.currency ?? invoice.currency))}
-
-
Subtotal${esc(money(invoice.subtotalAmount))}
-
Tax${esc(money(invoice.taxAmount))}
-
Total${esc(money(invoice.totalAmount))}
-
Paid${esc(money(invoice.paidAmount))}
-
Balance${esc(money(invoice.balanceAmount))}
-
- -
- -`; - } - - private safeFilename(value: string): string { - return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); - } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index a77a46c29..f8c0dd355 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -1,101 +1,23 @@ -import { existsSync } from 'fs'; +import { Injectable } from '@nestjs/common'; -import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { PdfRenderService } from '../billing/documents/pdf-render.service'; const MIN_VALID_PDF_BYTES = 2_000; -const RELEASE_DOCUMENT_PRINT_STYLES = ` -`; - @Injectable() export class WarehouseReleaseDocumentService { - private readonly logger = new Logger(WarehouseReleaseDocumentService.name); + constructor(private readonly pdf: PdfRenderService) {} - async htmlToPdfBuffer(html: string): Promise { - const preparedHtml = this.injectPdfPrintStyles(html); - const executablePath = this.resolveExecutablePath(); - - try { - const puppeteer = await import('puppeteer'); - const launchOptions: import('puppeteer').LaunchOptions = { - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], - ...(executablePath ? { executablePath } : {}), - }; - - const browser = await puppeteer.default.launch(launchOptions); - try { - const page = await browser.newPage(); - await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); - await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 }); - await page.emulateMediaType('print'); - await new Promise((resolve) => setTimeout(resolve, 250)); - - const pdf = await page.pdf({ - format: 'A4', - printBackground: true, - margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' }, - }); - - const buffer = Buffer.from(pdf); - if (!this.isValidPdf(buffer)) { - throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`); - } - this.logger.log( - `Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, - ); - return buffer; - } finally { - await browser.close(); - } - } catch (error) { - this.logger.error( - `Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`, - ); - const fallback = this.htmlToBasicPdfBuffer(preparedHtml); - if (this.isValidPdf(fallback)) { - this.logger.warn( - `Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, - ); - return fallback; - } - throw new InternalServerErrorException( - 'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', - ); - } - } - - private injectPdfPrintStyles(html: string): string { - if (html.includes('warehouse-release-document-print-fix')) return html; - if (html.includes('')) { - return html.replace('', `${RELEASE_DOCUMENT_PRINT_STYLES}`); - } - return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`; - } - - private resolveExecutablePath(): string | undefined { - const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); - if (fromEnv && existsSync(fromEnv)) return fromEnv; - - const candidates = [ - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/usr/bin/google-chrome-stable', - '/usr/bin/google-chrome', - ]; - return candidates.find((path) => existsSync(path)); - } - - private isValidPdf(buffer: Buffer): boolean { - return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-'; + /** + * Render the gate-clearance release document to PDF via the shared renderer, + * falling back to the release-specific hand-built layout when Chromium is + * unavailable. + */ + htmlToPdfBuffer(html: string): Promise { + return this.pdf.htmlToPdfBuffer(html, { + label: 'Warehouse release', + fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml), + }); } private htmlToBasicPdfBuffer(html: string): Buffer { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index d880a3554..a7ce68319 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { DocumentsModule } from '../billing/documents/documents.module'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; @@ -70,6 +71,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseFeeInvoice, WarehouseFeeInvoiceItem, ]), + DocumentsModule, FilesModule, InterchangeDocumentsModule, forwardRef(() => LastMileModule), From 5d70c3b5577a088960b60098d374c5f96723214e Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:13:48 +0000 Subject: [PATCH 018/401] fix --- .../src/modules/fuel/entities/fuel-consumption.entity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts index 002eca861..aabafd17c 100644 --- a/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts @@ -21,8 +21,8 @@ export class FuelConsumption extends BaseEntity { @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) totalCost!: number; - @Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2 }) - totalDistanceKm!: number; + @Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2, default: 0 }) + totalDistanceKm: number = 0; @Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true }) fuelEfficiencyKmPerL?: number; From e59a77b859bdf8b281545ff07805590ac4fe1db3 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:17:17 +0000 Subject: [PATCH 019/401] feat: add maintenance tracking module foundation Entities: - MaintenanceSchedule: track preventive/corrective maintenance - MaintenanceCost: record actual maintenance expenses DTOs: - CreateMaintenanceScheduleDto: schedule maintenance - CreateMaintenanceCostDto: log costs - UpdateMaintenanceScheduleDto: mark complete/adjust cost Repository: - getUpcomingMaintenance(): find due maintenance - getMaintenanceCosts(): historical costs by date - getTotalMaintenanceCost(): aggregate spending Also fixed fuel-consumption.entity.ts: totalDistanceKm default 0 Co-Authored-By: Claude Haiku 4.5 --- .../maintenance/dto/create-maintenance.dto.ts | 87 +++++++++++++++++++ .../entities/maintenance-cost.entity.ts | 43 +++++++++ .../entities/maintenance-schedule.entity.ts | 65 ++++++++++++++ .../maintenance/maintenance.repository.ts | 50 +++++++++++ 4 files changed, 245 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts new file mode 100644 index 000000000..d3e70acff --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts @@ -0,0 +1,87 @@ +import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator'; +import { MaintenanceType, MaintenanceStatus } from '../entities/maintenance-schedule.entity'; + +export class CreateMaintenanceScheduleDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(MaintenanceType) + maintenanceType!: MaintenanceType; + + @IsString() + description!: string; + + @IsDateString() + scheduledDate!: string; + + @IsOptional() + @IsNumber() + estimatedCost?: number; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsNumber() + nextDueKm?: number; + + @IsOptional() + @IsDateString() + nextDueDate?: string; +} + +export class CreateMaintenanceCostDto { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsUUID() + maintenanceScheduleId?: string; + + @IsDateString() + incurredDate!: string; + + @IsNumber() + costAmount!: number; + + @IsString() + costType!: string; + + @IsString() + description!: string; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + invoiceNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateMaintenanceScheduleDto { + @IsOptional() + @IsEnum(MaintenanceStatus) + status?: MaintenanceStatus; + + @IsOptional() + @IsDateString() + completedDate?: string; + + @IsOptional() + @IsNumber() + actualCost?: number; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts new file mode 100644 index 000000000..5afbaa80f --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { MaintenanceSchedule } from './maintenance-schedule.entity'; + +@Entity({ name: 'maintenance_costs', schema: 'freight' }) +@Index(['vehicleId', 'incurredDate']) +export class MaintenanceCost extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_schedule_id', type: 'uuid', nullable: true }) + maintenanceScheduleId?: string; + + @ManyToOne(() => MaintenanceSchedule, { eager: false, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'maintenance_schedule_id' }) + maintenanceSchedule?: MaintenanceSchedule; + + @Column({ name: 'incurred_date', type: 'timestamptz' }) + incurredDate!: Date; + + @Column({ name: 'cost_amount', type: 'numeric', precision: 14, scale: 2 }) + costAmount!: number; + + @Column({ name: 'cost_type' }) + costType!: string; // 'PARTS', 'LABOR', 'DIAGNOSTICS', 'OTHER' + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'invoice_number', nullable: true }) + invoiceNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts new file mode 100644 index 000000000..a4d4d60a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts @@ -0,0 +1,65 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum MaintenanceType { + PREVENTIVE = 'PREVENTIVE', + CORRECTIVE = 'CORRECTIVE', + INSPECTION = 'INSPECTION', + REPAIR = 'REPAIR', +} + +export enum MaintenanceStatus { + SCHEDULED = 'SCHEDULED', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', + OVERDUE = 'OVERDUE', +} + +@Entity({ name: 'maintenance_schedules', schema: 'freight' }) +@Index(['vehicleId', 'scheduledDate']) +export class MaintenanceSchedule extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_type', type: 'varchar' }) + maintenanceType!: MaintenanceType; + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'scheduled_date', type: 'timestamptz' }) + scheduledDate!: Date; + + @Column({ name: 'completed_date', type: 'timestamptz', nullable: true }) + completedDate?: Date; + + @Column({ name: 'estimated_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + estimatedCost?: number; + + @Column({ name: 'actual_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + actualCost?: number; + + @Column({ name: 'status', type: 'varchar', default: MaintenanceStatus.SCHEDULED }) + status!: MaintenanceStatus; + + @Column({ name: 'odometer_reading', type: 'numeric', nullable: true }) + odometerReading?: number; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'next_due_km', type: 'numeric', nullable: true }) + nextDueKm?: number; + + @Column({ name: 'next_due_date', type: 'timestamptz', nullable: true }) + nextDueDate?: Date; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts new file mode 100644 index 000000000..9e8cf972e --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, Between } from 'typeorm'; +import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; + +@Injectable() +export class MaintenanceRepository extends BaseRepository { + constructor( + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) { + super(scheduleRepository); + } + + async getUpcomingMaintenance(vehicleId: string, daysAhead: number = 30) { + const futureDate = new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000); + return this.scheduleRepository.find({ + where: { + vehicleId, + scheduledDate: Between(new Date(), futureDate), + status: MaintenanceStatus.SCHEDULED, + }, + order: { scheduledDate: 'ASC' }, + }); + } + + async getMaintenanceCosts(vehicleId: string, startDate: Date, endDate: Date) { + return this.costRepository.find({ + where: { + vehicleId, + incurredDate: Between(startDate, endDate), + }, + order: { incurredDate: 'DESC' }, + }); + } + + async getTotalMaintenanceCost(vehicleId: string, startDate: Date, endDate: Date) { + const result = await this.costRepository + .createQueryBuilder() + .select('SUM(cost_amount)', 'total') + .where('vehicle_id = :vehicleId', { vehicleId }) + .andWhere('incurred_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + return result?.total || 0; + } +} From f436916c42499e29c429b3130d2eec79c6ba9671 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:32:08 +0000 Subject: [PATCH 020/401] feat: complete maintenance tracking backend Service/Controller/Module: - scheduleMaintenanceAsync: schedule work - recordMaintenanceCost: log expenses - getUpcomingMaintenance: due items - getVehicleMaintenanceStats: cost aggregation Endpoints: - POST /maintenance/schedules - POST /maintenance/costs - PATCH /maintenance/schedules/:id - GET /maintenance/upcoming/:vehicleId - GET /maintenance/history/:vehicleId - GET /maintenance/stats/:vehicleId Migration: idempotent maintenance tables creation Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-api/src/app.module.ts | 2 + .../1850000000000-CreateMaintenanceTables.ts | 82 +++++++++++++++++++ .../maintenance/maintenance.controller.ts | 46 +++++++++++ .../modules/maintenance/maintenance.module.ts | 15 ++++ .../maintenance/maintenance.service.ts | 82 +++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index cbc8ce22a..f40e4f40f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -70,6 +70,7 @@ import { OverviewModule } from './modules/overview/overview.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { DriversModule } from './modules/drivers/drivers.module'; import { FuelModule } from './modules/fuel/fuel.module'; +import { MaintenanceModule } from './modules/maintenance/maintenance.module'; import { FirstMileModule } from './modules/first-mile/first-mile.module'; import { LastMileModule } from './modules/last-mile/last-mile.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; @@ -135,6 +136,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera VehiclesModule, DriversModule, FuelModule, + MaintenanceModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, diff --git a/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts new file mode 100644 index 000000000..26d4afe21 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateMaintenanceTables1850000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create maintenance_schedules table + const scheduleTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules' + ) + `); + + if (!scheduleTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_schedules" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_type" varchar NOT NULL, + "description" varchar NOT NULL, + "scheduled_date" timestamptz NOT NULL, + "completed_date" timestamptz, + "estimated_cost" numeric(14,2), + "actual_cost" numeric(14,2), + "status" varchar NOT NULL DEFAULT 'SCHEDULED', + "odometer_reading" numeric, + "service_provider" varchar, + "notes" text, + "next_due_km" numeric, + "next_due_date" timestamptz, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id") + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")` + ); + } + + // Create maintenance_costs table + const costsTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_costs' + ) + `); + + if (!costsTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_costs" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_schedule_id" uuid, + "incurred_date" timestamptz NOT NULL, + "cost_amount" numeric(14,2) NOT NULL, + "cost_type" varchar NOT NULL, + "description" varchar NOT NULL, + "service_provider" varchar, + "invoice_number" varchar, + "notes" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id"), + CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id") + REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")` + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`); + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts new file mode 100644 index 000000000..6f29089d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { MaintenanceService } from './maintenance.service'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; + +@ApiTags('Maintenance Management') +@Controller('maintenance') +export class MaintenanceController { + constructor(private readonly maintenanceService: MaintenanceService) {} + + @Post('schedules') + @ApiOperation({ summary: 'Schedule maintenance' }) + async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) { + return this.maintenanceService.scheduleMaintenanceAsync(dto); + } + + @Post('costs') + @ApiOperation({ summary: 'Record maintenance cost' }) + async recordCost(@Body() dto: CreateMaintenanceCostDto) { + return this.maintenanceService.recordMaintenanceCost(dto); + } + + @Patch('schedules/:id') + @ApiOperation({ summary: 'Update maintenance schedule' }) + async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) { + return this.maintenanceService.updateMaintenanceSchedule(id, dto); + } + + @Get('upcoming/:vehicleId') + @ApiOperation({ summary: 'Get upcoming maintenance' }) + async getUpcoming(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getUpcomingMaintenance(vehicleId); + } + + @Get('history/:vehicleId') + @ApiOperation({ summary: 'Get maintenance history' }) + async getHistory(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getMaintenanceHistory(vehicleId); + } + + @Get('stats/:vehicleId') + @ApiOperation({ summary: 'Get maintenance statistics' }) + async getStats(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getVehicleMaintenanceStats(vehicleId); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts new file mode 100644 index 000000000..a0227a733 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceController } from './maintenance.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])], + providers: [MaintenanceService, MaintenanceRepository], + controllers: [MaintenanceController], + exports: [MaintenanceService], +}) +export class MaintenanceModule {} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts new file mode 100644 index 000000000..4cbe6886c --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; + +@Injectable() +export class MaintenanceService { + constructor( + private readonly maintenanceRepository: MaintenanceRepository, + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) {} + + async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise { + const schedule = this.scheduleRepository.create({ + ...dto, + scheduledDate: new Date(dto.scheduledDate), + nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined, + }); + return this.scheduleRepository.save(schedule); + } + + async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise { + const cost = this.costRepository.create({ + ...dto, + incurredDate: new Date(dto.incurredDate), + }); + return this.costRepository.save(cost); + } + + async updateMaintenanceSchedule( + id: string, + dto: UpdateMaintenanceScheduleDto, + ): Promise { + await this.scheduleRepository.update(id, { + ...dto, + completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined, + }); + const updated = await this.scheduleRepository.findOneBy({ id }); + return updated!; + } + + async getUpcomingMaintenance(vehicleId: string) { + return this.maintenanceRepository.getUpcomingMaintenance(vehicleId); + } + + async getMaintenanceHistory(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + return this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + } + + async getVehicleMaintenanceStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const costs = await this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); + + return { + vehicleId, + totalCost, + numberOfMaintenanceItems: costs.length, + averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, + costByType: this.groupCostsByType(costs), + }; + } + + private groupCostsByType(costs: MaintenanceCost[]) { + const grouped: Record = {}; + costs.forEach((c) => { + if (!grouped[c.costType]) grouped[c.costType] = 0; + grouped[c.costType] += Number(c.costAmount); + }); + return grouped; + } +} From 373c0356f2e82143d587135f8c538e6eab0fa13c Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:41:23 +0000 Subject: [PATCH 021/401] feat: maintenance + financial reports frontend MaintenancePage: - Schedule maintenance (PREVENTIVE/CORRECTIVE/INSPECTION/REPAIR) - View upcoming by vehicle - Modal form with date, cost, provider, notes FinancialReportsPage: - Aggregate fuel + maintenance costs - Period selector (3/6/12 months) - Cost breakdown (percentages, ring progress) - Operating insights (purchases, efficiency, items, avg cost) - Cost per month calculation Routes: - /dashboard/maintenance - /dashboard/financial-reports Sidebar: - "Maintenance" in Fleet Management - "Financial Reports" in Fleet Management QUERY_KEYS: - FUEL, MAINTENANCE, FINANCIAL_REPORTS cache patterns Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-web/backoffice/src/App.tsx | 30 +++ .../backoffice/src/constants/QUERY_KEYS.ts | 20 ++ .../src/pages/fleet/FinancialReportsPage.tsx | 245 ++++++++++++++++++ .../src/pages/fleet/MaintenancePage.tsx | 197 ++++++++++++++ 4 files changed, 492 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8d5b568dc..513dd1300 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -59,6 +59,8 @@ import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import RoutesPage from "./pages/fleet/RoutesPage"; import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; import FuelStatsPage from "./pages/fleet/FuelStatsPage"; +import { MaintenancePage } from "./pages/fleet/MaintenancePage"; +import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -214,6 +216,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, // { // label: "Containers", // href: "/dashboard/containers", @@ -755,6 +769,22 @@ const App = () => { } /> + + + + } + /> + + + + } + /> ["overview", "customers", range ?? "30d"] as const, staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const, }, + + FUEL: { + ROOT: ["fuel"] as const, + purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const, + stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const, + }, + + MAINTENANCE: { + ROOT: ["maintenance"] as const, + schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const, + upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const, + history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const, + stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const, + }, + + FINANCIAL_REPORTS: { + ROOT: ["financial-reports"] as const, + fleet: (vehicleId?: string, months?: number) => + ["financial-reports", "fleet", vehicleId ?? "all", months ?? 12] as const, + }, } as const; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx new file mode 100644 index 000000000..31d7a7a23 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -0,0 +1,245 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress } from '@mantine/core'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; +import { api } from '@/services/api'; +import { vehiclesService } from '@/services/vehicles.service'; + +interface FuelStats { + vehicleId: string; + totalPurchases: number; + totalFuel: number; + totalCost: number; + averageCostPerLiter: number; +} + +interface MaintenanceStats { + vehicleId: string; + totalCost: number; + numberOfMaintenanceItems: number; + averageCostPerMaintenance: number; + costByType: Record; +} + +interface CombinedReport { + vehicleId: string; + fuelCost: number; + maintenanceCost: number; + totalOperatingCost: number; + fuelPercentage: number; + maintenancePercentage: number; +} + +export function FinancialReportsPage() { + const [selectedVehicle, setSelectedVehicle] = useState(null); + const [months, setMonths] = useState('12'); + + const { data: vehicles } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: () => vehiclesService.getAll({ limit: 1000 }), + }); + + const { data: fuelStats } = useQuery({ + queryKey: QUERY_KEYS.FUEL.stats(selectedVehicle || ''), + queryFn: () => selectedVehicle ? api.get(`/fuel/stats/${selectedVehicle}?months=${months}`) : Promise.resolve(null), + enabled: !!selectedVehicle, + }); + + const { data: maintenanceStats } = useQuery({ + queryKey: QUERY_KEYS.MAINTENANCE.stats(selectedVehicle || ''), + queryFn: () => selectedVehicle ? api.get(`/maintenance/stats/${selectedVehicle}`) : Promise.resolve(null), + enabled: !!selectedVehicle, + }); + + const vehicleOptions = useMemo( + () => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [], + [vehicles] + ); + + const report = useMemo(() => { + if (!fuelStats || !maintenanceStats) return null; + + const fuelCost = Number(fuelStats.totalCost) || 0; + const maintenanceCost = Number(maintenanceStats.totalCost) || 0; + const total = fuelCost + maintenanceCost; + + return { + vehicleId: selectedVehicle!, + fuelCost, + maintenanceCost, + totalOperatingCost: total, + fuelPercentage: total > 0 ? Math.round((fuelCost / total) * 100) : 0, + maintenancePercentage: total > 0 ? Math.round((maintenanceCost / total) * 100) : 0, + }; + }, [fuelStats, maintenanceStats, selectedVehicle]); + + const StatCard = ({ label, value }: { label: string; value: string }) => ( + + + + {label} + + + {value} + + + + ); + + return ( + + + + Fleet Financial Analysis + + + + setMonths(v || '12')} + style={{ flex: 1 }} + /> + + + + + {report && ( + <> + + + + + + + + + + + + + + + Monthly Avg + + + ${(report.totalOperatingCost / parseInt(months)).toFixed(2)} + + + + + + + + + + + Cost Breakdown + + + + + + + Fuel + + {report.fuelPercentage}% + + + {report.fuelPercentage}% + + } + size={100} + thickness={4} + /> + + + + + Maintenance + + {report.maintenancePercentage}% + + + {report.maintenancePercentage}% + + } + size={100} + thickness={4} + /> + + + + + + + + + + Operational Insights + + + +
+ + Fuel Purchases + + {fuelStats?.totalPurchases || 0} transactions +
+
+ + Fuel Efficiency + + + {fuelStats?.fuelEfficiency?.toFixed(2) || 'N/A'} km/L + +
+
+ + Maintenance Items + + {maintenanceStats?.numberOfMaintenanceItems || 0} records +
+
+ + Avg Maintenance Cost + + ${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'} +
+
+
+
+
+
+ + )} + + {!selectedVehicle && ( + + + Select a vehicle to view financial reports + + + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx new file mode 100644 index 000000000..162ee5f21 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -0,0 +1,197 @@ +import { useState, useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text } from '@mantine/core'; +import { DateInput } from '@mantine/dates'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; +import { api } from '@/services/api'; +import { vehiclesService } from '@/services/vehicles.service'; + +interface MaintenanceSchedule { + id: string; + vehicleId: string; + maintenanceType: string; + description: string; + scheduledDate: string; + completedDate?: string; + status: string; + estimatedCost?: number; + actualCost?: number; + serviceProvider?: string; +} + +export function MaintenancePage() { + const [selectedVehicle, setSelectedVehicle] = useState(null); + const [openScheduleModal, setOpenScheduleModal] = useState(false); + const [formData, setFormData] = useState({ + maintenanceType: 'PREVENTIVE', + description: '', + scheduledDate: new Date(), + estimatedCost: 0, + serviceProvider: '', + notes: '', + }); + + const queryClient = useQueryClient(); + + const { data: vehicles } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: () => vehiclesService.getAll({ limit: 1000 }), + }); + + const { data: upcoming, isLoading } = useQuery({ + queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), + queryFn: () => selectedVehicle ? api.get(`/maintenance/upcoming/${selectedVehicle}`) : Promise.resolve([]), + enabled: !!selectedVehicle, + }); + + const scheduleMutation = useMutation({ + mutationFn: async () => { + if (!selectedVehicle) return; + return api.post('/maintenance/schedules', { + vehicleId: selectedVehicle, + ...formData, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || '') }); + setOpenScheduleModal(false); + setFormData({ + maintenanceType: 'PREVENTIVE', + description: '', + scheduledDate: new Date(), + estimatedCost: 0, + serviceProvider: '', + notes: '', + }); + }, + }); + + const vehicleOptions = useMemo( + () => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [], + [vehicles] + ); + + const statusColor = (status: string) => { + const colors: Record = { + SCHEDULED: 'blue', + IN_PROGRESS: 'yellow', + COMPLETED: 'green', + OVERDUE: 'red', + }; + return colors[status] || 'gray'; + }; + + return ( + + + + + Schedule Maintenance + + + + + setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })} + /> + setFormData({ ...formData, description: e.currentTarget.value })} + /> + setFormData({ ...formData, scheduledDate: d || new Date() })} + /> + setFormData({ ...formData, estimatedCost: Number(v) })} + /> + setFormData({ ...formData, serviceProvider: e.currentTarget.value })} + /> + setFormData({ ...formData, notes: e.currentTarget.value })} + /> + + + + + + + + ); +} From a667f5b2df910706de6558b7bf96c522720fb495 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 30 Jun 2026 15:51:47 +0300 Subject: [PATCH 022/401] add test payment event --- .../payments/payment-events.consumer.ts | 5 + .../outbox/dto/test-payment-event.dto.ts | 92 +++++++++++++++++++ .../src/modules/outbox/outbox.module.ts | 7 ++ .../modules/outbox/test-events.controller.ts | 88 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/test-events.controller.ts diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index 291402240..f13191c48 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -29,6 +29,11 @@ export class PaymentEventsConsumer { }, }) async handle(event: PaymentEvent): Promise { + // Logged the instant RabbitMQ delivers the message, before any DB work — proves the + // payment -> passenger broker connection works even if processing later fails/hangs. + this.logger.log( + `RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`, + ); try { const result = await this.paymentsService.handlePaymentEvent( event as unknown as PaymentEventDto, diff --git a/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts b/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts new file mode 100644 index 000000000..700d64717 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts @@ -0,0 +1,92 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsEnum, + IsIn, + IsInt, + IsOptional, + IsPositive, + IsString, +} from "class-validator"; +import { + PaymentEventType, + PaymentReferenceType, + PaymentService, + ProviderMethod, +} from "@edr/types"; + +/** + * Body for the dev-only POST /test/payment-event endpoint. Every field is optional — the + * controller fills sensible defaults so an empty `{}` publishes a `payment.succeeded` to the + * passenger queue. Set `referenceId` to a real bookingId to exercise the consumer's side effects + * (seat confirm / ticket issue); leave it blank to only prove RabbitMQ delivery. + */ +export class TestPaymentEventDto { + @ApiPropertyOptional({ + enum: ["payment.succeeded", "payment.failed"], + default: "payment.succeeded", + }) + @IsOptional() + @IsIn(["payment.succeeded", "payment.failed"]) + eventType?: PaymentEventType; + + @ApiPropertyOptional({ enum: PaymentService, default: PaymentService.PASSENGER }) + @IsOptional() + @IsEnum(PaymentService) + service?: PaymentService; + + @ApiPropertyOptional({ + enum: PaymentReferenceType, + default: PaymentReferenceType.BOOKING, + }) + @IsOptional() + @IsEnum(PaymentReferenceType) + referenceType?: PaymentReferenceType; + + @ApiPropertyOptional({ + description: "Domain order id (e.g. bookingId). Defaults to a random uuid.", + }) + @IsOptional() + @IsString() + referenceId?: string; + + @ApiPropertyOptional({ description: "Defaults to a random uuid." }) + @IsOptional() + @IsString() + intentId?: string; + + @ApiPropertyOptional({ description: "Defaults to test-." }) + @IsOptional() + @IsString() + merchantOrderId?: string; + + @ApiPropertyOptional({ enum: ProviderMethod, default: ProviderMethod.WAAFI }) + @IsOptional() + @IsEnum(ProviderMethod) + provider?: ProviderMethod; + + @ApiPropertyOptional({ default: 10000, description: "Amount in minor units." }) + @IsOptional() + @IsInt() + @IsPositive() + amountMinor?: number; + + @ApiPropertyOptional({ default: "ETB" }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: "Only used for payment.succeeded." }) + @IsOptional() + @IsString() + providerTxnId?: string; + + @ApiPropertyOptional({ description: "Only used for payment.failed." }) + @IsOptional() + @IsString() + failureCode?: string; + + @ApiPropertyOptional({ description: "Only used for payment.failed." }) + @IsOptional() + @IsString() + failureMessage?: string; +} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts index eae5515e8..5d8d0cc8a 100644 --- a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts +++ b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts @@ -11,6 +11,12 @@ import { OutboxRepository } from "./outbox.repository"; import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher"; import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher"; import { RabbitMqPaymentEventPublisher } from "./publisher/rabbitmq-payment-event-publisher"; +import { TestEventsController } from "./test-events.controller"; + +// Dev-only harness to publish a synthetic payment event straight to the broker. +// Never registered in production, so the endpoint cannot exist there. +const testControllers = + process.env.NODE_ENV !== "production" ? [TestEventsController] : []; const rabbitImports = isRabbitPublisher() ? [ @@ -42,6 +48,7 @@ const rabbitImports = isRabbitPublisher() HttpModule, ...rabbitImports, ], + controllers: testControllers, providers: [ OutboxRepository, OutboxRelayService, diff --git a/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts b/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts new file mode 100644 index 000000000..d4396a754 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; +import { Body, Controller, Inject, Logger, Post } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { + PaymentEvent, + PaymentReferenceType, + PaymentService, + ProviderMethod, + paymentRoutingKey, +} from "@edr/types"; +import { + PAYMENT_EVENT_PUBLISHER, + PaymentEventPublisher, +} from "./publisher/payment-event-publisher"; +import { TestPaymentEventDto } from "./dto/test-payment-event.dto"; + +/** + * DEV-ONLY test harness. Publishes a synthetic payment event through the real + * PaymentEventPublisher (RabbitMQ in dev), so the passenger/freight consumer receives it + * exactly as in production — without creating an intent or going through a booking + provider + * flow. Registered only when NODE_ENV !== "production" (see OutboxModule); never reachable in prod. + * + * Quick check (no body): POST /test/payment-event -> publishes payment.passenger.succeeded. + * Real side effects: pass a real bookingId as `referenceId`. + */ +@ApiTags("Dev test (non-production)") +@Controller("test") +export class TestEventsController { + private readonly logger = new Logger(TestEventsController.name); + + constructor( + @Inject(PAYMENT_EVENT_PUBLISHER) + private readonly publisher: PaymentEventPublisher, + ) {} + + @Post("payment-event") + @ApiOperation({ + summary: + "DEV ONLY: publish a synthetic payment event to the broker (passenger/freight consumes it)", + description: + "Bypasses intents/booking. Empty body publishes a payment.succeeded for PASSENGER. " + + "Set referenceId to a real bookingId to trigger the consumer's seat/ticket side effects.", + }) + async publishTestEvent( + @Body() dto: TestPaymentEventDto, + ): Promise<{ published: true; routingKey: string; event: PaymentEvent }> { + const eventType = dto.eventType ?? "payment.succeeded"; + const service = dto.service ?? PaymentService.PASSENGER; + const now = new Date().toISOString(); + + const base = { + version: 1 as const, + eventId: randomUUID(), + occurredAt: now, + service, + intentId: dto.intentId ?? randomUUID(), + referenceType: dto.referenceType ?? PaymentReferenceType.BOOKING, + referenceId: dto.referenceId ?? randomUUID(), + merchantOrderId: dto.merchantOrderId ?? `test-${randomUUID().slice(0, 8)}`, + provider: dto.provider ?? ProviderMethod.WAAFI, + amountMinor: dto.amountMinor ?? 10_000, + currency: dto.currency ?? "ETB", + }; + + const event: PaymentEvent = + eventType === "payment.failed" + ? { + ...base, + eventType: "payment.failed", + failureCode: dto.failureCode ?? "TEST_DECLINED", + failureMessage: dto.failureMessage ?? "Synthetic test failure", + } + : { + ...base, + eventType: "payment.succeeded", + providerTxnId: dto.providerTxnId ?? `TEST-${randomUUID().slice(0, 8)}`, + paidAt: now, + }; + + await this.publisher.publish(event); + + const routingKey = paymentRoutingKey(event.service, event.eventType); + this.logger.log( + `published TEST ${event.eventType} (${event.eventId}) ref=${event.referenceId} -> ${routingKey}`, + ); + return { published: true, routingKey, event }; + } +} From fa3138f2aca8fd6a906ced36b0e4d06b8e065473 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 12:54:04 +0000 Subject: [PATCH 023/401] refactor: migrate the warehouse invoice to use the central one --- apps/edr-freight-api/package.json | 2 +- ...29000000000-CentralizeWarehouseInvoices.ts | 222 +++++++ .../src/modules/billing/billing.service.ts | 29 +- .../warehouse-fee-invoice-item.entity.ts | 50 -- .../entities/warehouse-fee-invoice.entity.ts | 107 ---- .../warehouse-fee-invoice-item.repository.ts | 13 - .../warehouse-fee-invoice.repository.ts | 13 - .../warehouses/warehouse-invoice.service.ts | 600 ++++++++++++------ .../warehouses/warehouse-invoice.types.ts | 88 +++ .../modules/warehouses/warehouses.module.ts | 10 +- apps/edr-freight-api/tsconfig.json | 1 + 11 files changed, 744 insertions(+), 391 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 71e2fd60a..9edf388b9 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -6,7 +6,7 @@ "scripts": { "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", "predev": "pnpm run clean", - "dev": "nest start --watch", + "dev": "nest start --watch --clearScreen false", "prebuild": "pnpm run clean", "build": "nest build", "start": "node dist/main.js", diff --git a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts new file mode 100644 index 000000000..dd246cb7d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts @@ -0,0 +1,222 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Fold warehouse fee invoices into the central billing system. + * + * Warehouse fee invoices are no longer a standalone aggregate: each becomes a + * global `freight.invoices` row (`source = 'warehouse'`, `source_id = + * inventory_id`) with its items as `freight.invoice_lines`. The warehouse + * service is now a thin layer over `BillingService`. This migration backfills the + * existing rows (preserving ids, numbers, status, amounts and payment history), + * then drops the two legacy tables. + * + * Rows that cannot be billed centrally — no company to bill (`company_id` / + * `company_profile_id` underivable from the customer or the booking) — are not + * migrated; they could never have been charged through the gateway and are + * dropped with the table. + */ +export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface { + name = 'CentralizeWarehouseInvoices1829000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 1. Invoice headers. Keep the same id so items still link, and so any + // external reference to the invoice id stays valid. + await queryRunner.query(` + INSERT INTO freight.invoices ( + id, invoice_number, company_id, company_profile_id, + subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, status, source, source_id, type, + issued_at, paid_at, payments, payment_id, due_at, + created_at, updated_at, deleted_at + ) + SELECT + fee.id, + fee.invoice_number, + COALESCE(fee.customer_id, b.company_id), + COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ), + fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount, + fee.currency, + fee.status::freight.invoices_status_enum, + 'warehouse', + fee.inventory_id, + fee.invoice_type, + fee.issued_at, + fee.paid_at, + COALESCE(fee.payments, '[]'::jsonb), + NULL, + COALESCE(fee.due_date, fee.issued_at, fee.created_at), + fee.created_at, fee.updated_at, fee.deleted_at + FROM freight.warehouse_fee_invoices fee + LEFT JOIN freight.bookings b ON b.id = fee.booking_id + WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL + AND COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ) IS NOT NULL + ON CONFLICT (id) DO NOTHING; + `); + + // 2. Invoice lines — only for items whose parent invoice migrated. Warehouse + // fee fields (fee_rule_id / chargeable_days / free_days) move into the + // line's jsonb metadata. + await queryRunner.query(` + INSERT INTO freight.invoice_lines ( + id, invoice_id, charge_type, description, quantity, unit_rate, amount, + currency, metadata, created_at, updated_at, deleted_at + ) + SELECT + item.id, + item.invoice_id, + item.fee_type, + item.description, + item.quantity, + item.unit_rate, + item.amount, + item.currency, + jsonb_build_object( + 'feeRuleId', item.fee_rule_id, + 'chargeableDays', item.chargeable_days, + 'freeDays', item.free_days + ), + item.created_at, item.updated_at, item.deleted_at + FROM freight.warehouse_fee_invoice_items item + JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // 3. Drop the legacy tables (items first — FK to invoices). + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Recreate the legacy tables … + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_number varchar(40) NOT NULL, + booking_id uuid, + customer_id uuid, + inventory_id uuid NOT NULL, + facility_id uuid, + warehouse_id uuid, + yard_id uuid, + zone_id uuid, + invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES', + status varchar(20) NOT NULL DEFAULT 'DRAFT', + subtotal_amount numeric(14,2) NOT NULL DEFAULT 0, + tax_amount numeric(14,2) NOT NULL DEFAULT 0, + total_amount numeric(14,2) NOT NULL DEFAULT 0, + paid_amount numeric(14,2) NOT NULL DEFAULT 0, + balance_amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + period_start timestamptz, + period_end timestamptz, + issued_at timestamptz, + due_date timestamptz, + paid_at timestamptz, + cancelled_at timestamptz, + payments jsonb NOT NULL DEFAULT '[]', + notes text, + CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id), + CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number) + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`, + ); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_id uuid NOT NULL, + fee_rule_id uuid, + fee_type varchar(32) NOT NULL, + description varchar(255) NOT NULL, + quantity numeric(12,2) NOT NULL DEFAULT 1, + unit_rate numeric(14,2) NOT NULL DEFAULT 0, + amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + chargeable_days int, + free_days int, + CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id), + CONSTRAINT "FK_warehouse_fee_invoice_items_invoice" + FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`, + ); + + // … then copy the warehouse-source invoices back, deriving the typed FKs and + // period from the linked inventory item. + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoices ( + id, created_at, updated_at, deleted_at, invoice_number, + booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id, + invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes + ) + SELECT + i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number, + inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id, + i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount, + i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at, + CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END, + i.payments, NULL + FROM freight.invoices i + LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoice_items ( + id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type, + description, quantity, unit_rate, amount, currency, chargeable_days, free_days + ) + SELECT + l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id, + NULLIF(l.metadata->>'feeRuleId', '')::uuid, + l.charge_type, + COALESCE(l.description, ''), + l.quantity, l.unit_rate, l.amount, l.currency, + NULLIF(l.metadata->>'chargeableDays', '')::int, + NULLIF(l.metadata->>'freeDays', '')::int + FROM freight.invoice_lines l + JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // Remove the migrated rows from the central tables. + await queryRunner.query(` + DELETE FROM freight.invoice_lines + WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse'); + `); + await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`); + } +} 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 edacc2c79..e4389e7cf 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,3 +1,4 @@ +import { Freight, PaymentReferenceType } from "@edr/types"; import { BadRequestException, forwardRef, @@ -7,22 +8,21 @@ import { NotFoundException, } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; -import { Freight, PaymentReferenceType } from "@edr/types"; import { DataSource, EntityManager, In } from "typeorm"; -import { Invoice, InvoicePayment } from "./entities/invoice.entity"; -import { InvoiceLine } from "./entities/invoice-line.entity"; -import { InvoiceRepository } from "./invoice.repository"; -import { InvoiceLineRepository } from "./invoice-line.repository"; -import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; -import { applySettlement, round2 } from "./invoice-settlement.util"; +import { CompaniesService } from "../companies/companies.service"; +import { PaymentService } from "../payment/payment.service"; +import { InitiateResponseDto } from "../payment/payments.dto"; import { InvoiceDocumentModel, InvoiceDocumentService, } from "./documents/invoice-document.service"; -import { PaymentService } from "../payment/payment.service"; -import { InitiateResponseDto } from "../payment/payments.dto"; -import { CompaniesService } from "../companies/companies.service"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { Invoice, InvoicePayment } from "./entities/invoice.entity"; +import { InvoiceLineRepository } from "./invoice-line.repository"; +import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; +import { applySettlement, round2 } from "./invoice-settlement.util"; +import { InvoiceRepository } from "./invoice.repository"; /** Options forwarded to the payment gateway when settling an invoice. */ export interface PayInvoiceOptions { @@ -96,6 +96,11 @@ export interface GenerateInvoiceInput { * (default PENDING) stamps `issuedAt`. */ status?: Freight.InvoiceStatus; + /** + * Document number prefix for this source (e.g. `WHF` for warehouse fees); + * defaults to `FRT`. The daily sequence is allocated per prefix. + */ + numberCode?: string; } /** Payload broadcast on `${source}.invoice.`. */ @@ -269,9 +274,9 @@ export class BillingService { // ── Generation ─────────────────────────────────────────────────────────────── - /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */ + /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ private nextInvoiceNumber(mg: EntityManager): Promise { - return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code: "FRT" }); + return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" }); } /** diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts deleted file mode 100644 index 8b14dcea3..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; - -import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity'; - -export const WAREHOUSE_FEE_TYPES = [ - 'CONTAINER_DEMURRAGE', - 'BULK_DEMURRAGE', - 'STORAGE_FEE', - 'HANDLING_FEE', -] as const; -export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; - -@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' }) -@Index(['invoiceId']) -export class WarehouseFeeInvoiceItem extends BaseEntity { - @Column({ name: 'invoice_id', type: 'uuid' }) - invoiceId!: string; - - @ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'invoice_id' }) - invoice?: WarehouseFeeInvoice; - - @Column({ name: 'fee_rule_id', type: 'uuid', nullable: true }) - feeRuleId?: string | null; - - @Column({ name: 'fee_type', type: 'varchar', length: 32 }) - feeType!: WarehouseFeeType; - - @Column({ name: 'description', type: 'varchar', length: 255 }) - description!: string; - - @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 }) - quantity!: number; - - @Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 }) - unitRate!: number; - - @Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - amount!: number; - - @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) - currency!: string; - - @Column({ name: 'chargeable_days', type: 'int', nullable: true }) - chargeableDays?: number | null; - - @Column({ name: 'free_days', type: 'int', nullable: true }) - freeDays?: number | null; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts deleted file mode 100644 index e57d626d5..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index } from 'typeorm'; - -export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; -export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; - -export const WAREHOUSE_INVOICE_STATUSES = [ - 'DRAFT', - 'ISSUED', - 'PARTIALLY_PAID', - 'PAID', - 'CANCELLED', -] as const; -export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; - -/** A single recorded payment against a warehouse fee invoice (history). */ -export interface WarehouseInvoicePayment { - amount: number; - method?: string | null; - reference?: string | null; - paidAt: string; -} - -/** - * Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation. - * Owns warehouse fees; links to booking/customer/inventory/location so it can - * connect to the existing payment module without duplicating it. - */ -@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' }) -@Index(['invoiceNumber'], { unique: true }) -@Index(['bookingId']) -@Index(['inventoryId']) -@Index(['status']) -export class WarehouseFeeInvoice extends BaseEntity { - @Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true }) - invoiceNumber!: string; - - @Column({ name: 'booking_id', type: 'uuid', nullable: true }) - bookingId?: string | null; - - @Column({ name: 'customer_id', type: 'uuid', nullable: true }) - customerId?: string | null; - - @Column({ name: 'inventory_id', type: 'uuid' }) - inventoryId!: string; - - @Column({ name: 'facility_id', type: 'uuid', nullable: true }) - facilityId?: string | null; - - @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) - warehouseId?: string | null; - - @Column({ name: 'yard_id', type: 'uuid', nullable: true }) - yardId?: string | null; - - @Column({ name: 'zone_id', type: 'uuid', nullable: true }) - zoneId?: string | null; - - @Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' }) - invoiceType!: WarehouseInvoiceType; - - @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) - status!: WarehouseInvoiceStatus; - - @Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - subtotalAmount!: number; - - @Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - taxAmount!: number; - - @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - totalAmount!: number; - - @Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - paidAmount!: number; - - @Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - balanceAmount!: number; - - @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) - currency!: string; - - /** Charge window covered by this invoice — used to allow a later invoice for a new period. */ - @Column({ name: 'period_start', type: 'timestamptz', nullable: true }) - periodStart?: Date | null; - - @Column({ name: 'period_end', type: 'timestamptz', nullable: true }) - periodEnd?: Date | null; - - @Column({ name: 'issued_at', type: 'timestamptz', nullable: true }) - issuedAt?: Date | null; - - @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) - dueDate?: Date | null; - - @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) - paidAt?: Date | null; - - @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) - cancelledAt?: Date | null; - - @Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" }) - payments!: WarehouseInvoicePayment[]; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts deleted file mode 100644 index 5b5df396e..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; - -@Injectable() -export class WarehouseFeeInvoiceItemRepository extends BaseRepository { - constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository) { - super(repository); - } -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts deleted file mode 100644 index 97328f46d..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; - -@Injectable() -export class WarehouseFeeInvoiceRepository extends BaseRepository { - constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository) { - super(repository); - } -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 904728251..9b349181d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,22 +1,23 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Freight } from '@edr/types'; import { DataSource } from 'typeorm'; +import { BillingService, InvoiceLineInput } from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { InvoiceDocumentModel, InvoiceDocumentService, } from '../billing/documents/invoice-document.service'; -import { nextDailyInvoiceNumber } from '../billing/invoice-numbering.util'; -import { applySettlement } from '../billing/invoice-settlement.util'; import { NotificationsService } from '../notifications/notifications.service'; +import { WarehouseFeeService } from './warehouse-fee.service'; import { - WarehouseFeeInvoice, + WarehouseFeeInvoiceView, + WarehouseFeeType, + WarehouseInvoiceItemView, WarehouseInvoiceStatus, WarehouseInvoiceType, -} from './entities/warehouse-fee-invoice.entity'; -import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity'; -import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; -import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; -import { WarehouseFeeService } from './warehouse-fee.service'; +} from './warehouse-invoice.types'; interface GenerateOptions { confirmZero?: boolean; @@ -32,9 +33,20 @@ export interface PayInvoiceDto { driverPhone?: string; } -/** Invoices that still owe money and therefore block terminal release. */ -const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID']; -const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID']; +/** Warehouse fee invoices live in the global billing system under this source. */ +const SOURCE = Freight.InvoiceSource.Warehouse; +/** Document number prefix kept for warehouse fee invoices (e.g. `WHF-20260630-00001`). */ +const NUMBER_CODE = 'WHF'; + +/** Global statuses that still owe money and therefore block terminal release. */ +const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ + Freight.InvoiceStatus.Issued, + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.PartiallyPaid, + Freight.InvoiceStatus.Overdue, +]; +/** Global statuses considered an "active" invoice for per-inventory dedup. */ +const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid]; export interface InvoiceDocumentDetails { bookingReference: string | null; @@ -50,28 +62,75 @@ export interface InvoiceDocumentDetails { zoneName: string | null; } -export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial; +export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView & + Partial & { items: WarehouseInvoiceItemView[] }; +/** The warehouse-specific columns derived from the linked inventory item. */ +interface InventoryContext { + bookingId: string | null; + facilityId: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + periodStart: Date | null; +} + +/** Source fields a view is projected from — satisfied by the global {@link Invoice}. */ +interface ViewSource { + id: string; + invoiceNumber: string; + companyId: string; + sourceId: string; + type: string; + status: Freight.InvoiceStatus | string; + subtotalAmount: number | string; + taxAmount: number | string; + totalAmount: number | string; + paidAmount: number | string; + balanceAmount: number | string; + currency: string; + issuedAt?: Date | null; + dueAt?: Date | null; + paidAt?: Date | null; + createdAt: Date; + updatedAt: Date; + payments?: Array<{ + amount: number | string; + method?: string | null; + reference?: string | null; + paidAt: string; + }> | null; +} + +/** + * Thin warehouse layer over the central {@link BillingService}. Warehouse fee + * invoices are global `Invoice` rows (`source = warehouse`, `sourceId = + * inventoryId`); this service owns only the warehouse-specific concerns — + * computing fees, per-inventory dedup, release-blocking, SMS notifications, the + * sealed PDF, and reshaping the global invoice back into the historical + * `WarehouseFeeInvoice` JSON the portal/backoffice expect. All money, numbering, + * status, and payment math live in billing. + */ @Injectable() export class WarehouseInvoiceService { private readonly logger = new Logger(WarehouseInvoiceService.name); constructor( private readonly dataSource: DataSource, - private readonly invoiceRepository: WarehouseFeeInvoiceRepository, - private readonly itemRepository: WarehouseFeeInvoiceItemRepository, - private readonly feeService: WarehouseFeeService, + private readonly billing: BillingService, private readonly invoiceDocuments: InvoiceDocumentService, + private readonly feeService: WarehouseFeeService, private readonly notifications: NotificationsService, ) {} // ── Generation ─────────────────────────────────────────────────────────── - async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { + async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { const [item] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt", w.facility_id AS "facilityId", - b.company_id AS "customerId", b.freight_type AS "freightType" + b.company_id AS "companyId", b.company_profile_id AS "companyProfileId", + b.freight_type AS "freightType" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -80,9 +139,16 @@ export class WarehouseInvoiceService { ); if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + // Routing through the global invoice requires a billable company + profile, + // both of which come from the inventory's booking. + if (!item.companyId || !item.companyProfileId) { + throw new BadRequestException( + 'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).', + ); + } + // Dedup: only one active (non-cancelled) invoice per inventory item. - const active = await this.invoiceRepository.findAll({ where: { inventoryId } }); - if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) { + if (await this.hasActiveInvoice(inventoryId)) { throw new ConflictException( 'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.', ); @@ -117,9 +183,7 @@ export class WarehouseInvoiceService { }; }); - const subtotal = items.reduce((s, i) => s + i.amount, 0); - const total = subtotal; // tax model can be layered on later - + const total = items.reduce((s, i) => s + i.amount, 0); if (total <= 0 && !opts.confirmZero) { throw new BadRequestException('No payable warehouse fee found for this item.'); } @@ -129,58 +193,77 @@ export class WarehouseInvoiceService { const invoiceType: WarehouseInvoiceType = hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; - const currency = billingCurrency; - const now = new Date(); - const periodEnd = previews[0] ? new Date(previews[0].endDate) : now; + const lines: InvoiceLineInput[] = items.map((it) => ({ + chargeType: it.feeType, + description: it.description, + quantity: it.quantity, + unitRate: it.unitRate, + amount: it.amount, + currency: it.currency, + metadata: { + feeRuleId: it.feeRuleId ?? null, + chargeableDays: it.chargeableDays ?? null, + freeDays: it.freeDays ?? null, + }, + })); - const invoice = await this.invoiceRepository.create({ - invoiceNumber: await this.nextInvoiceNumber(), - bookingId: item.bookingId ?? null, - customerId: item.customerId ?? null, - inventoryId, - facilityId: item.facilityId ?? null, - warehouseId: item.warehouseId ?? null, - yardId: item.yardId ?? null, - zoneId: item.zoneId ?? null, - invoiceType, - status: 'ISSUED', - subtotalAmount: subtotal, - taxAmount: 0, - totalAmount: total, - paidAmount: 0, - balanceAmount: total, - currency, - periodStart: item.arrivedAt ?? null, - periodEnd, - issuedAt: now, - payments: [], - notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null, + const invoice = await this.billing.generateInvoice({ + source: SOURCE, + sourceId: inventoryId, + type: invoiceType, + companyId: item.companyId, + companyProfileId: item.companyProfileId, + currency: billingCurrency, + lines, + status: Freight.InvoiceStatus.Issued, + numberCode: NUMBER_CODE, }); - for (const it of items) { - await this.itemRepository.create({ invoiceId: invoice.id, ...it }); - } - - const saved = await this.findById(invoice.id); - await this.notifyWarehouseFeeIssued(saved); - return saved; - } - - /** WHF-YYYYMMDD-00001 — sequential per day (shared billing numbering). */ - private nextInvoiceNumber(): Promise { - return nextDailyInvoiceNumber(this.dataSource, { - table: 'freight.warehouse_fee_invoices', - code: 'WHF', - }); + const detail = await this.findById(invoice.id); + await this.notifyWarehouseFeeIssued(detail); + return detail; } // ── Reads ──────────────────────────────────────────────────────────────── - async findById(id: string): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - const items = await this.itemRepository.findAll({ where: { invoiceId: id } }); + async findById(id: string): Promise { + const invoice = await this.loadWarehouseInvoice(id); + const ctx = await this.getInventoryContext(invoice.sourceId); const details = await this.getInvoiceDocumentDetails(invoice); - return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] }; + const items = invoice.lines.map((l) => this.lineToItem(l)); + return { ...this.buildView(invoice, ctx), ...details, items }; + } + + listForInventory(inventoryId: string): Promise { + return this.queryViews('AND i.source_id = $1', [inventoryId]); + } + + listForBooking(bookingId: string): Promise { + return this.queryViews('AND inv.booking_id = $1', [bookingId]); + } + + async findAll( + filter: Partial< + Pick< + WarehouseFeeInvoiceView, + 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId' + > + >, + ): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + const add = (sql: (p: string) => string, value: unknown) => { + params.push(value); + conditions.push(sql(`$${params.length}`)); + }; + + if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus)); + if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType); + if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId); + if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId); + if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId); + if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId); + + return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params); } async document(id: string): Promise<{ filename: string; buffer: Buffer }> { @@ -196,20 +279,219 @@ export class WarehouseInvoiceService { return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT')); } - /** Map a warehouse fee invoice (with display details + items) onto the shared document model. */ + // ── State changes ──────────────────────────────────────────────────────── + async cancel(id: string): Promise { + const invoice = await this.loadWarehouseInvoice(id); + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException('A paid invoice cannot be cancelled.'); + } + await this.billing.cancelInvoice(id); + return this.findById(id); + } + + /** Record a payment against the invoice (delegates settlement to billing). */ + async pay(id: string, dto: PayInvoiceDto): Promise { + // Guard that this is a warehouse invoice before recording (404 otherwise). + await this.loadWarehouseInvoice(id); + await this.billing.recordPayment(id, { + amount: dto.amount, + method: dto.method ?? null, + reference: dto.reference ?? null, + metadata: + dto.driverName || dto.driverPhone + ? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null } + : null, + }); + const detail = await this.findById(id); + await this.notifyWarehouseFeePayment(detail, dto); + return detail; + } + + // ── Release blocking ────────────────────────────────────────────────────── + /** Returns the first unpaid invoice that blocks terminal release, or null. */ + async findBlockingInvoice(inventoryId: string): Promise { + const blocking = await this.queryViews( + `AND i.source_id = $1 AND i.status::text = ANY($2::text[])`, + [inventoryId, BLOCKING_STATUSES], + ); + return blocking[0] ?? null; + } + + async assertClearanceAllowed(inventoryId: string): Promise { + const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]); + const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); + if (blocking) { + throw new BadRequestException( + `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, + ); + } + + if (invoices.some((inv) => inv.status === 'PAID')) return; + + const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); + const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); + if (payableAmount > 0) { + throw new BadRequestException( + 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', + ); + } + } + + // ── Internal: loading & projection ───────────────────────────────────────── + + /** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */ + private async loadWarehouseInvoice(id: string): Promise { + const invoice = await this.billing.findById(id); + if (invoice.source !== SOURCE) { + throw new NotFoundException(`Invoice ${id} not found`); + } + return invoice; + } + + private async hasActiveInvoice(inventoryId: string): Promise { + const [row] = await this.dataSource.query( + `SELECT 1 + FROM freight.invoices + WHERE source = $1 AND source_id = $2 AND status::text = ANY($3::text[]) AND deleted_at IS NULL + LIMIT 1`, + [SOURCE, inventoryId, ACTIVE_STATUSES], + ); + return Boolean(row); + } + + /** + * Project warehouse-source global invoices into the historical view, joined to + * their inventory item for the typed FKs. Powers every list/filter read. + */ + private async queryViews(extraWhere: string, params: unknown[]): Promise { + const rows = await this.dataSource.query( + `SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId", + i.source_id AS "sourceId", i.type, i.status, + i.subtotal_amount AS "subtotalAmount", i.tax_amount AS "taxAmount", + i.total_amount AS "totalAmount", i.paid_amount AS "paidAmount", + i.balance_amount AS "balanceAmount", i.currency, i.payments, + i.issued_at AS "issuedAt", i.due_at AS "dueAt", i.paid_at AS "paidAt", + i.created_at AS "createdAt", i.updated_at AS "updatedAt", + inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", + w.facility_id AS "facilityId" + FROM freight.invoices i + LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere} + ORDER BY i.created_at DESC`, + [...params, SOURCE], + ); + + return (rows as Array).map((row) => + this.buildView(row, { + bookingId: row.bookingId ?? null, + facilityId: row.facilityId ?? null, + warehouseId: row.warehouseId ?? null, + yardId: row.yardId ?? null, + zoneId: row.zoneId ?? null, + periodStart: row.periodStart ?? null, + }), + ); + } + + /** Reshape a global invoice (+ derived inventory context) into the warehouse view. */ + private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView { + const status = this.toWarehouseStatus(inv.status); + return { + id: inv.id, + invoiceNumber: inv.invoiceNumber, + bookingId: ctx.bookingId, + customerId: inv.companyId ?? null, + inventoryId: inv.sourceId, + facilityId: ctx.facilityId, + warehouseId: ctx.warehouseId, + yardId: ctx.yardId, + zoneId: ctx.zoneId, + invoiceType: inv.type as WarehouseInvoiceType, + status, + subtotalAmount: Number(inv.subtotalAmount), + taxAmount: Number(inv.taxAmount), + totalAmount: Number(inv.totalAmount), + paidAmount: Number(inv.paidAmount), + balanceAmount: Number(inv.balanceAmount), + currency: inv.currency, + periodStart: ctx.periodStart, + // No standalone period column once centralized: the charge window ends at + // issuance, so `issuedAt` is the period end. + periodEnd: inv.issuedAt ?? null, + issuedAt: inv.issuedAt ?? null, + dueDate: inv.dueAt ?? null, + paidAt: inv.paidAt ?? null, + cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null, + payments: (inv.payments ?? []).map((p) => ({ + amount: Number(p.amount), + method: p.method ?? null, + reference: p.reference ?? null, + paidAt: p.paidAt, + })), + notes: null, + createdAt: inv.createdAt, + updatedAt: inv.updatedAt, + }; + } + + private lineToItem(line: InvoiceLine): WarehouseInvoiceItemView { + const meta = (line.metadata ?? {}) as { + feeRuleId?: string | null; + chargeableDays?: number | null; + freeDays?: number | null; + }; + return { + feeRuleId: meta.feeRuleId ?? null, + feeType: line.chargeType as WarehouseFeeType, + description: line.description ?? '', + quantity: Number(line.quantity), + unitRate: Number(line.unitRate), + amount: Number(line.amount), + currency: line.currency, + chargeableDays: meta.chargeableDays ?? null, + freeDays: meta.freeDays ?? null, + }; + } + + private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus { + switch (status) { + case Freight.InvoiceStatus.Draft: + return 'DRAFT'; + case Freight.InvoiceStatus.PartiallyPaid: + return 'PARTIALLY_PAID'; + case Freight.InvoiceStatus.Paid: + return 'PAID'; + case Freight.InvoiceStatus.Cancelled: + case Freight.InvoiceStatus.Refunded: + return 'CANCELLED'; + default: + // Issued / Pending / Overdue → an issued, still-owed invoice. + return 'ISSUED'; + } + } + + private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus { + switch (status) { + case 'DRAFT': + return Freight.InvoiceStatus.Draft; + case 'PARTIALLY_PAID': + return Freight.InvoiceStatus.PartiallyPaid; + case 'PAID': + return Freight.InvoiceStatus.Paid; + case 'CANCELLED': + return Freight.InvoiceStatus.Cancelled; + default: + return Freight.InvoiceStatus.Issued; + } + } + + /** Map a warehouse fee invoice view onto the shared document model. */ private toDocumentModel( - invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, + invoice: WarehouseFeeInvoiceDetail, kind: 'INVOICE' | 'RECEIPT', ): InvoiceDocumentModel { - const items = invoice.items as Array<{ - description?: string; - feeType?: string; - quantity?: number; - unitRate?: number; - amount?: number; - currency?: string; - chargeableDays?: number | null; - }>; const lastPayment = [...(invoice.payments ?? [])].pop(); const date = (value: unknown) => value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null; @@ -241,7 +523,7 @@ export class WarehouseInvoiceService { }, ], categoryHeader: 'Fee type', - lines: items.map((item) => ({ + lines: invoice.items.map((item) => ({ description: item.description ?? null, category: item.feeType ?? null, quantity: item.quantity ?? item.chargeableDays ?? 0, @@ -259,92 +541,14 @@ export class WarehouseInvoiceService { }; } - listForInventory(inventoryId: string): Promise { - return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } }); - } - - listForBooking(bookingId: string): Promise { - return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } }); - } - - findAll(filter: Partial>): Promise { - const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null)); - return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } }); - } - - // ── State changes ──────────────────────────────────────────────────────── - async cancel(id: string): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.'); - const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() }); - return updated as WarehouseFeeInvoice; - } - - /** Record a payment against the invoice and sync status (links to existing payment flow). */ - async pay(id: string, dto: PayInvoiceDto): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.'); - if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.'); - if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.'); - - const { paidAmount, balanceAmount, fullyPaid } = applySettlement( - invoice.totalAmount, - invoice.paidAmount, - dto.amount, - ); - - const payments = [ - ...(invoice.payments ?? []), - { amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() }, - ]; - - const updated = await this.invoiceRepository.update(id, { - paidAmount, - balanceAmount, - status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID', - paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, - payments, - }); - const paidInvoice = updated as WarehouseFeeInvoice; - await this.notifyWarehouseFeePayment(paidInvoice, dto); - return paidInvoice; - } - - // ── Release blocking ────────────────────────────────────────────────────── - /** Returns the first unpaid invoice that blocks terminal release, or null. */ - async findBlockingInvoice(inventoryId: string): Promise { - const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); - return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null; - } - - async assertClearanceAllowed(inventoryId: string): Promise { - const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); - const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)); - if (blocking) { - throw new BadRequestException( - `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, - ); - } - - if (invoices.some((inv) => inv.status === 'PAID')) return; - - const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); - const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); - if (payableAmount > 0) { - throw new BadRequestException( - 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', - ); - } - } - - private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise { + /** Warehouse-specific display details, derived from the linked inventory item. */ + private async getInvoiceDocumentDetails(invoice: ViewSource): Promise { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference", inv.status AS "inventoryStatus", + inv.release_date AS "releaseDate", COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", CONCAT_WS( @@ -355,16 +559,10 @@ export class WarehouseInvoiceService { ) AS "inventoryInfo", wh.name AS "warehouseName", yard.name AS "yardName", - zone.name AS "zoneName", - CASE - WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED' - WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE' - ELSE 'PENDING PAYMENT' - END AS "clearanceStatus" - FROM freight.warehouse_fee_invoices fee - LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL - LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL - LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + zone.name AS "zoneName" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id @@ -372,14 +570,21 @@ export class WarehouseInvoiceService { ) LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) - LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id - LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id - LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id - WHERE fee.id = $1 + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, - [invoice.id, invoice.status], + [invoice.sourceId], ); + const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID'; + const clearanceStatus = row?.releaseDate + ? 'RELEASE ISSUED' + : fullyPaid + ? 'FEE PAID - READY FOR RELEASE' + : 'PENDING PAYMENT'; + return { bookingReference: row?.bookingReference ?? null, customerName: row?.customerName ?? null, @@ -391,11 +596,33 @@ export class WarehouseInvoiceService { warehouseName: row?.warehouseName ?? null, yardName: row?.yardName ?? null, zoneName: row?.zoneName ?? null, - clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'), + clearanceStatus, }; } - private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{ + private async getInventoryContext(inventoryId: string): Promise { + const [row] = await this.dataSource.query( + `SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", + w.facility_id AS "facilityId" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE inv.id = $1 AND inv.deleted_at IS NULL + LIMIT 1`, + [inventoryId], + ); + return { + bookingId: row?.bookingId ?? null, + facilityId: row?.facilityId ?? null, + warehouseId: row?.warehouseId ?? null, + yardId: row?.yardId ?? null, + zoneId: row?.zoneId ?? null, + periodStart: row?.periodStart ?? null, + }; + } + + // ── Notifications ────────────────────────────────────────────────────────── + private async getInvoiceNotificationContacts(inventoryId: string): Promise<{ bookingReference: string | null; customerName: string | null; customerPhone: string | null; @@ -417,10 +644,9 @@ export class WarehouseInvoiceService { COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone", COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription" - FROM freight.warehouse_fee_invoices fee - LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL - LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL - LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id @@ -446,9 +672,9 @@ export class WarehouseInvoiceService { ) latest_first_mile ON true LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id - WHERE fee.id = $1 + WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, - [invoice.id], + [inventoryId], ); return { @@ -472,8 +698,8 @@ export class WarehouseInvoiceService { } } - private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice); + private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise { + const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); const customerName = contacts.customerName?.trim() || 'Customer'; const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; const cargo = contacts.containerNumber || contacts.cargoDescription; @@ -486,8 +712,8 @@ export class WarehouseInvoiceService { await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`); } - private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice); + private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise { + const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); const customerName = contacts.customerName?.trim() || 'Customer'; const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; const statusText = diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts new file mode 100644 index 000000000..e201241ba --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts @@ -0,0 +1,88 @@ +/** + * Public shapes for warehouse fee invoices. + * + * Warehouse fee invoices are no longer a standalone table — they are global + * `Invoice` rows (`source = "warehouse"`, `sourceId = inventoryId`) owned by the + * central {@link BillingService}. These types preserve the warehouse-facing API + * contract: `WarehouseInvoiceService` reshapes the global invoice (+ lines + + * inventory context) back into the historical `WarehouseFeeInvoice` JSON so the + * portal/backoffice stay untouched. + */ + +export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; +export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; + +export const WAREHOUSE_INVOICE_STATUSES = [ + 'DRAFT', + 'ISSUED', + 'PARTIALLY_PAID', + 'PAID', + 'CANCELLED', +] as const; +export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; + +export const WAREHOUSE_FEE_TYPES = [ + 'CONTAINER_DEMURRAGE', + 'BULK_DEMURRAGE', + 'STORAGE_FEE', + 'HANDLING_FEE', +] as const; +export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; + +/** A single recorded payment against a warehouse fee invoice (history). */ +export interface WarehouseInvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + paidAt: string; +} + +/** A billed warehouse fee line, projected from a global `InvoiceLine`. */ +export interface WarehouseInvoiceItemView { + feeRuleId: string | null; + feeType: WarehouseFeeType; + description: string; + quantity: number; + unitRate: number; + amount: number; + currency: string; + chargeableDays: number | null; + freeDays: number | null; +} + +/** + * The warehouse-facing invoice header — same field set the old + * `WarehouseFeeInvoice` entity exposed, projected from a global `Invoice`. The + * typed FKs (`bookingId`/`facilityId`/`warehouseId`/`yardId`/`zoneId`) and the + * charge `period` are derived from the linked inventory item; `customerId` is the + * billed company; `invoiceType` is the invoice `type`. + */ +export interface WarehouseFeeInvoiceView { + id: string; + invoiceNumber: string; + bookingId: string | null; + customerId: string | null; + inventoryId: string; + facilityId: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + invoiceType: WarehouseInvoiceType; + status: WarehouseInvoiceStatus; + subtotalAmount: number; + taxAmount: number; + totalAmount: number; + paidAmount: number; + balanceAmount: number; + currency: string; + periodStart: Date | null; + periodEnd: Date | null; + issuedAt: Date | null; + dueDate: Date | null; + paidAt: Date | null; + cancelledAt: Date | null; + payments: WarehouseInvoicePayment[]; + notes: string | null; + createdAt: Date; + updatedAt: Date; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index a7ce68319..b871d2a36 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { DocumentsModule } from '../billing/documents/documents.module'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; @@ -11,8 +12,6 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; -import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; -import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; @@ -39,8 +38,6 @@ import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.r import { WarehouseAllocationService } from './warehouse-allocation.service'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; import { WarehouseFeeService } from './warehouse-fee.service'; -import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; -import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; import { WarehouseInvoiceController } from './warehouse-invoice.controller'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseRulesController } from './warehouse-rules.controller'; @@ -68,9 +65,8 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionReport, WarehouseAllocationRule, WarehouseFeeRule, - WarehouseFeeInvoice, - WarehouseFeeInvoiceItem, ]), + BillingModule, DocumentsModule, FilesModule, InterchangeDocumentsModule, @@ -104,8 +100,6 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionRepository, WarehouseAllocationRuleRepository, WarehouseFeeRuleRepository, - WarehouseFeeInvoiceRepository, - WarehouseFeeInvoiceItemRepository, WarehousesService, WarehouseYardsService, WarehouseZonesService, diff --git a/apps/edr-freight-api/tsconfig.json b/apps/edr-freight-api/tsconfig.json index 467c474ee..52598cb95 100644 --- a/apps/edr-freight-api/tsconfig.json +++ b/apps/edr-freight-api/tsconfig.json @@ -7,6 +7,7 @@ "noEmit": false, "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", + "preserveWatchOutput": true, "module": "node16", "moduleResolution": "node16" }, From 8e0cc7d5ee37bf720c42d4431bdda95c19e4a7d2 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 13:10:47 +0000 Subject: [PATCH 024/401] fix: vehicles data extraction in maintenance + financial pages Both MaintenancePage and FinancialReportsPage were calling vehiclesService.getAll() but not extracting res.data property. Result was vehicles being undefined, causing .map error. Fixed to match FuelPurchasePage pattern: const res = await vehiclesService.getAll() return res.data || [] Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/fleet/FinancialReportsPage.tsx | 5 ++++- .../backoffice/src/pages/fleet/MaintenancePage.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx index 31d7a7a23..729c50427 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -36,7 +36,10 @@ export function FinancialReportsPage() { const { data: vehicles } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), - queryFn: () => vehiclesService.getAll({ limit: 1000 }), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, }); const { data: fuelStats } = useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index 162ee5f21..d94ed375c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -35,7 +35,10 @@ export function MaintenancePage() { const { data: vehicles } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), - queryFn: () => vehiclesService.getAll({ limit: 1000 }), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, }); const { data: upcoming, isLoading } = useQuery({ From 0056dec9248f73ce820b1de3cf54d74ec817a549 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 13:28:14 +0000 Subject: [PATCH 025/401] style: clean up the invoice and setup event for warehouse. --- .../modules/billing/billing.service.spec.ts | 2 +- .../src/modules/billing/billing.service.ts | 16 +++---- .../src/modules/payment/payment.controller.ts | 13 +----- .../src/modules/payment/payment.service.ts | 46 ++----------------- .../warehouses/warehouse-invoice.service.ts | 21 +++++++-- 5 files changed, 30 insertions(+), 68 deletions(-) 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 e52dfafa1..61597264b 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 @@ -89,7 +89,7 @@ describe("BillingService.generateInvoice", () => { expect(invoice.sourceId).toBe("booking-1"); expect(invoice.totalAmount).toBe(1500); expect(invoice.issuedAt).toBeInstanceOf(Date); - expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/); + expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/); expect(savedLines).toHaveLength(2); }); 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 e4389e7cf..f1b58ad5e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -96,11 +96,6 @@ export interface GenerateInvoiceInput { * (default PENDING) stamps `issuedAt`. */ status?: Freight.InvoiceStatus; - /** - * Document number prefix for this source (e.g. `WHF` for warehouse fees); - * defaults to `FRT`. The daily sequence is allocated per prefix. - */ - numberCode?: string; } /** Payload broadcast on `${source}.invoice.`. */ @@ -665,11 +660,12 @@ export class BillingService { const result = await this.payment.initiate({ referenceId: sourceId, source: invoice.source, - // Gateway reference type derives from the invoice source by convention - // (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and - // the domain never supplies it. New sources add their uppercased value to - // the PaymentReferenceType enum. - referenceType: invoice.source.toUpperCase() as PaymentReferenceType, + // Freight payments settle under the generic SHIPMENT reference — how the + // payment service attributes them to the freight API. The payment ↔ invoice + // link is the intent id (`paymentId`); per-source post-payment reactions live + // in the domain via `${source}.invoice.paid`. Neither billing nor the payment + // service branches on a domain-specific reference type. + referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber, amountMinor: Math.round(Number(invoice.totalAmount)), currency: invoice.currency, diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index b1b269665..50856c3d7 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -6,8 +6,6 @@ import { ParseUUIDPipe, Query, Res, - Body, - Post, } from "@nestjs/common"; import { ApiTags, @@ -18,9 +16,9 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { BookingView, FreightAdmin } from "../../common/booking-guards"; +import { BookingView } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; -import { IntentStatusDto, RefundDto } from "./payments.dto"; +import { IntentStatusDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") @@ -73,13 +71,6 @@ export class PaymentController { return this.paymentService.getIntentByBookingId(bookingId); } - @Post("refund") - @FreightAdmin() - @ApiOperation({ summary: "Refund a paid booking (staff/admin only)" }) - refund(@Body() dto: RefundDto) { - return this.paymentService.refund(dto); - } - @Get("receipt/:orderId") @Public() @ApiOperation({ summary: "Generate a payment receipt HTML page" }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 738a6d118..d92af7a3e 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -7,7 +7,6 @@ import { Logger, NotFoundException, } from "@nestjs/common"; -import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; @@ -16,7 +15,6 @@ import { BillingService } from "../billing/billing.service"; import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; -import { Booking } from "../bookings/entities/booking.entity"; import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers"; import { @@ -29,7 +27,6 @@ import { InitiateResponseDto, IntentStatusDto, PaymentPlatformDto, - RefundDto, } from "./payments.dto"; /** Everything the gateway needs to open an intent. Amount/currency are supplied by @@ -96,7 +93,6 @@ export class PaymentService { private readonly logger = new Logger(PaymentService.name); constructor( - private readonly datasource: DataSource, private readonly paymentRepo: PaymentRepository, private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BillingService)) @@ -404,34 +400,6 @@ export class PaymentService { ); } - async refund(dto: RefundDto) { - const intent = await this.paymentRepo.findOneBy({ - refId: dto.bookingId, - type: "booking", - }); - if (!intent || intent.status !== "success") { - throw new BadRequestException("No successful payment to refund"); - } - - // NOTE: refunding still mutates the booking directly — left intact pending - // the refund redesign. TODO: route refunds through billing.refundPayable + - // a `${source}.invoice.refunded` reaction, like settlement. - await this.datasource.transaction(async (mg) => { - await mg.update( - PaymentEntity, - { id: intent.id }, - { status: "refunded", refundedAt: new Date() }, - ); - await mg.update( - Booking, - { id: dto.bookingId }, - { paymentStatus: "FAILED", status: "CANCELLED" }, - ); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - async getActivePaymentByOrderIdAndMethod( orderId: string, method: PaymentEntity["method"], @@ -527,16 +495,10 @@ export class PaymentService { `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, ); - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { - await this.datasource.manager.update( - Booking, - { id: intent.refId }, - { status: "PAID", paymentStatus: "PAID" }, - ); - } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + // The payment service stays domain-agnostic: it settles the intent and + // lets billing settle the invoice (markIntentSucceeded → settleByPaymentId), + // which emits `${source}.invoice.paid`. Per-source advances (booking → PAID, + // warehouse → release, …) live in the domain services that listen for it. return { processed: true, alreadyFinalized }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 9b349181d..6f7219781 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; import { DataSource } from 'typeorm'; -import { BillingService, InvoiceLineInput } from '../billing/billing.service'; +import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { @@ -35,8 +36,6 @@ export interface PayInvoiceDto { /** Warehouse fee invoices live in the global billing system under this source. */ const SOURCE = Freight.InvoiceSource.Warehouse; -/** Document number prefix kept for warehouse fee invoices (e.g. `WHF-20260630-00001`). */ -const NUMBER_CODE = 'WHF'; /** Global statuses that still owe money and therefore block terminal release. */ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ @@ -216,7 +215,6 @@ export class WarehouseInvoiceService { currency: billingCurrency, lines, status: Freight.InvoiceStatus.Issued, - numberCode: NUMBER_CODE, }); const detail = await this.findById(invoice.id); @@ -307,6 +305,21 @@ export class WarehouseInvoiceService { return detail; } + /** + * Notify on online (gateway) settlement — the domain side-effect of a warehouse + * fee being paid through billing's payment flow. The counter {@link pay} path + * notifies inline (and carries driver details from the request), so this only + * handles gateway payments: those stamp the invoice `paymentId`, whereas a + * counter settlement leaves it null. Skipping null-`paymentId` events avoids + * double-notifying a counter payment that already sent its SMS. + */ + @OnEvent('warehouse.invoice.paid') + async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise { + if (!payload.paymentId) return; + const detail = await this.findById(payload.invoiceId); + await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) }); + } + // ── Release blocking ────────────────────────────────────────────────────── /** Returns the first unpaid invoice that blocks terminal release, or null. */ async findBlockingInvoice(inventoryId: string): Promise { From 814db8a17d93ef5b9c04b44a93993a387059d717 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 13:38:35 +0000 Subject: [PATCH 026/401] feat: fleet dashboard page Shows fleet overview + key metrics: - Total vehicles, active count - Total fuel spending - Total maintenance spending - Average fuel efficiency - Fleet status (active/idle/maintenance) - Operating cost breakdown (fuel vs maintenance pie chart) - Fleet vehicle list (first 10) Route: /dashboard/fleet-dashboard Sidebar: Added to Fleet Management section Metrics aggregate from: - /vehicles (fleet size) - /fuel/stats (fuel spending + efficiency) - /maintenance/stats (maintenance spending) Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-web/backoffice/src/App.tsx | 15 + .../src/pages/fleet/FleetDashboard.tsx | 262 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 513dd1300..7d8d07ab5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -61,6 +61,7 @@ import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; import FuelStatsPage from "./pages/fleet/FuelStatsPage"; import { MaintenancePage } from "./pages/fleet/MaintenancePage"; import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; +import { FleetDashboard } from "./pages/fleet/FleetDashboard"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -168,6 +169,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Fleet Management", items: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, { label: "Routes", href: "/dashboard/routes", @@ -785,6 +792,14 @@ const App = () => { } /> + + + + } + /> ( + + + + + {label} + + + {value} + + + + + + + +); + +export function FleetDashboard() { + const { data: vehicles = [] } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + const { data: fuelStats } = useQuery({ + queryKey: ['fleet-fuel-stats'], + queryFn: async () => { + try { + const res = await api.get('/fuel/stats'); + return res.data || {}; + } catch { + return {}; + } + }, + }); + + const { data: maintenanceStats } = useQuery({ + queryKey: ['fleet-maintenance-stats'], + queryFn: async () => { + try { + const res = await api.get('/maintenance/stats'); + return res.data || {}; + } catch { + return {}; + } + }, + }); + + const metrics = useMemo((): FleetMetrics => { + const totalVehicles = (vehicles as Vehicle[]).length; + const activeVehicles = (vehicles as Vehicle[]).filter(v => v.status === 'ACTIVE').length; + + const fuelTotal = fuelStats?.totalCost || 0; + const maintenanceTotal = maintenanceStats?.totalCost || 0; + + return { + totalVehicles, + activeVehicles, + maintenanceOverdue: 0, // TODO: fetch from API + totalFuelSpend: fuelTotal, + totalMaintenanceSpend: maintenanceTotal, + averageFuelEfficiency: fuelStats?.averageEfficiency || 0, + costPerKm: (fuelTotal + maintenanceTotal) / 100000, // Placeholder + }; + }, [vehicles, fuelStats, maintenanceStats]); + + const operatingCost = metrics.totalFuelSpend + metrics.totalMaintenanceSpend; + const fuelPercent = operatingCost > 0 ? Math.round((metrics.totalFuelSpend / operatingCost) * 100) : 0; + + return ( + + + + + Fleet Overview + + + {/* Key Metrics */} + + + + + + + + + + + + + + + + {/* Fleet Status */} + + + + + Fleet Status + + + +
+ + Active Vehicles + {metrics.activeVehicles} / {metrics.totalVehicles} + + +
+ +
+ + Maintenance Overdue + {metrics.maintenanceOverdue} + + +
+ +
+ + Idle / Under Maintenance + {metrics.totalVehicles - metrics.activeVehicles} + + +
+
+
+
+
+ + + + + Operating Cost Breakdown + + + + + + + ${operatingCost.toFixed(0)} + + + Total Cost + + + } + size={120} + thickness={4} + /> + + +
+ + + + + + Fuel + + {fuelPercent}% + +
+ +
+ + + + + + Maintenance + + {100 - fuelPercent}% + +
+
+
+
+
+
+ + {/* Fleet List */} + + + Fleet Vehicles + + + {(vehicles as Vehicle[]).length > 0 ? ( + + + + Registration + Plate + Model + Status + + + + {(vehicles as Vehicle[]).slice(0, 10).map(v => ( + + {v.registrationNumber} + {v.plateNumber} + + {v.manufacturer} {v.model} + + + {v.status || 'UNKNOWN'} + + + ))} + +
+ ) : ( + + + + No vehicles in fleet + + + )} +
+
+
+ ); +} From 6e81090f8c3e2f91688e6343c5e693042fdcad13 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 30 Jun 2026 16:45:51 +0300 Subject: [PATCH 027/401] Added optimization for search result and fix sea map --- .../src/modules/search/search.service.ts | 284 ++++++++---------- .../src/modules/segments/segments.service.ts | 90 ++++++ .../portal/src/app/booking/seats/page.tsx | 24 +- .../portal/src/components/AppHeader.tsx | 4 - 4 files changed, 229 insertions(+), 173 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index e14db6b0a..08687d291 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -9,6 +9,30 @@ import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; +// Shape returned by the heavy schedule include used throughout this service +type ScheduleWithIncludes = { + id: string; + routeId: string | null; + departureAt: Date; + arrivalAt: Date; + status: string; + train: any; + originStation: any; + destinationStation: any; + stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>; + coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>; +}; + +const SCHEDULE_INCLUDE = { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, +} as const; + @Injectable() export class SearchService { constructor( @@ -124,7 +148,7 @@ export class SearchService { if (windowStart < now) windowStart.setTime(now.getTime()); const windowEnd = new Date(requestedDate); - windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound + windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); const totalPassengers = adultCount + (childCount ?? 0); @@ -139,30 +163,16 @@ export class SearchService { ], stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, orderBy: { departureAt: 'asc' }, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult( - schedule, - originStationId, - destinationStationId, - totalPassengers, - nationality, - ); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } private async searchSchedules( @@ -185,29 +195,18 @@ export class SearchService { departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } // ── Transit search ───────────────────────────────────────────────────────── - // Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination) - // where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes - // to change trains at the transit station. private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; @@ -219,82 +218,67 @@ export class SearchService { childCount?: number, nationality?: string, ) { - // Find all stations that can serve as transit points: - // they must be a stop after origin on some schedule AND - // a stop before destination on another schedule on the same day. const [y, m, d] = dateStr.split('-').map(Number); const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000); const totalPassengers = adultCount + (childCount ?? 0); - // Load all schedules on this date that pass through origin - const leg1Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: dayStart, lt: dayEnd }, - stopTimes: { some: { stationId: originStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + // Load leg1 and all potential leg2 candidates in one parallel round-trip + // instead of firing a separate DB query per transit stop. + const [leg1Schedules, allCandidates] = await Promise.all([ + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: dayEnd }, + stopTimes: { some: { stationId: originStationId } }, }, - }, - }); + include: SCHEDULE_INCLUDE, + }), + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: leg2WindowEnd }, + }, + include: SCHEDULE_INCLUDE, + }), + ]); const results: any[] = []; - for (const leg1 of leg1Schedules) { - const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId); + for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) { + const originStop = leg1.stopTimes.find(s => s.stationId === originStationId); if (!originStop) continue; - // Every stop after origin on leg1 is a candidate transit station const candidateTransitStops = leg1.stopTimes.filter( - (s: any) => s.sequence > originStop.sequence, + s => s.sequence > originStop.sequence, ); for (const transitStop of candidateTransitStops) { - // leg1 must NOT already contain the final destination - const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId); - if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules + const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId); + if (leg1HasDest) continue; const transitStationId = transitStop.stationId; const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; - // Find leg2 schedules departing from the transit station within the connection window, - // and reaching the final destination. Search up to the next calendar day to handle - // overnight connections. const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000); const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000); - const leg2Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: connWindowStart, lte: connWindowEnd }, - stopTimes: { some: { stationId: transitStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + // Filter from pre-loaded candidates in memory — no extra DB query + const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => { + const dep = new Date(s.departureAt).getTime(); + return dep >= connWindowStart.getTime() + && dep <= connWindowEnd.getTime() + && s.stopTimes.some(st => st.stationId === transitStationId); }); for (const leg2 of leg2Schedules) { - const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId); - const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId); + const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId); + const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId); if (!leg2TransitStop || !leg2DestStop) continue; if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; - // Build individual leg result objects (reuse existing per-schedule logic) const [leg1Result, leg2Result] = await Promise.all([ this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality), this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality), @@ -326,7 +310,6 @@ export class SearchService { displayCurrency, combinedMinFareMinor, combinedMinFareDisplay, - // Convenience top-level fields so round-trip filter can read them uniformly departureAt: leg1Result.departureAt, arrivalAt: leg2Result.arrivalAt, totalDurationMinutes: @@ -339,19 +322,37 @@ export class SearchService { return results; } - // Builds the same result shape as searchSchedules for a single schedule+leg, - // extracted so both direct and transit paths share identical output. private async buildScheduleResult( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, totalPassengers: number, nationality?: string, ) { - const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); - const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); + const originStop = schedule.stopTimes.find(s => s.stationId === originStationId); + const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; + // Collect all valid seat IDs upfront for a single batch availability check + const allValidSeatIds = schedule.coachAssignments.flatMap(a => + a.coach.seats + .filter((s: any) => s.status !== 'BLOCKED' && s.seatNumber?.trim()) + .map((s: any) => s.id as string) + ); + + // Run availability batch and fare calculation in parallel + const [freeSeats, faresByClass] = await Promise.all([ + this.segmentsService.getFreeSeatIds( + schedule.id, + allValidSeatIds, + schedule.stopTimes, + originStop.sequence, + destStop.sequence, + ), + this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality), + ]); + + // Compute per-class availability using the pre-computed free seat set const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; @@ -362,8 +363,7 @@ export class SearchService { let count = 0; for (const seat of assignment.coach.seats) { if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) count++; + if (freeSeats.has(seat.id)) count++; } if (count > 0) { const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); @@ -374,15 +374,13 @@ export class SearchService { let available = 0; for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) available++; + if (freeSeats.has(seat.id)) available++; } for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; } } - const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality); - const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); + const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; @@ -400,8 +398,8 @@ export class SearchService { durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000), status: schedule.status, stops: schedule.stopTimes - .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) - .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), + .filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) + .map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), availabilityByClass, hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), displayCurrency, @@ -500,46 +498,31 @@ export class SearchService { } private async calculateFaresForSegment( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, nationality?: string, ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); - const seatClassIds: string[] = Array.from( - new Set( - schedule.coachAssignments - .flatMap((a: any) => a.coach.coachType?.seatClasses || []) - .map((sc: any) => sc.id) - .filter((id: any) => id) - ) - ); - - if (seatClassIds.length === 0) { - console.log(`No seat classes assigned to schedule ${schedule.id}`); - return []; + // Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany + const seatClassMap = new Map(); + for (const a of schedule.coachAssignments) { + for (const sc of (a.coach.coachType?.seatClasses ?? [])) { + if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc); + } } + const seatClasses = Array.from(seatClassMap.values()) + .sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor); - const seatClasses = await this.prisma.seatClass.findMany({ - where: { - isActive: true, - id: { in: seatClassIds } - }, - orderBy: { baseFareMinor: 'asc' }, - }); - - if (seatClasses.length === 0) { - console.log(`No active seat classes for schedule ${schedule.id}`); - return []; - } + if (seatClasses.length === 0) return []; if (schedule.routeId) { const results = await Promise.all( seatClasses.map(async (sc) => { try { const fare = await this.fareEngine.calculate({ - routeId: schedule.routeId, + routeId: schedule.routeId!, originStationId, destinationStationId, seatClassId: sc.id, @@ -552,8 +535,7 @@ export class SearchService { displayCurrency: fare.billingCurrency as Currency, displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), }; - } catch (error) { - console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message); + } catch { return null; } }), @@ -562,36 +544,32 @@ export class SearchService { const validResults = results.filter( (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, ); - if (validResults.length > 0) { - return validResults; - } + if (validResults.length > 0) return validResults; } - const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); - const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); + // Fallback: use station codes from already-loaded stopTimes when available + const originStop = schedule.stopTimes.find(st => st.stationId === originStationId); + const destStop = schedule.stopTimes.find(st => st.stationId === destinationStationId); + const originCode = originStop?.station?.code; + const destCode = destStop?.station?.code; - if (originStation && destStation) { - const segmentRoute = `${originStation.code}-${destStation.code}`; + if (originCode && destCode) { + const segmentRoute = `${originCode}-${destCode}`; const now = new Date(); const fareRules = await this.prisma.fareRule.findMany({ where: { route: segmentRoute, - seatClassId: { in: seatClassIds }, + seatClassId: { in: seatClasses.map((sc: any) => sc.id) }, validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], + OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }); if (fareRules.length > 0) { - console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); - const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); return fareRules.map(rule => ({ - seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', + seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', baseFareMinor: rule.baseFareMinor, displayCurrency, displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), @@ -599,20 +577,20 @@ export class SearchService { } } - console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`); return []; } - private async buildCoachTypeDetails( - schedule: any, + // buildCoachTypeDetails is pure in-memory — no async needed + private buildCoachTypeDetails( + schedule: ScheduleWithIncludes, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, - ): Promise; - }>> { + }> { const coachTypeMap = new Map< string, { coachType: any; classNames: Set; coachId: string } @@ -682,14 +660,6 @@ export class SearchService { return fare.baseFarePerPassengerMinor; } - private getDefaultFareForClass(_className: string): never { - throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead'); - } - - private defaultFare(_seatClassName: string): never { - throw new Error('defaultFare should not be called — use resolveScheduleFare instead'); - } - private selectBestFareRule( candidates: any[], scheduleId: string, diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts index 2eef0302e..16b486bbe 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts @@ -146,6 +146,96 @@ export class SegmentsService { return true; } + /** + * Batch availability check for multiple seats on a single schedule. + * Replaces N×isSeatFreeForLeg calls with 2 queries total. + * Returns a Set of seat IDs that are free for [reqFrom, reqTo). + */ + async getFreeSeatIds( + scheduleId: string, + seatIds: string[], + stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>, + reqFrom: number, + reqTo: number, + ): Promise> { + if (seatIds.length === 0) return new Set(); + + const seqOf = (stationId: string) => + stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence; + + const seatIdSet = new Set(seatIds); + const now = new Date(); + + const [allHolds, bookedLegs] = await Promise.all([ + this.prisma.seatHold.findMany({ + where: { scheduleId, expiresAt: { gt: now } }, + select: { seatIds: true, createdBy: true }, + }), + this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { in: seatIds }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true, journeyId: true, departureStationId: true, arrivalStationId: true }, + }), + ]); + + // Determine which seats are blocked by active holds + const holdBlockedSeats = new Set(); + for (const hold of allHolds) { + let holdFrom: number | undefined; + let holdTo: number | undefined; + try { + if (hold.createdBy) { + const meta = JSON.parse(hold.createdBy as string); + holdFrom = seqOf(meta.originStationId); + holdTo = seqOf(meta.destinationStationId); + } + } catch { /* ignore */ } + + for (const sid of hold.seatIds) { + if (!seatIdSet.has(sid)) continue; + // Conservative block if leg can't be resolved; otherwise check overlap + if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) { + holdBlockedSeats.add(sid); + } + } + } + + // Build full journey ranges per seat (group multi-leg journeys) + const journeyRangesBySeat = new Map>(); + for (const leg of bookedLegs) { + if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue; + const depSeq = seqOf(leg.departureStationId); + const arrSeq = seqOf(leg.arrivalStationId); + if (depSeq === undefined || arrSeq === undefined) continue; + + let rangeMap = journeyRangesBySeat.get(leg.seatId); + if (!rangeMap) { rangeMap = new Map(); journeyRangesBySeat.set(leg.seatId, rangeMap); } + + const existing = rangeMap.get(leg.journeyId); + rangeMap.set(leg.journeyId, existing + ? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) } + : { from: depSeq, to: arrSeq }); + } + + const freeSeats = new Set(); + for (const seatId of seatIds) { + if (holdBlockedSeats.has(seatId)) continue; + let blocked = false; + const rangeMap = journeyRangesBySeat.get(seatId); + if (rangeMap) { + for (const { from, to } of rangeMap.values()) { + if (from < reqTo && reqFrom < to) { blocked = true; break; } + } + } + if (!blocked) freeSeats.add(seatId); + } + + return freeSeats; + } + /** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */ async getOverlappingReservations( scheduleId: string, diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 696dcb45b..a02f00eec 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -259,20 +259,14 @@ export default function SeatsPage() { })), }); - const coachesWithSeats = coaches.filter( - (c: any) => c.seats && c.seats.length > 0, - ); - - if (!currentSchedule?.selectedSeatClass) { - console.log( - "✅ No filter applied, returning all coaches:", - coachesWithSeats.length, - ); - return coachesWithSeats; - } + const coachesWithSeats = coaches.filter((c: any) => { + // Bed coaches store occupants in rooms.beds, not seats + if (c.rooms?.length > 0) return c.rooms.some((r: any) => r.beds?.length > 0); + return c.seats && c.seats.length > 0; + }); console.log( - "✅ No seat class filter - returning all coaches with seats:", + "✅ Returning all coaches with seats/beds:", coachesWithSeats.length, ); return coachesWithSeats; @@ -333,6 +327,8 @@ export default function SeatsPage() { return seatLabel && !seatLabel.startsWith("-"); }); const isBedCoach = + selectedCoachData?.isBedCoach === true || + seats.some((s: any) => s.bedPosition) || selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.mode?.toLowerCase().includes("bed"); @@ -1071,6 +1067,8 @@ export default function SeatsPage() { const allSelected = selectedSeats.length === passengers.length; const isBedCoach = + selectedCoachData?.isBedCoach === true || + selectedCoachData?.rooms?.length > 0 || selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.mode?.toLowerCase().includes("bed"); @@ -1314,6 +1312,8 @@ export default function SeatsPage() { } const isBed = + coach.isBedCoach === true || + coach.rooms?.length > 0 || coach.seatClass?.toLowerCase().includes("bed") || coach.mode?.toLowerCase().includes("bed"); diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx index 3b6c19336..6422e12fa 100644 --- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx @@ -4,7 +4,6 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useEffect, useState } from "react"; -import { LanguageSwitcher } from "./LanguageSwitcher"; export default function AppHeader() { const [isOpen, setIsOpen] = useState(false); @@ -72,9 +71,6 @@ export default function AppHeader() { - {/* Language Switcher */} - - {/* Theme Toggler */} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index d94ed375c..61c1215ba 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -2,9 +2,11 @@ import { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text } from '@mantine/core'; import { DateInput } from '@mantine/dates'; +import { Plus } from 'lucide-react'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/services/api'; import { vehiclesService } from '@/services/vehicles.service'; +import { freightBrand } from '@/theme/freight-brand'; interface MaintenanceSchedule { id: string; @@ -76,12 +78,12 @@ export function MaintenancePage() { const statusColor = (status: string) => { const colors: Record = { - SCHEDULED: 'blue', - IN_PROGRESS: 'yellow', - COMPLETED: 'green', - OVERDUE: 'red', + SCHEDULED: 'edr-blue', + IN_PROGRESS: 'edr-amber-soft', + COMPLETED: 'edr-green', + OVERDUE: 'edr-red', }; - return colors[status] || 'gray'; + return colors[status] || 'edr-slate'; }; return ( @@ -90,7 +92,9 @@ export function MaintenancePage() { Schedule Maintenance - + From 69d1d3073f0724003b9f08045db747b0e551d795 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 13:59:20 +0000 Subject: [PATCH 031/401] style: standardize dashboard padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All fleet pages now use consistent layout: - Container size: xl - Vertical padding: xl Pages updated: - FleetDashboard: size="xl" py="xl" (unchanged) - FuelPurchasePage: lg → xl - FuelStatsPage: lg → xl - MaintenancePage: Added Container wrapper (xl, xl) - FinancialReportsPage: Added Container wrapper (xl, xl) Uniform spacing across all fleet management dashboards. Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/fleet/FinancialReportsPage.tsx | 8 +++++--- .../backoffice/src/pages/fleet/FuelPurchasePage.tsx | 2 +- .../backoffice/src/pages/fleet/FuelStatsPage.tsx | 2 +- .../backoffice/src/pages/fleet/MaintenancePage.tsx | 8 +++++--- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx index f61583f8f..312d38676 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress } from '@mantine/core'; +import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress, Container } from '@mantine/core'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/services/api'; import { vehiclesService } from '@/services/vehicles.service'; @@ -91,7 +91,8 @@ export function FinancialReportsPage() { ); return ( - + + Fleet Financial Analysis @@ -244,6 +245,7 @@ export function FinancialReportsPage() { )} - + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx index 574b9d1c6..21974fc37 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -119,7 +119,7 @@ export default function FuelPurchasePage() { const totalCost = formData.liters * formData.costPerLiter; return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx index 78ed36498..f5efe8cdd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -57,7 +57,7 @@ export default function FuelStatsPage() { : "—"; return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index 61c1215ba..455290647 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text } from '@mantine/core'; +import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text, Container } from '@mantine/core'; import { DateInput } from '@mantine/dates'; import { Plus } from 'lucide-react'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; @@ -87,7 +87,8 @@ export function MaintenancePage() { }; return ( - + + @@ -199,6 +200,7 @@ export function MaintenancePage() { - + + ); } From 38db9a4177a40af201f7f79f1d93f6381b7f5305 Mon Sep 17 00:00:00 2001 From: hagiye Date: Tue, 30 Jun 2026 17:05:53 +0300 Subject: [PATCH 032/401] Delivery approval customer handover signature --- .../edr-freight-web/backoffice/src/constants/apiConfig.ts | 8 ++++---- apps/edr-freight-web/portal/src/constants/apiConfig.ts | 4 ---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 0a5ce4647..07e271cb6 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,11 +1,11 @@ -<<<<<<< HEAD + export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; //export const API_BASE_URL = 'http://localhost:3001'; -======= -export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL; + + // export const API_BASE_URL = 'http://localhost:3001'; ->>>>>>> 8616f6dcdfa44089be2fd7b6a9867b20e320b305 + /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index c51eb9e7b..07496364b 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,9 +1,5 @@ -<<<<<<< HEAD export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; //export const API_BASE_URL = 'http://localhost:3001'; -======= -export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL; ->>>>>>> 8616f6dcdfa44089be2fd7b6a9867b20e320b305 /** * URL that streams an uploaded file through the API by its UUID. Routes the From 2e1c720b86970942945a8b0c7b648386c839694d Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 14:08:21 +0000 Subject: [PATCH 033/401] feat: add vehicle tracking/GPS map New TrackingPage with: - Interactive map grid showing vehicle locations - Real-time GPS coordinates (mock data) - Vehicle speed & heading display - Vehicle selector dropdown - Live status indicators - All vehicles list with speed - Click-to-track functionality - Location details sidebar: * Latitude/Longitude * Current speed * Heading direction * Last update timestamp * View history button Features: - Grid-based map (no external dependencies) - Vehicle markers (color-coded selected/inactive) - SVG grid background (lat/lng lines) - Responsive layout (map + sidebar) - Mantine UI + brand colors - Mock GPS generation per vehicle Route: /dashboard/tracking Sidebar: "Track Vehicles" in Fleet Management Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-web/backoffice/src/App.tsx | 16 + .../src/pages/fleet/TrackingPage.tsx | 353 ++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 7d8d07ab5..8c1562bc4 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -6,6 +6,7 @@ import { FileText, LayoutDashboard, LayoutGrid, + MapPin, Network, Package, PackageCheck, @@ -62,6 +63,7 @@ import FuelStatsPage from "./pages/fleet/FuelStatsPage"; import { MaintenancePage } from "./pages/fleet/MaintenancePage"; import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; import { FleetDashboard } from "./pages/fleet/FleetDashboard"; +import { TrackingPage } from "./pages/fleet/TrackingPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -211,6 +213,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, { label: "Fuel Purchases", href: "/dashboard/fuel-purchases", @@ -800,6 +808,14 @@ const App = () => { } /> + + + + } + /> ({ + lat: 9.0 + Math.random() * 0.5, + lng: 38.7 + Math.random() * 0.5, + speed: Math.floor(Math.random() * 120), + heading: Math.floor(Math.random() * 360), + lastUpdate: new Date(Date.now() - Math.random() * 300000).toLocaleTimeString(), +}); + +export function TrackingPage() { + const [selectedVehicleId, setSelectedVehicleId] = useState(null); + const [mapCenter] = useState({ lat: 9.0, lng: 38.8 }); + const mapZoom = 10; + + const { data: vehicles = [] } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + // Generate mock GPS data for each vehicle + const vehiclesWithGPS = useMemo(() => { + return (vehicles as Vehicle[]).map((v, idx) => ({ + ...v, + gps: generateMockGPS(idx), + })); + }, [vehicles]); + + const selectedVehicle = vehiclesWithGPS.find(v => v.id === selectedVehicleId); + const vehicleOptions = useMemo( + () => vehiclesWithGPS.map(v => ({ label: v.registrationNumber, value: v.id })), + [vehiclesWithGPS] + ); + + // Map dimensions + const mapWidth = 800; + const mapHeight = 500; + const pixelsPerLat = mapHeight / 0.6; + const pixelsPerLng = mapWidth / 0.6; + + const getMapCoords = (lat: number, lng: number) => ({ + x: ((lng - (mapCenter.lng - 0.3)) * pixelsPerLng), + y: ((mapCenter.lat + 0.3 - lat) * pixelsPerLat), + }); + + return ( + + + + + +
+ + Real-Time Vehicle Tracking + + + Monitor vehicle locations, speed, and status + +
+
+ + + {/* Map Section */} + + + + + Map View + + }> + {vehiclesWithGPS.filter(v => v.status === 'ACTIVE').length} Active + + + + + + + + {/* Grid background */} + + {/* Latitude lines */} + {[0, 1, 2, 3, 4, 5, 6].map(i => ( + + ))} + {/* Longitude lines */} + {[0, 1, 2, 3, 4, 5, 6].map(i => ( + + ))} + + + {/* Vehicle markers */} + {vehiclesWithGPS.map((vehicle) => { + const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng); + const isSelected = vehicle.id === selectedVehicleId; + + return ( + setSelectedVehicleId(vehicle.id)} + title={vehicle.registrationNumber} + > + + + + + ); + })} + + {/* Map labels */} + + + 📍 Addis Ababa, Ethiopia + + + + + + + + {/* Sidebar */} + + + {/* Vehicle Selector */} + + + qtyField.onChange(e.target.value)} - onBlur={qtyField.onBlur} - type="number" - min={1} - className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" - /> - - - {fieldState.error?.message && ( - - {fieldState.error.message} + render={({ field: qtyField, fieldState }) => { + // Keep the per-line hazmat/reefer quantities within the new + // line quantity whenever it drops, so an old larger value + // can't outlive a shrink. + const setQty = (next: number) => { + const n = Math.max(1, next); + qtyField.onChange(n.toString()); + clampDependentQty(index, n); + }; + return ( +
+ + Quantity * - )} -
- )} +
+ + { + qtyField.onChange(e.target.value); + const n = Number(e.target.value); + if (!Number.isNaN(n) && n >= 1) + clampDependentQty(index, n); + }} + onBlur={qtyField.onBlur} + type="number" + min={1} + className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" + /> + +
+ {fieldState.error?.message && ( + + {fieldState.error.message} + + )} + + ); + }} /> + + {/* Per-container hazmat / reefer. Each switch reveals a bounded + "how many of this line" input (1..line quantity). */} +
+ ( + } + iconBg="#FBEAE7" + iconColor="#C0392B" + title="Hazardous" + description="Some of these containers carry hazardous cargo." + checked={!!hazField.value} + onChange={(v) => { + hazField.onChange(v); + form.setValue( + `containers.${index}.hazardousQty`, + v ? defaultLineQty(index) : "0", + { shouldDirty: true, shouldValidate: true }, + ); + }} + > + ( + + hq.onChange( + clampToLine(e.currentTarget.value, index), + ) + } + onBlur={hq.onBlur} + error={fieldState.error?.message} + radius="md" + /> + )} + /> + + )} + /> + ( + } + iconBg="#E9F0F8" + iconColor="#2E5B96" + title="Refrigerated" + description="Some of these containers need reefer transport." + checked={!!reeField.value} + onChange={(v) => { + reeField.onChange(v); + form.setValue( + `containers.${index}.reeferQty`, + v ? defaultLineQty(index) : "0", + { shouldDirty: true, shouldValidate: true }, + ); + }} + > + ( + + rq.onChange( + clampToLine(e.currentTarget.value, index), + ) + } + onBlur={rq.onBlur} + error={fieldState.error?.message} + radius="md" + /> + )} + /> + + )} + /> +
))} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index f658b84cd..a789943c2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -204,6 +204,12 @@ export function Step8Review({ // Bulk PER_ITEM cargo is a whole item count, not tons — label it accordingly. const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM"; + const bulkUnit = isPerItem ? "items" : "tons"; + const bulkUnitAmount = (raw?: string) => { + const n = Number(raw ?? 0); + if (Number.isNaN(n)) return "0"; + return isPerItem ? String(Math.round(n)) : n.toFixed(1); + }; const totalQuantityRow = isPerItem ? { label: "Total quantity", @@ -306,7 +312,16 @@ export function Step8Review({ c.isHazardous) && "Hazardous", + values.containers.some((c) => c.isReefer) && "Refrigerated", + ] + : [ + values.isHazardous && "Hazardous", + values.isRefrigerated && "Refrigerated", + ] + ) .filter(Boolean) .join(", ") || "None" } @@ -377,12 +392,26 @@ export function Step8Review({ value={totalQuantityRow.value} /> )} + {values.cargoType === "bulk" && values.isHazardous && ( + + )} + {values.cargoType === "bulk" && values.isRefrigerated && ( + + )} {values.cargoType === "container" && values.containers.length > 0 && ( Type Qty + Hazardous + Refrigerated @@ -392,6 +421,12 @@ export function Step8Review({ {c.containerType || c.type} {c.qty} + + {c.isHazardous ? `${c.hazardousQty} of ${c.qty}` : "—"} + + + {c.isReefer ? `${c.reeferQty} of ${c.qty}` : "—"} + ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx deleted file mode 100644 index 66cd94ac8..000000000 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Alert, - Box, - Button, - Group, - Loader, - Paper, - Stack, - Text, -} from "@mantine/core"; -import { SmartFileInput } from "@edr/ui-common"; -import { - AlertCircle, - ArrowLeft, - CheckCircle2, - Download, - FileText, - Send, -} from "lucide-react"; - -import type { Freight } from "@edr/types"; -import useAuth from "@/hooks/useAuth"; -import { api } from "@/services/api"; -import { fileViewUrl } from "@/constants/apiConfig"; -import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs"; -import { BORDER, GREEN, INK } from "./contract-ui"; - -type DocumentsValue = Record; -type ContractFile = NonNullable[number]; - -/** Onboarding document setting code for the company's nationality. */ -function documentSettingCode(nationality: string | null | undefined): string { - return nationality === "foreign" - ? "company_onboarding_documents_foreign" - : "company_onboarding_documents_ethiopian"; -} - -function hasFile(value: File | File[] | null | undefined): boolean { - if (!value) return false; - return Array.isArray(value) ? value.length > 0 : true; -} - -/** One row per distinct doc code already on the contract (latest upload). */ -function dedupeLatestByCode(files: ContractFile[]): ContractFile[] { - const order: string[] = []; - const latest = new Map(); - for (const f of files) { - if (f.code === "contract" || f.code.startsWith("signature_")) continue; - if (!latest.has(f.code)) order.push(f.code); - latest.set(f.code, f); - } - return order.map((c) => latest.get(c)!); -} - -/** - * Edit-and-resubmit view for a contract staff returned with CHANGES_REQUESTED. - * The customer reviews the request, updates their documents (business license, - * TIN, national ID, passport, … — driven by the company onboarding setting), - * then resubmits. Documents already on the contract are shown as "on file". - */ -export function ContractChangesRequestedView({ - contract, -}: { - contract: Freight.IContract; -}) { - const navigate = useNavigate(); - const auth = useAuth(); - const queryClient = useQueryClient(); - - const nationality = auth.company?.company?.nationality as - | string - | null - | undefined; - const settingQuery = useQuery( - api.fileUploadSettings.getByCode.queryOptions({ - input: { code: documentSettingCode(nationality) }, - }), - ); - - const files = contract.files ?? []; - const onFile = useMemo(() => dedupeLatestByCode(files), [files]); - const existingCodes = useMemo(() => new Set(files.map((f) => f.code)), [files]); - - const [documents, setDocuments] = useState({}); - const [showErrors, setShowErrors] = useState(false); - const [error, setError] = useState(""); - - const fields = settingQuery.data?.fields ?? []; - const missingRequiredKeys = useMemo( - () => - fields - .filter((f) => f.isRequired) - .filter( - (f) => - !existingCodes.has(f.fileKey) && !hasFile(documents[f.fileKey]), - ) - .map((f) => f.fileKey), - [fields, existingCodes, documents], - ); - - const updateMutation = useMutation({ - mutationFn: (docs: DocumentsValue) => - api.contracts.update.call({ id: contract.id, dto: {}, documents: docs }), - }); - const submitMutation = useMutation({ - mutationFn: () => api.contracts.submit.call({ id: contract.id }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: api.contracts.get.queryKey({ id: contract.id }), - }); - queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() }); - navigate(`/contracts/${contract.id}`); - }, - }); - - const isBusy = - settingQuery.isLoading || - updateMutation.isPending || - submitMutation.isPending; - - const resubmit = () => { - if (missingRequiredKeys.length > 0) { - setShowErrors(true); - setError("Please attach all required documents before resubmitting."); - return; - } - setShowErrors(false); - setError(""); - - const docs: DocumentsValue = {}; - for (const [k, v] of Object.entries(documents)) if (hasFile(v)) docs[k] = v; - - if (Object.keys(docs).length > 0) { - updateMutation.mutate(docs, { onSuccess: () => submitMutation.mutate() }); - } else { - submitMutation.mutate(); - } - }; - - const fieldErrors = showErrors - ? Object.fromEntries(missingRequiredKeys.map((k) => [k, "Required"])) - : {}; - - return ( - - - - -
- - {contract.reference} - - - Changes requested — update your documents and resubmit. - -
-
- - } - title="A reviewer asked for changes" - > - Update the documents below — replace anything that needs to change and - attach any required document that isn't on file yet — then resubmit the - contract for review. - - - - {onFile.length > 0 && ( - - - Already on file - - {onFile.map((file) => ( - - - - - - - {labelForDocCode(file.code)} - - - {file.name} - - - - - - - On file - - - - - - ))} - - )} - - - Update documents - - - Replace any document you need to change. Documents marked required - must be on file before you can resubmit. - - - {settingQuery.isLoading ? ( - - - - ) : settingQuery.data ? ( - - ) : ( - - No document requirements are configured for your account. You can - resubmit using the documents already on file. - - )} - - {error && ( - } - mt="md" - > - {error} - - )} - {(updateMutation.isError || submitMutation.isError) && ( - } - mt="md" - > - Couldn't resubmit. Please try again. - - )} - - - -
-
- ); -} - -export default ContractChangesRequestedView; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index bd7e1bafe..03be22005 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -1,5 +1,10 @@ import { useEffect, useMemo, useState } from "react"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { + Navigate, + useNavigate, + useParams, + useSearchParams, +} from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { Badge, @@ -23,6 +28,7 @@ import { CheckCircle2, Download, Eye, + FileBadge, FileSignature, FileText, Flame, @@ -46,7 +52,6 @@ import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; import { labelForDocCode } from "@/pages/bookings/resubmit"; import { ContractClearancePanel } from "./ContractClearancePanel"; -import { ContractChangesRequestedView } from "./ContractChangesRequestedView"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { BORDER, @@ -72,14 +77,19 @@ const CLEARANCE_UPLOAD_STATUSES = [ type ContractFile = NonNullable[number]; +// Business-license document codes — surfaced as their own section so they stand +// out from the rest of the onboarding/profile set. +const BUSINESS_LICENSE_DOC_CODES = new Set([ + "business_license", + "commercial_license", + "investment_license", +]); + // Onboarding / company-profile document codes seeded in file-upload-settings. // These get attached to the contract at creation and belong under "Profile // documents" rather than the clearance set. const PROFILE_DOC_CODES = new Set([ "tin_certificate", - "commercial_license", - "business_license", - "investment_license", "national_id", "national_id_passport", "passport", @@ -98,18 +108,21 @@ interface DocGroup { * groups are dropped so the tab only renders sections that have files. */ function groupContractDocuments(files: ContractFile[]): DocGroup[] { + const businessLicense: ContractFile[] = []; const profile: ContractFile[] = []; const clearance: ContractFile[] = []; for (const f of files) { // The generated contract PDF lives in the contract list / home rows, not // here. Signature images are baked into that PDF — skip both. if (f.code === "contract" || f.code.startsWith("signature_")) continue; + else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f); else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f); else clearance.push(f); } return [ - { key: "profile", title: "Profile documents", files: profile }, { key: "clearance", title: "Clearance documents", files: clearance }, + { key: "businessLicense", title: "Business license", files: businessLicense }, + { key: "profile", title: "Profile documents", files: profile }, ].filter((g) => g.files.length > 0); } @@ -194,10 +207,11 @@ export default function ContractDetailPage() { ); } - // Staff returned the contract for changes — show the edit-and-resubmit view - // (update documents → resubmit) instead of the read-only detail. + // Staff returned the contract for changes — send the customer to the full edit + // wizard (edit any term + replace documents → resubmit) rather than the + // read-only detail. if (contract.status === "CHANGES_REQUESTED") { - return ; + return ; } const isContainer = contract.freightType === "CONTAINER"; @@ -980,12 +994,14 @@ const KEY_FACT_ACCENT: Record = { // Per-section accent + icon for the Documents tab groups. const DOC_GROUP_ACCENT: Record = { - profile: "#2B6CB0", clearance: "#C77F09", + businessLicense: "#0A6F4D", + profile: "#2B6CB0", }; const DOC_GROUP_ICON: Record = { - profile: FileText, clearance: Upload, + businessLicense: FileBadge, + profile: FileText, }; /** diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 5a623e296..0456bca3e 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -28,9 +28,14 @@ import { Upload, XCircle, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; -import { Navigate, useLocation, useNavigate } from "react-router-dom"; +import { + Navigate, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import { CONTRACT_STEPS, @@ -47,6 +52,12 @@ import { operationToProfileType, operationToTradeDirection, } from "./new-contract-form/helpers"; +import { contractToFormValues } from "./new-contract-form/contractToForm"; +import { + ContractDocsEditor, + documentSettingCode, + missingRequiredDocKeys, +} from "./new-contract-form/ContractDocsEditor"; import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; import type { ProfileTypeValue } from "@/services/companies.service"; import { StepIndicator } from "./new-contract-form/StepIndicator"; @@ -55,7 +66,6 @@ import { useContractDraft, } from "./new-contract-form/useContractDraft"; import { - Step0OperationType, Step1ContractType, Step2ServiceType, Step3CargoScope, @@ -67,14 +77,42 @@ import { formatRateUnit } from "./new-contract-form/unit-rates"; type PriceModalMode = "submit" | "draft"; -export default function NewContractPage() { +/** + * The contract wizard, used both to create a new contract and — in `edit` mode — + * to edit & resubmit a contract staff returned with CHANGES_REQUESTED. Edit mode + * hydrates the form from the saved contract, lets the customer change any term + * and replace documents, then runs the same update → price → submit flow. + */ +export default function NewContractPage({ + mode = "create", +}: { + mode?: "create" | "edit"; +}) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { id: editId } = useParams<{ id: string }>(); + const isEdit = mode === "edit" && Boolean(editId); const [step, setStep] = useState(0); const auth = useAuth(); const { data: referenceData, isLoading: refDataLoading } = useQuery( api.bookings.referenceData.queryOptions(), ); + const { data: editContract } = useQuery({ + ...api.contracts.get.queryOptions({ input: { id: editId ?? "" } }), + enabled: isEdit, + }); + // Onboarding document requirements — used in edit mode to block resubmit until + // every required document is on file (existing or freshly attached). + const editDocSettingQuery = useQuery({ + ...api.fileUploadSettings.getByCode.queryOptions({ + input: { + code: documentSettingCode( + auth.company?.company?.nationality as string | null | undefined, + ), + }, + }), + enabled: isEdit, + }); // Contract creation is gated on profile approval, same as bookings. if (!auth.isPending && auth.company && !auth.canBook) { @@ -105,7 +143,17 @@ export default function NewContractPage() { const [pricingData, setPricingData] = useState(null); - const [priceContractId, setPriceContractId] = useState(null); + // In edit mode the contract already exists, so seed its id — this makes + // persistAndPriceMutation take the UPDATE branch instead of creating anew. + const [priceContractId, setPriceContractId] = useState( + isEdit ? (editId ?? null) : null, + ); + // Documents freshly attached on the review step (edit mode only). Merged into + // the form's `documents` map before the contract is updated. + const [editDocuments, setEditDocuments] = useState< + Record + >({}); + const [showDocErrors, setShowDocErrors] = useState(false); const [priceModalMode, setPriceModalMode] = useState( null, ); @@ -205,7 +253,30 @@ export default function NewContractPage() { const location = useLocation(); const startFresh = (location.state as { fresh?: boolean } | null)?.fresh === true; - useContractDraft({ form, step, setStep, fresh: startFresh }); + useContractDraft({ + form, + step, + setStep, + fresh: startFresh, + enabled: !isEdit, + }); + + // Edit mode: hydrate the form from the saved contract once both the contract + // and the reference data (needed to rebuild the cargo-type path) have loaded. + const hydratedRef = useRef(false); + useEffect(() => { + if (!isEdit || hydratedRef.current) return; + if (!editContract || !referenceData) return; + hydratedRef.current = true; + const forwarderProfile = + (auth.company?.company?.companyProfiles ?? []).find( + (p) => p.id === editContract.companyProfileId, + )?.type === "freight_forwarder"; + form.reset( + contractToFormValues(editContract, referenceData, forwarderProfile), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEdit, editContract, referenceData]); const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); @@ -239,19 +310,43 @@ export default function NewContractPage() { ); }, [originYard, destinationYard, operationType, referenceData]); + // type -> status ("active" = approved | "pending" = awaiting staff approval) + // for the company's operational profiles. Drives both the select-time gate and + // the per-option dropdown badges. + const profileStatusByType = useMemo(() => { + const m = new Map(); + for (const p of auth.company?.company?.companyProfiles ?? []) + m.set(p.type, p.status); + return m; + }, [auth.company]); const profileTypes = useMemo( - () => (auth.company?.company?.companyProfiles ?? []).map((p) => p.type), - [auth.company], + () => [...profileStatusByType.keys()], + [profileStatusByType], ); // All operation types are always selectable. Picking one the company has no // profile for prompts a license upload that creates the profile on the fly - // (mirrors the header "Add service" flow). + // (mirrors the header "Add service" flow); picking one backed by a not-yet- + // approved profile is blocked with an "awaiting approval" notice. const allowedOperations = useMemo( () => [...OPERATION_TYPES], [], ); + // Approval state of the profile each operation maps to — used for the dropdown + // badges. Intercity rides any customer profile, so always "approved". + const operationStatus = useMemo( + () => + (op: OperationType): "approved" | "pending" | "missing" => { + if (op === "intercity") return "approved"; + const target = operationToProfileType(op, profileTypes); + const status = profileStatusByType.get(target); + if (!status) return "missing"; + return status === "active" ? "approved" : "pending"; + }, + [profileStatusByType, profileTypes], + ); + // Create-profile modal state (license upload → createProfileAndSwitch). const [createTarget, setCreateTarget] = useState( null, @@ -260,6 +355,13 @@ export default function NewContractPage() { useState(null); const [licenseFiles, setLicenseFiles] = useState([]); const [createError, setCreateError] = useState(null); + // After a license is uploaded the new profile comes back "pending", so the + // create-profile modal switches to an "awaiting approval" success state. + const [licenseSubmitted, setLicenseSubmitted] = useState(false); + // Set when the user picks an operation whose profile exists but isn't approved + // yet — drives the "awaiting approval" block modal. + const [pendingApprovalProfile, setPendingApprovalProfile] = + useState(null); const createProfileMutation = useMutation({ mutationFn: async ({ @@ -275,11 +377,13 @@ export default function NewContractPage() { } }, onSuccess: () => { - // The operation was already set on the form when the modal opened. - setCreateTarget(null); - setPendingOperation(null); + // The new profile comes back "pending", so the user can't proceed under + // this operation yet: revert the select and switch the modal to its + // "awaiting approval" success state (kept open until the user dismisses). + form.setValue("operationType", undefined as never, { shouldDirty: true }); setLicenseFiles([]); setCreateError(null); + setLicenseSubmitted(true); }, onError: (err) => { setCreateError( @@ -292,15 +396,25 @@ export default function NewContractPage() { // Intercity (domestic) runs on any existing customer profile — no switch. if (op === "intercity") return; const target = operationToProfileType(op, profileTypes) as ProfileTypeValue; - const hasProfile = profileTypes.includes(target); - if (!hasProfile) { - // No matching profile — collect a license and create one. + const status = profileStatusByType.get(target); + + if (!status) { + // Case 3 — no matching profile: collect a license and create one. setPendingOperation(op); setCreateTarget(target); setLicenseFiles([]); setCreateError(null); + setLicenseSubmitted(false); return; } + if (status !== "active") { + // Case 2 — profile exists but isn't approved yet: block + revert the + // select so an unusable operation is never left chosen. + setPendingApprovalProfile(target); + form.setValue("operationType", undefined as never, { shouldDirty: true }); + return; + } + // Case 1 — approved: proceed, switching the active profile if needed. if (auth.activeProfileType !== target) { void auth.switchMode(target as never); } @@ -324,6 +438,17 @@ export default function NewContractPage() { setPendingOperation(null); setLicenseFiles([]); setCreateError(null); + setLicenseSubmitted(false); + }; + + // Dismiss the post-submit "awaiting approval" success state. The select was + // already reverted on success — just close and reset the modal. + const handleCreateProfileDone = () => { + setCreateTarget(null); + setPendingOperation(null); + setLicenseFiles([]); + setCreateError(null); + setLicenseSubmitted(false); }; const createTargetLabel = createTarget @@ -466,6 +591,25 @@ export default function NewContractPage() { const handleSubmitContract = form.handleSubmit((data) => { try { + // Edit mode: all required documents must be on file (already uploaded or + // freshly attached) before resubmitting, and freshly attached files are + // merged into the form's documents map so the update sends them. + if (isEdit && editContract) { + const missing = missingRequiredDocKeys( + editDocSettingQuery.data, + editContract, + editDocuments, + ); + if (missing.length > 0) { + setShowDocErrors(true); + return; + } + setShowDocErrors(false); + form.setValue("documents", { + ...(form.getValues("documents") ?? {}), + ...editDocuments, + }); + } const apiPayload = buildApiPayload(data); persistAndPriceMutation.mutate({ payload: apiPayload, @@ -511,20 +655,23 @@ export default function NewContractPage() { > - New Contract + {isEdit ? "Edit Contract" : "New Contract"} - Define your freight contract — scope, routes, and unit rates. Book - shipments against it after signing. + {isEdit + ? "Update your contract details and documents, then resubmit it for EDR staff review." + : "Define your freight contract — scope, routes, and unit rates. Book shipments against it after signing."} @@ -535,6 +682,20 @@ export default function NewContractPage() { onSubmit={(e) => e.preventDefault()} > + {isEdit && ( + } + title="A reviewer asked for changes" + mb="lg" + > + Update any contract detail or document that needs to change, then + resubmit the contract for review. + + )} + @@ -565,12 +726,13 @@ export default function NewContractPage() { description="Define the operation, contract kind, and the service this contract is for." /> - - @@ -625,6 +787,27 @@ export default function NewContractPage() { persistAndPriceMutation.isPending && persistAndPriceMutation.variables?.mode === "submit" } + isEdit={isEdit} + documentsEditor={ + isEdit && editContract ? ( + [k, "Required"]), + ) + : {} + } + /> + ) : undefined + } /> )} @@ -891,47 +1074,116 @@ export default function NewContractPage() { {/* Create-profile modal — opens when the chosen operation type has no matching company profile yet. Collects a license, creates the profile, - and switches to it (mirrors the header "Add service" flow). */} + then shows an "awaiting approval" state (the new profile is pending). */} { - if (!createProfileMutation.isPending) handleCreateProfileCancel(); + if (createProfileMutation.isPending) return; + if (licenseSubmitted) handleCreateProfileDone(); + else handleCreateProfileCancel(); }} - title={`Set up your ${createTargetLabel} profile`} + title={ + licenseSubmitted + ? "Awaiting approval" + : `Set up your ${createTargetLabel} profile` + } + centered + radius="lg" + > + {licenseSubmitted ? ( + + + + + + + License submitted. Your {createTargetLabel.toLowerCase()} profile + is now awaiting staff approval. We'll notify you once it's + approved — then you can create this contract as{" "} + {createTargetLabel.toLowerCase()}. + + + + + + + ) : ( + + + You don't have a {createTargetLabel.toLowerCase()} profile yet. Add + your business license to create one. It goes to staff for approval + before you can use it. + + } + placeholder="Select license file(s)" + value={licenseFiles} + onChange={(files) => setLicenseFiles(files ?? [])} + error={createError ?? undefined} + /> + + + + + + )} + + + {/* Awaiting-approval modal — the chosen operation maps to a profile that + exists but isn't approved yet. The select was already reverted. */} + setPendingApprovalProfile(null)} + title="Awaiting approval" centered radius="lg" > - You don't have a {createTargetLabel.toLowerCase()} profile yet. Add - your business license to create one and continue this contract as{" "} - {createTargetLabel.toLowerCase()}. + Your{" "} + {pendingApprovalProfile + ? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ?? + pendingApprovalProfile) + : ""}{" "} + profile was submitted and is under staff review. You can start a + contract under it once it's approved. - } - placeholder="Select license file(s)" - value={licenseFiles} - onChange={(files) => setLicenseFiles(files ?? [])} - error={createError ?? undefined} - /> - diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx new file mode 100644 index 000000000..e491cd88a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx @@ -0,0 +1,192 @@ +import { useMemo } from "react"; +import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core"; +import { SmartFileInput } from "@edr/ui-common"; +import { CheckCircle2, Download, FileText } from "lucide-react"; + +import type { Freight } from "@edr/types"; +import { useQuery } from "@tanstack/react-query"; +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; +import { fileViewUrl } from "@/constants/apiConfig"; +import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs"; +import { BORDER, GREEN, INK } from "../contract-ui"; + +type DocumentsValue = Record; +type ContractFile = NonNullable[number]; + +/** Onboarding document setting code for the company's nationality. */ +function documentSettingCode(nationality: string | null | undefined): string { + return nationality === "foreign" + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; +} + +function hasFile(value: File | File[] | null | undefined): boolean { + if (!value) return false; + return Array.isArray(value) ? value.length > 0 : true; +} + +/** One row per distinct doc code already on the contract (latest upload). */ +function dedupeLatestByCode(files: ContractFile[]): ContractFile[] { + const order: string[] = []; + const latest = new Map(); + for (const f of files) { + if (f.code === "contract" || f.code.startsWith("signature_")) continue; + if (!latest.has(f.code)) order.push(f.code); + latest.set(f.code, f); + } + return order.map((c) => latest.get(c)!); +} + +/** + * Document replace/upload block for an existing contract. Lists the documents + * already on file (latest upload per code) and renders the onboarding-driven + * `SmartFileInput` so the customer can replace any of them or attach any + * required document that isn't on file yet. Used on the contract wizard's review + * step when editing a CHANGES_REQUESTED contract. + * + * The keys returned through `onChange` are doc-setting field keys; the parent + * wizard merges them into the form's `documents` map, which is uploaded with the + * contract update. + */ +export function ContractDocsEditor({ + contract, + value, + onChange, + errors, +}: { + contract: Freight.IContract; + value: DocumentsValue; + onChange: (next: DocumentsValue) => void; + errors?: Record; +}) { + const auth = useAuth(); + + const nationality = auth.company?.company?.nationality as + | string + | null + | undefined; + const settingQuery = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: documentSettingCode(nationality) }, + }), + ); + + const files = contract.files ?? []; + const onFile = useMemo(() => dedupeLatestByCode(files), [files]); + + return ( + + {onFile.length > 0 && ( + + + Already on file + + {onFile.map((file) => ( + + + + + + + {labelForDocCode(file.code)} + + + {file.name} + + + + + + + On file + + + + + + ))} + + )} + + + + Update documents + + + Replace any document you need to change. Documents marked required must + be on file before you can resubmit. + + + {settingQuery.isLoading ? ( + + + + ) : settingQuery.data ? ( + + ) : ( + + No document requirements are configured for your account. You can + resubmit using the documents already on file. + + )} + + + ); +} + +/** + * Keys of the documents the onboarding setting marks required that are neither + * already on the contract nor freshly attached in `documents`. Empty means the + * customer may resubmit. + */ +export function missingRequiredDocKeys( + setting: Freight.IFileUploadSetting | undefined, + contract: Freight.IContract, + documents: DocumentsValue, +): string[] { + const fields = setting?.fields ?? []; + const existingCodes = new Set((contract.files ?? []).map((f) => f.code)); + return fields + .filter((f) => f.isRequired) + .filter( + (f) => !existingCodes.has(f.fileKey) && !hasFile(documents[f.fileKey]), + ) + .map((f) => f.fileKey); +} + +export { documentSettingCode, hasFile, dedupeLatestByCode }; +export default ContractDocsEditor; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts new file mode 100644 index 000000000..f3d66de3d --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts @@ -0,0 +1,147 @@ +import type { Freight } from "@edr/types"; +import type { ContractFormInputValues, OperationType } from "./schema"; +import { initialContractFormValues } from "./schema"; + +/** + * Trade direction → base operation type. The freight-forwarder variants + * (import_ff / export_ff) are selected by the caller when the contract is + * stamped to a freight_forwarder profile (see `isForwarderProfile`). + */ +function directionToOperation( + direction: Freight.ContractTradeDirection, + isForwarderProfile: boolean, +): OperationType { + if (direction === "IMPORT") return isForwarderProfile ? "import_ff" : "import"; + if (direction === "EXPORT") return isForwarderProfile ? "export_ff" : "export"; + return "intercity"; +} + +/** ISO timestamp → `YYYY-MM-DD` for the native date input. "" when absent. */ +function isoToDateInput(iso: string | null | undefined): string { + if (!iso) return ""; + return iso.slice(0, 10); +} + +/** + * Rebuild the cargo-type path `[groupId, commodityId]` from a flat cargoTypeId + * by locating which reference cargo-type group owns it. + */ +function cargoTypePathFor( + cargoTypeId: string | null | undefined, + referenceData: Freight.BookingReferenceData | undefined, +): string[] { + if (!cargoTypeId || !referenceData?.cargo_type) return []; + for (const group of referenceData.cargo_type) { + if (group.children?.some((c) => c.id === cargoTypeId)) { + return [group.id, cargoTypeId]; + } + } + return []; +} + +/** + * Inverse of `buildApiPayload` in NewContractPage: hydrate the contract wizard + * form from a saved contract so a customer can edit a CHANGES_REQUESTED (or + * DRAFT) contract in the same UI used to create it. Missing fields fall back to + * `initialContractFormValues`. Files are not mapped — the wizard's `documents` + * map only holds freshly attached replacements; existing files are listed + * separately by `ContractDocsEditor`. + */ +export function contractToFormValues( + contract: Freight.IContract, + referenceData: Freight.BookingReferenceData | undefined, + isForwarderProfile: boolean, +): Partial { + const isContainer = contract.freightType === "CONTAINER"; + const isGeneral = contract.contractKind === "GENERAL"; + + const routes = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + ); + const primaryRoute = routes[0]; + const extraRoutes = isGeneral + ? routes.slice(1).map((r) => ({ + originYard: r.originYardId, + destinationYard: r.destinationYardId, + })) + : []; + + const scope = contract.cargoScope ?? []; + + // Container scope: one row per enabled size, with per-size caps for GENERAL. + const enabledContainerSizes = isContainer + ? scope + .map((s) => s.containerSize) + .filter((s): s is string => Boolean(s)) + : []; + const containerSizeCaps: Record = {}; + if (isContainer && isGeneral) { + for (const s of scope) { + if (s.containerSize && s.quantityCap != null) { + containerSizeCaps[s.containerSize] = s.quantityCap; + } + } + } + + // Bulk scope: a single commodity row. + const bulkRow = !isContainer ? scope[0] : undefined; + const cargoTypePath = bulkRow + ? cargoTypePathFor(bulkRow.cargoTypeId, referenceData) + : []; + + const hasFirstMile = Boolean(contract.firstMilePickupAddress); + const hasLastMile = Boolean(contract.lastMileDeliveryAddress); + + return { + ...initialContractFormValues, + + operationType: directionToOperation( + contract.tradeDirection, + isForwarderProfile, + ), + contractKind: isGeneral ? "general_contract" : "one_time", + contractType: contract.renewalOfId ? "renewal" : "new", + previousContractRef: contract.renewalOfId ?? "", + + serviceTypeId: contract.serviceTypeId, + paymentCurrency: + contract.paymentCurrency === "ETB" ? "ETB" : "USD", + + firstMile: { + enabled: hasFirstMile, + pickUpAddress: contract.firstMilePickupAddress ?? "", + exactLocation: "", + lat: contract.firstMilePickupLat ?? null, + lng: contract.firstMilePickupLng ?? null, + }, + lastMile: { + enabled: hasLastMile, + deliveryAddress: contract.lastMileDeliveryAddress ?? "", + exactLocation: "", + lat: contract.lastMileDeliveryLat ?? null, + lng: contract.lastMileDeliveryLng ?? null, + }, + customsClearingEnabled: contract.customsClearingEnabled, + customsClearingAgent: contract.customsClearingAgent ?? "", + + cargoType: isContainer ? "container" : "bulk", + enabledContainerSizes: + enabledContainerSizes as ContractFormInputValues["enabledContainerSizes"], + containerSizeCaps, + cargoTypePath, + cargoFreeText: bulkRow?.cargoFreeText ?? "", + bulkQuantityCap: + isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0, + isHazardous: contract.isHazardous, + isRefrigerated: contract.isReefer, + + originYard: primaryRoute?.originYardId ?? "", + destinationYard: primaryRoute?.destinationYardId ?? "", + extraRoutes, + estimatedShipmentDate: isoToDateInput(contract.estimatedShipmentDate), + + documents: {}, + }; +} + +export default contractToFormValues; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index 7ffb38e97..c86e17fc5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -3,11 +3,13 @@ import type { Freight } from "@edr/types"; import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; -import { Select, Stack, Text } from "@mantine/core"; +import { Badge, Group, Select, Stack, Text } from "@mantine/core"; import { CONTRACT_KIND_OPTIONS, ContractFormInputValues, + OPERATION_TYPE_OPTIONS, type ContractFormValues, + type OperationType, } from "./schema"; import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared"; @@ -31,11 +33,22 @@ interface PreviousContractOption { export function Step1ContractType({ form, referenceData, + allowedOperations, + onOperationSelect, + operationStatus, }: { form: ContractForm; referenceData?: Freight.BookingReferenceData; + allowedOperations: OperationType[]; + onOperationSelect?: (op: OperationType) => void; + /** Approval state of the profile each operation maps to (for the badges). */ + operationStatus?: (op: OperationType) => "approved" | "pending" | "missing"; }) { const contractType = form.watch("contractType"); + + const operationData = OPERATION_TYPE_OPTIONS.filter((opt) => + allowedOperations.includes(opt.value), + ).map((opt) => ({ value: opt.value, label: opt.label })); const previousContractRef = form.watch("previousContractRef"); const [searchQuery, setSearchQuery] = useState(""); @@ -167,38 +180,81 @@ export function Step1ContractType({ return ( -
+ {allowedOperations.length === 0 && ( + + Your company has no operational profile yet. Complete onboarding to + register as an importer, exporter, or freight forwarder. + + )} + + {/* Operation Type + Contract Kind + New/Renewal on one wrapping row. Each + field keeps a sensible min width and flexes to fill / wrap below on + narrow screens. */} +
+ ( + ({ - value: o.value, - label: o.label, - }))} - value={field.value ?? "one_time"} - onChange={(v) => field.onChange(v ?? "one_time")} - allowDeselect={false} - radius={10} - checkIconPosition="right" - comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }} - styles={fieldStyles} - /> - {selected && ( - - {selected.description} - - )} -
- ); - }} + render={({ field }) => ( + ) : ( -
+
{services.map((s) => { const selected = s.id === value; return ( @@ -147,8 +147,8 @@ function ServiceTypeSelector({ position: "relative", textAlign: "left", cursor: "pointer", - padding: 16, - borderRadius: 16, + padding: 12, + borderRadius: 14, border: `1.5px solid ${selected ? GREEN : error ? "#F0B4B4" : BORDER}`, background: selected ? GREEN_SOFT : "#fff", boxShadow: selected @@ -162,10 +162,10 @@ function ServiceTypeSelector({ - {selected ? : null} + {selected ? : null} - + - + - - + + {s.serviceName} - {s.description ? ( - - {s.description} - - ) : null} - {serviceFeatures(s).map((f) => ( Trucking & customs options + {/* First mile, last mile and customs sit side by side on one wrapping + row; each keeps a min width and stacks below on narrow screens. */} +
{includesFirstMile && ( )} +
)} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index 7fbf45d71..a5fbbc989 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -188,6 +188,8 @@ export function Step8Review({ onSubmit, saveDraftPending = false, submitPending = false, + documentsEditor, + isEdit = false, }: { form: ContractForm; /** Retained for caller compatibility; the review page is read-only. */ @@ -201,6 +203,14 @@ export function Step8Review({ onSubmit?: () => void; saveDraftPending?: boolean; submitPending?: boolean; + /** + * Document replace/upload block, shown only when editing an existing contract + * (resubmit after CHANGES_REQUESTED). Omitted for fresh creation, where docs + * come from the company profile automatically. + */ + documentsEditor?: React.ReactNode; + /** Editing an existing contract (resubmit) rather than creating a new one. */ + isEdit?: boolean; }) { const values = form.watch(); const serviceType = referenceData?.service.find( @@ -429,6 +439,17 @@ export function Step8Review({ /> )} + {documentsEditor && ( + + {documentsEditor} + + )} + - {pricing - ? "Review your unit-rate quotation. Approve to submit the contract for EDR staff review." - : "Ready to submit. You'll review the unit-rate quotation before final submission."} + {isEdit + ? pricing + ? "Review your updated unit-rate quotation. Approve to resubmit the contract for EDR staff review." + : "Ready to resubmit. You'll review the unit-rate quotation before final resubmission." + : pricing + ? "Review your unit-rate quotation. Approve to submit the contract for EDR staff review." + : "Ready to submit. You'll review the unit-rate quotation before final submission."} - + {!isEdit && ( + + )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx index e40b33a4f..0226ccadd 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx @@ -1,4 +1,3 @@ -export { Step0OperationType } from "./step0-operation-type"; export { Step1ContractType } from "./step1-contract-type"; export { Step2ServiceType } from "./step2-service-type"; export { Step3CargoScope } from "./step3-cargo-scope"; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts index 7c44b7382..a94e77168 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts @@ -58,14 +58,22 @@ export function useContractDraft({ step, setStep, fresh, + enabled = true, }: { form: ContractForm; step: number; setStep: (step: number) => void; fresh: boolean; + /** + * When false the draft is neither restored nor persisted — used in edit mode, + * where the server contract is the source of truth and the localStorage key is + * shared with the create flow. + */ + enabled?: boolean; }): { clearDraft: () => void } { const restoredRef = useRef(false); useEffect(() => { + if (!enabled) return; if (restoredRef.current) return; restoredRef.current = true; @@ -107,6 +115,7 @@ export function useContractDraft({ }; useEffect(() => { + if (!enabled) return; if (!restoredRef.current) return; const sub = form.watch(() => { if (timerRef.current) clearTimeout(timerRef.current); @@ -117,9 +126,10 @@ export function useContractDraft({ if (timerRef.current) clearTimeout(timerRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [form, enabled]); useEffect(() => { + if (!enabled) return; if (!restoredRef.current) return; write(); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index dbfcf655d..100b1a20a 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -432,6 +432,10 @@ export interface IBooking extends BaseEntity { isHazardous: boolean; isRefrigerated: boolean; + /** Bulk-only hazardous amount in the cargo's unit (tons/items); 0 otherwise. */ + bulkHazardousQuantity?: number; + /** Bulk-only refrigerated amount in the cargo's unit (tons/items); 0 otherwise. */ + bulkReeferQuantity?: number; tradeDirection: "IMPORT" | "EXPORT"; paymentCurrency: string; @@ -717,6 +721,10 @@ export interface CreateBookingContainerDto { containerTypeId: string; quantity: number; vgmPerUnitTons: number; + /** How many of this line's containers are hazardous (0..quantity). */ + hazardousQuantity?: number; + /** How many of this line's containers are refrigerated (0..quantity). */ + reeferQuantity?: number; } /** A contracted route+quantity line for a GENERAL contract. */ @@ -768,6 +776,10 @@ export interface CreateBookingDto { isHazardous?: boolean | undefined; /** Booking-level refrigerated flag (bulk freight only; containers derive reefer from the container type). */ isReefer?: boolean | undefined; + /** Bulk-only: hazardous amount in the cargo's unit (tons or items), <= cargoTotalWeightVgm. */ + bulkHazardousQuantity?: number | undefined; + /** Bulk-only: refrigerated amount in the cargo's unit (tons or items), <= cargoTotalWeightVgm. */ + bulkReeferQuantity?: number | undefined; paymentCurrency: string; pnrCode?: string | undefined; startDate?: string | undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd8c81c83..203eb4d71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: '@mantine/core': specifier: ^9.3.0 version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': + specifier: ^9.3.0 + version: 9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': specifier: ^9.3.0 version: 9.3.0(react@19.2.6) @@ -12495,10 +12498,19 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 9.3.0(react@19.2.6) + clsx: 2.1.1 + dayjs: 1.11.21 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@mantine/dates@9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 9.3.0(react@19.2.6) clsx: 2.1.1 dayjs: 1.11.21 react: 19.2.6 @@ -15399,7 +15411,7 @@ snapshots: '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -20233,7 +20245,7 @@ snapshots: mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@tabler/icons-react': 3.44.0(react@19.2.6) '@tanstack/match-sorter-utils': 8.19.4 From 33d3aa9294aa01847d4b0fdba5f755eef6039747 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 30 Jun 2026 15:18:30 +0000 Subject: [PATCH 042/401] Enhance rule engine form to support currency formatting and suffix display --- .../src/components/ruleEngine/RuleEngineFormDialog.tsx | 8 ++++++++ .../src/components/ruleEngine/ruleEngineFormat.tsx | 9 +++++++++ .../backoffice/src/pages/ruleEngine/config/resources.ts | 7 +++++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index f60ac3045..6ae71cdc0 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -307,6 +307,14 @@ const RuleEngineFormDialog = ({ size="md" radius="md" styles={inputStyles} + rightSection={ + field.suffix ? ( + + {field.suffix} + + ) : undefined + } + rightSectionWidth={field.suffix ? 52 : undefined} /> ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx index 5251141a4..5ec7597b9 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx @@ -94,6 +94,15 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => return {Number.isNaN(num) ? String(value) : num.toLocaleString()}; } + if (format === "currency") { + const num = Number(value); + return ( + + {Number.isNaN(num) ? String(value) : `USD ${num.toLocaleString()}`} + + ); + } + if (format === "date") { const d = new Date(String(value)); if (Number.isNaN(d.getTime())) return {String(value)}; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 1740cfbe6..23b77f943 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -11,6 +11,7 @@ export type ColumnFormat = | "rateStatus" | "date" | "number" + | "currency" | "entityLabel" | "rateLabel"; @@ -36,6 +37,8 @@ export interface FormFieldDef { placeholder?: string; description?: string; disabled?: boolean; + /** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */ + suffix?: string; /** Hide this field when another field currently equals one of these values. */ hideWhen?: { field: string; equals: string[] }; /** @@ -402,7 +405,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ columns: [ { id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" }, { id: "trigger", header: "Trigger", accessorKey: "trigger" }, - { id: "rateValue", header: "Value", accessorKey: "rateValue", format: "number" }, + { id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" }, { id: "rateUnit", header: "Unit", accessorKey: "rateUnit" }, { id: "status", header: "Status", accessorKey: "status", format: "rateStatus" }, { id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" }, @@ -454,7 +457,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Select bulk commodity (optional)", showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] }, }, - { name: "rateValue", label: "Rate value", type: "number", required: true }, + { name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" }, { name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS }, { name: "effectiveFrom", label: "Effective from", type: "date", required: true }, { name: "effectiveTo", label: "Effective to", type: "date" }, From 22eedf05f3c58823a1c3210e0a6f2c0036d8d5a7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 15:24:36 +0000 Subject: [PATCH 043/401] fix --- .../backoffice/src/pages/fleet/format.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/format.ts diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/format.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/format.ts new file mode 100644 index 000000000..6193ca58c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/format.ts @@ -0,0 +1,16 @@ +/** Shared formatting helpers for the fleet-management pages. */ + +/** Format a number as Ethiopian Birr, e.g. 12345.6 → "ETB 12,346". */ +export function formatETB(amount: number, fractionDigits = 0): string { + const value = Number.isFinite(amount) ? amount : 0; + return `ETB ${value.toLocaleString("en-US", { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + })}`; +} + +/** Safe percentage of `part` over `total`, rounded, 0 when total is 0. */ +export function pct(part: number, total: number): number { + if (!total || !Number.isFinite(total) || !Number.isFinite(part)) return 0; + return Math.round((part / total) * 100); +} From 1c9efed1031fd4658ccab5b3815f059350935499 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 30 Jun 2026 20:06:43 +0300 Subject: [PATCH 044/401] fix: ( payment ) add ispublic to payment event consumer --- apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts | 4 ++++ .../src/modules/payments/payment-events.consumer.ts | 2 ++ .../payment-providers/src/providers/dmoney/dmoney.provider.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts index 43ddecddf..7a450bfaf 100644 --- a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts +++ b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts @@ -15,6 +15,10 @@ export class DynamicThrottlerGuard extends ThrottlerGuard { } async canActivate(context: ExecutionContext): Promise { + if (context.getType() !== 'http') { + return true; + } + const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] = await Promise.all([ this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT), diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index f13191c48..a80c63468 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_EXCHANGE, @@ -19,6 +20,7 @@ export class PaymentEventsConsumer { constructor(private readonly paymentsService: PaymentsService) {} + @IsPublic() @RabbitSubscribe({ exchange: PAYMENT_EVENTS_EXCHANGE, routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.* diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 32b41dbb1..15a2e3a62 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -61,6 +61,9 @@ export class DMoneyProvider implements PaymentProvider { ): Promise { const fabricToken = await this.applyFabricToken(); const requestBody = this.buildPreOrderRequest(input); + this.logger.log( + `D-Money preOrder send request merchOrderId=${input.merchantOrderId} body=${JSON.stringify(this.sanitize(requestBody))}`, + ); const response = await this.postJson( `${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`, requestBody, From 28c1089ce677cbfc5fc121b01b5a1f9dcbc1498c Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 1 Jul 2026 02:42:05 +0000 Subject: [PATCH 045/401] Remove estimated shipment date from contract forms and related components --- .../contract-document-view-model.builder.ts | 4 ++- .../modules/contracts/contracts.service.ts | 6 ---- .../contracts/dto/create-contract.dto.ts | 9 ------ .../pages/contracts/ContractDetailPage.tsx | 11 ------- .../src/pages/contracts/NewContractPage.tsx | 7 ----- .../new-contract-form/contractToForm.ts | 7 ----- .../contracts/new-contract-form/schema.ts | 15 +-------- .../new-contract-form/step4-route.tsx | 31 ++----------------- .../new-contract-form/step8-review.tsx | 15 --------- 9 files changed, 6 insertions(+), 99 deletions(-) diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index caf161614..05061ed9a 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -200,7 +200,9 @@ export class ContractDocumentViewModelBuilder { serviceType: this.valueOrDash( contract.serviceType?.serviceName ?? contract.serviceType?.code, ), - scheduledDate: this.formatDate(contract.estimatedShipmentDate), + // Estimated shipment date was removed from the contract wizard; the + // binding scheduled date is set per-booking, not on the contract. + scheduledDate: this.formatDate(null), contractType: this.valueOrDash(contract.contractType), cargoDescription: this.valueOrDash(cargoName), totalWeightVgm: '—', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 70d2632fa..8d864aac7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -200,9 +200,6 @@ export class ContractsService { lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, isHazardous: dto.isHazardous ?? false, isReefer: dto.isReefer ?? false, - estimatedShipmentDate: dto.estimatedShipmentDate - ? new Date(dto.estimatedShipmentDate) - : null, contractType: dto.contractType ?? null, status: 'DRAFT', clearanceStatus: 'NOT_APPLICABLE', @@ -347,9 +344,6 @@ export class ContractsService { lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng, contractType: dto.contractType ?? existing.contractType, }; - if (dto.estimatedShipmentDate) { - updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); - } if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null; // Customs clearing always mirrors the (possibly changed) service type. diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 70dba1e90..b20b99575 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -4,7 +4,6 @@ import { ArrayMinSize, IsArray, IsBoolean, - IsDateString, IsIn, IsNumber, IsOptional, @@ -219,14 +218,6 @@ export class CreateContractDto { @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; - @ApiPropertyOptional({ - description: 'Non-binding estimate from the wizard (NOT validated against departures)', - example: '2026-07-15T00:00:00.000Z', - }) - @IsOptional() - @IsDateString() - estimatedShipmentDate?: string; - @ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 03be22005..538c58fe7 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -449,17 +449,6 @@ export default function ContractDetailPage() { routes[0]?.destinationYard?.label ?? "—" }`} /> - } - value={ - contract.estimatedShipmentDate - ? new Date( - contract.estimatedShipmentDate, - ).toLocaleDateString() - : "—" - } - /> } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 0456bca3e..7821b0076 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -541,13 +541,6 @@ export default function NewContractPage({ isHazardous: data.isHazardous, // Reefer is a contract-level flag for both container and bulk. isReefer: data.isRefrigerated, - ...(data.estimatedShipmentDate - ? { - estimatedShipmentDate: new Date( - data.estimatedShipmentDate, - ).toISOString(), - } - : {}), ...(data.previousContractRef ? { renewalOfId: data.previousContractRef } : {}), diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts index f3d66de3d..8916ef67d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts @@ -16,12 +16,6 @@ function directionToOperation( return "intercity"; } -/** ISO timestamp → `YYYY-MM-DD` for the native date input. "" when absent. */ -function isoToDateInput(iso: string | null | undefined): string { - if (!iso) return ""; - return iso.slice(0, 10); -} - /** * Rebuild the cargo-type path `[groupId, commodityId]` from a flat cargoTypeId * by locating which reference cargo-type group owns it. @@ -138,7 +132,6 @@ export function contractToFormValues( originYard: primaryRoute?.originYardId ?? "", destinationYard: primaryRoute?.destinationYardId ?? "", extraRoutes, - estimatedShipmentDate: isoToDateInput(contract.estimatedShipmentDate), documents: {}, }; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index 015517f07..5152163d8 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -187,8 +187,6 @@ export const contractFormSchema = z }), ) .default([]), - // Non-binding estimate. The binding scheduled date is captured at booking. - estimatedShipmentDate: z.string().default(""), documents: z.record(z.string(), z.any()).default({}), notes: z.string().default(""), @@ -222,15 +220,6 @@ export const contractFormSchema = z }, ) .superRefine((data, ctx) => { - // Estimated shipment date is a planning value and is required for all - // contracts (doc §7 step 4). - if (!data.estimatedShipmentDate.trim()) { - ctx.addIssue({ - code: "custom", - path: ["estimatedShipmentDate"], - message: "Select an estimated shipment date.", - }); - } if (data.cargoType === "container") { // Container scope: at least one enabled size. if (data.enabledContainerSizes.length === 0) { @@ -287,7 +276,6 @@ export const initialContractFormValues: DeepPartial = { originYard: "", destinationYard: "", extraRoutes: [], - estimatedShipmentDate: "", documents: {}, notes: "", @@ -311,7 +299,7 @@ export const contractStepFields: Record< "firstMile", "lastMile", ], - // Step 1 — Cargo & Route: scope, sizes, flags, origin/destination, date. + // Step 1 — Cargo & Route: scope, sizes, flags, origin/destination. 1: [ "cargoType", "enabledContainerSizes", @@ -324,7 +312,6 @@ export const contractStepFields: Record< "originYard", "destinationYard", "extraRoutes", - "estimatedShipmentDate", ], // Step 2 — Review & Submit. (The separate Documents step was removed — the // company profile documents are attached to the contract automatically.) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx index d678dd318..1e9ead268 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx @@ -1,6 +1,6 @@ import type { Freight } from "@edr/types"; -import { Box, Button, Group, Skeleton, Stack, Text, TextInput } from "@mantine/core"; -import { CalendarDays, MapPin, Plus, Trash2 } from "lucide-react"; +import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; +import { MapPin, Plus, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo } from "react"; import { Controller, @@ -125,12 +125,6 @@ export function Step4Route({ const stationSelectDisabled = yardOptions.length === 0; - const todayISODate = useMemo(() => { - const now = new Date(); - const tz = now.getTimezoneOffset() * 60000; - return new Date(now.getTime() - tz).toISOString().slice(0, 10); - }, []); - return ( {isLoading ? ( @@ -177,27 +171,6 @@ export function Step4Route({
)} - {/* Estimated shipment date — a planning value for every contract. The - binding scheduled date is captured later at booking time. */} - - ( - } - error={fieldState.error?.message} - value={field.value ?? ""} - onChange={(e) => field.onChange(e.currentTarget.value)} - radius="md" - /> - )} - /> -
)} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index a5fbbc989..549d5d8be 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -9,9 +9,7 @@ import { Text, Textarea, } from "@mantine/core"; -import { format } from "date-fns"; import { - Calendar, CheckCircle2, Circle, ClipboardCheck, @@ -248,10 +246,6 @@ export function Step8Review({ referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ?? values.destinationYard; - const scheduleLabel = values.estimatedShipmentDate - ? format(new Date(values.estimatedShipmentDate), "EEEE, MMM d, yyyy") - : "—"; - const directionLabel = direction ? direction.charAt(0) + direction.slice(1).toLowerCase() : "—"; @@ -353,11 +347,6 @@ export function Step8Review({ : directionLabel } /> - } - label="Estimated shipment" - value={scheduleLabel} - /> } label="Cargo scope" @@ -485,10 +474,6 @@ export function Step8Review({ done={Boolean(values.originYard && values.destinationYard)} label="Route selected" /> - Date: Wed, 1 Jul 2026 07:27:53 +0300 Subject: [PATCH 046/401] Implement contract document download functionality and enhance clearance review status checks - Added methods to assert clearance reviewable, finalizable, and uploadable statuses in the ContractClearanceService. - Introduced a new endpoint in ContractsController for downloading contract PDFs. - Implemented download functionality in the contracts service for both backoffice and portal applications. - Updated UI components to include download buttons for contract PDFs in relevant pages. - Enhanced contract request and view pages to support contract document downloads. --- .../contracts/contract-clearance.service.ts | 87 +++++++--- .../contracts/contract-transition.service.ts | 8 + .../modules/contracts/contracts.controller.ts | 22 +++ apps/edr-freight-web/backoffice/src/App.tsx | 13 ++ .../detail/ClearanceReviewSection.tsx | 23 +-- .../ContractClearanceReviewSection.tsx | 24 +-- .../contracts/ContractSignSuccessModal.tsx | 43 +++++ .../src/components/layout/route-meta.ts | 7 + .../backoffice/src/constants/URLS.ts | 1 + .../ContractValidityPeriodsPage.tsx | 145 ++++++++++++++++ .../contracts/ContractClearanceDetailPage.tsx | 12 +- .../contracts/ContractRequestDetailPage.tsx | 70 ++++++++ .../src/pages/contracts/ContractViewPage.tsx | 45 ++++- .../src/services/contracts.service.ts | 7 + .../contracts/ContractSignSuccessModal.tsx | 43 +++++ .../portal/src/constants/URLS.ts | 1 + .../pages/contracts/ContractDetailPage.tsx | 29 ++++ .../src/pages/contracts/ContractViewPage.tsx | 161 +++++++++++++++--- .../src/pages/contracts/NewContractPage.tsx | 84 +++++---- .../contracts/new-contract-form/schema.ts | 20 +++ .../new-contract-form/step2-service-type.tsx | 49 +++--- .../new-contract-form/step3-cargo-scope.tsx | 7 - .../new-contract-form/step8-review.tsx | 10 ++ .../portal/src/services/contracts.service.ts | 7 + 24 files changed, 785 insertions(+), 133 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractSignSuccessModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/components/contracts/ContractSignSuccessModal.tsx diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 71cfa0580..a57ca275b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -154,6 +154,59 @@ export class ContractClearanceService { ); } + /** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */ + private assertClearanceReviewableStatus(contract: Contract): void { + const allowed = [ + 'CLEARANCE_UNDER_REVIEW', + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_READY_FOR_BOOKING', + ]; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot review clearance documents on status "${contract.status}".`, + ); + } + } + + /** Finalize when docs are under review or all approved after a partial query cycle. */ + private assertClearanceFinalizableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot finalize clearance on status "${contract.status}".`, + ); + } + } + + private assertClearanceOutputUploadableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot upload output documents on status "${contract.status}".`, + ); + } + } + + private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise { + const refreshed = await this.contractsService.findById(contractId); + const allApproved = await this.isClearanceFullyApproved(refreshed); + if ( + !allApproved || + (refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && + refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING') + ) { + return; + } + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + } + } + /** * Customer uploads clearance documents on the contract. When every required * input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET. @@ -294,19 +347,7 @@ export class ContractClearanceService { note?: string, ): Promise { const contract = await this.contractsService.findById(contractId); - // Reviewing is allowed both while the batch is UNDER_REVIEW and after it has - // dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips - // the contract to "awaiting" (the customer must re-upload), but the reviewer - // may still be working through the rest of the batch. Restricting to - // UNDER_REVIEW only would 409 every review after the first query. - if ( - contract.status !== 'CLEARANCE_UNDER_REVIEW' && - contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' - ) { - throw new ConflictException( - `Cannot review clearance documents on status "${contract.status}".`, - ); - } + this.assertClearanceReviewableStatus(contract); if (status === 'QUERIED' && !note?.trim()) { throw new BadRequestException('A note is required when querying a document'); } @@ -348,6 +389,8 @@ export class ContractClearanceService { if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } + } else if (status === 'APPROVED') { + await this.bumpToUnderReviewWhenFullyApproved(contractId); } return this.contractsService.findById(contractId); @@ -359,11 +402,7 @@ export class ContractClearanceService { files: Express.Multer.File[], ): Promise { const contract = await this.contractsService.findById(contractId); - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot upload output documents on status "${contract.status}".`, - ); - } + this.assertClearanceOutputUploadableStatus(contract); const { outputCode } = contractClearanceCodes(contract); if (!outputCode) { throw new BadRequestException('This contract has no customs output documents'); @@ -394,11 +433,7 @@ export class ContractClearanceService { 'Self-clearance (Path A) contracts are finalized by Operations, not GL.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { @@ -452,11 +487,7 @@ export class ContractClearanceService { 'Operations finalize applies only to self-clearance (non-customs) contracts.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 4fcbc9a6c..8bcce1250 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -345,6 +345,14 @@ export class ContractTransitionService { return { view, html, signatures: view.signatures }; } + /** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */ + async streamContractPdf(contractId: string) { + const contract = await this.contractsService.findById(contractId); + const { view } = await this.documentViewModelBuilder.build(contractId); + const record = await this.upsertContractPdf(contractId, contract.reference, view); + return this.filesService.streamById(record.id); + } + /** * Rebuild the stored `contract` PDF from the current aggregate (now including * the latest signatures) so the downloaded/viewed file matches the live HTML diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 0ef013347..100183e64 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -9,6 +9,7 @@ import { Patch, Post, Query, + Res, UnauthorizedException, UploadedFiles, UseInterceptors, @@ -16,6 +17,7 @@ import { import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; import { ApiBearerAuth, ApiBody, @@ -417,6 +419,26 @@ export class ContractsController { }; } + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download contract PDF' }) + async downloadContractDocument( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + const contract = await this.contractsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + const { stream, record } = await this.transitionService.streamContractPdf(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${record.name}"`, + ); + stream.pipe(res); + } + @Post(':id/contract/sign') @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) signContract( diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 542a848a4..6526fb6cb 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -67,6 +67,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -343,6 +344,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: [ ...getCategorySidebarChildren("configuration"), + { + label: "Contract validity", + href: "/dashboard/configuration/contract-validity-periods", + }, // { // label: "Train scheduling rules", // href: "/dashboard/configuration/train-scheduling-rules", @@ -811,6 +816,14 @@ const App = () => { } /> + + + + } + /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 16bd6ed27..0f9f42217 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -419,6 +419,7 @@ function DocReviewCard({ const status = doc.reviewStatus ?? "PENDING"; const meta = STATUS_META[status]; const hasFile = !!doc.file; + const isApproved = status === "APPROVED"; return ( Open query - + {!isApproved && ( + + )} ) : ( )} - {!readOnly && hasFile && !isApproved && ( + {!readOnly && hasFile && ( {!queryOpen ? ( @@ -598,16 +598,18 @@ function DocReviewCard({ > Open query - + {!isApproved && ( + + )} ) : ( void; + reference: string; + message?: string; + confirmLabel?: string; +} + +export function ContractSignSuccessModal({ + opened, + onClose, + reference, + message = "The contract has been signed and recorded.", + confirmLabel = "Back to contract request", +}: ContractSignSuccessModalProps) { + return ( + + + + + + {reference} + + {message} + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index b400ddba4..9b75f35f5 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -184,6 +184,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Manage dropdown options used across the platform", }, }, + { + prefix: "/dashboard/configuration/contract-validity-periods", + meta: { + title: "Contract validity periods", + subtitle: "Validity options staff choose when accepting a submitted contract", + }, + }, { prefix: "/dashboard/configuration/train-scheduling-rules", meta: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 2b767d082..3acacd36b 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -137,6 +137,7 @@ export const URL_CONSTANTS = { `/contracts/${id}/approval-steps/${stepId}/approve`, CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`, CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`, + CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`, CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`, CLEARANCE_QUEUE: "/contracts/clearance/queue", CLEARANCE: (id: string) => `/contracts/${id}/clearance`, diff --git a/apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx new file mode 100644 index 000000000..ee6bb5ebd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx @@ -0,0 +1,145 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Badge, + Button, + Center, + Group, + Loader, + Paper, + Stack, + Table, + Text, + Title, +} from "@mantine/core"; +import { CalendarClock, Pencil } from "lucide-react"; + +import { PageContainer } from "@/components/page"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { api } from "@/services/api"; +import ManageDropdownOptionsDialog from "@/pages/dropdown_settings/ManageDropdownOptionsDialog"; + +const CONTRACT_VALIDITY_PERIODS_CODE = "contract_validity_periods"; + +/** + * Admin UI for contract validity options used when staff accepts a submitted + * contract (SUBMITTED → PENDING_APPROVAL). Backed by dropdown_settings. + */ +export default function ContractValidityPeriodsPage() { + const [editOpen, setEditOpen] = useState(false); + + const { data: setting, isLoading, isError } = useQuery( + api.dropdownSettings.getByCode.queryOptions({ + input: { code: CONTRACT_VALIDITY_PERIODS_CODE }, + }), + ); + + const options = useMemo( + () => + [...(setting?.children ?? [])].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0), + ), + [setting], + ); + + return ( + + + + + + + Contract validity periods + + Options shown when line staff accepts a submitted contract. Each + value is the number of days the contract stays valid from the + accept date. + + + {setting && ( + + )} + + + + {isLoading ? ( +
+ +
+ ) : isError || !setting ? ( + + Could not load contract validity settings. Ensure{" "} + + {CONTRACT_VALIDITY_PERIODS_CODE} + {" "} + is seeded in dropdown settings. + + ) : options.length === 0 ? ( + + + No validity periods configured yet. + + + ) : ( +
+ + + Label + Days (value) + Order + Status + + + + {options.map((opt) => ( + + {opt.label} + + + {opt.value} + + + {opt.order ?? "—"} + + + {opt.disabled ? "Disabled" : "Active"} + + + + ))} + +
+ )} + +
+ + {setting ? ( + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 63f011ea6..08f0ed0eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -62,6 +62,16 @@ export default function ContractClearanceDetailPage() { // Customs (Path B) hub. The customer always creates the booking in the portal // after GL finalizes clearance — there is no GL "Create booking" action here. const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING"; + const clearanceReadOnly = Boolean( + contract?.status && + [ + "ACTIVE_SHIPMENT_IN_PROGRESS", + "FULLY_EXECUTED", + "CONTRACT_ACTIVE", + "CONTRACT_CLOSED", + "EXPIRED", + ].includes(contract.status), + ); if (isLoading) { return ( @@ -160,7 +170,7 @@ export default function ContractClearanceDetailPage() { contractId={id!} hideSummary selfClear={false} - readOnly={ready} + readOnly={clearanceReadOnly} />
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 44d46c652..64737c3eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -6,6 +6,8 @@ import { Building2, Calendar, CalendarClock, + Download, + FileSignature, FileText, Files, Flame, @@ -56,6 +58,8 @@ import { useContractDetail, useContractMutations, } from "@/hooks/contracts/useContracts"; +import { contractsService } from "@/services/contracts.service"; +import { fileViewUrl } from "@/constants/apiConfig"; import { downloadBookingFile } from "@/services/files.service"; import type { Freight } from "@edr/types"; @@ -136,6 +140,22 @@ export default function ContractRequestDetailPage() { } }; + const downloadContractPdf = async () => { + if (!contract?.id) return; + try { + const blob = await contractsService.downloadContractDocument(contract.id); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const contractPdf = contract.files?.find((f) => f.code === "contract"); + a.download = contractPdf?.name ?? `contract-${contract.reference}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast.error("Could not download contract PDF."); + } + }; + if (isLoading) { return ( @@ -207,6 +227,14 @@ export default function ContractRequestDetailPage() { // Path A (no customs) → Operations reviews; Path B (customs) → GL reviews. const selfClear = !contract.customsClearingEnabled; const files = contract.files ?? []; + const contractPdf = files.find((f) => f.code === "contract"); + const hasContractDocument = Boolean( + contractPdf || contract.contractGeneratedAt, + ); + const canViewSign = + (contract.status === "CONTRACT_READY" || + contract.status === "SIGNED_CUSTOMER") && + Boolean(contract.contractGeneratedAt); // Resolve the active tab from the URL, falling back to details when the // requested tab isn't available for this contract (e.g. clearance pre-phase). const currentTab = @@ -292,6 +320,48 @@ export default function ContractRequestDetailPage() { /> ) : null}
+ {hasContractDocument && ( + + {canViewSign && ( + + )} + {contractPdf && ( + + )} + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx index ecb8f1c62..7e8f06e0b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -13,18 +13,17 @@ import { Text, TextInput, } from "@mantine/core"; -import { ArrowLeft, FileSignature, Printer } from "lucide-react"; +import { ArrowLeft, Download, FileSignature, Printer } from "lucide-react"; import toast from "react-hot-toast"; +import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { contractsService } from "@/services/contracts.service"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; /** * Staff contract preview + sign. Staff must open and read the generated - * contract here before signing — there is no sign action on the detail page or - * the list table. Signing as STAFF is only possible once the contract has been - * generated and is in CONTRACT_READY / SIGNED_CUSTOMER. + * contract here before signing. */ export default function ContractViewPage() { const { id } = useParams<{ id: string }>(); @@ -33,9 +32,9 @@ export default function ContractViewPage() { const iframeRef = useRef(null); const [signOpen, setSignOpen] = useState(false); + const [successOpen, setSuccessOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); - // Offer the staff member's saved signature first; they can draw a fresh one. const [drawNew, setDrawNew] = useState(false); const { data, isLoading, isError, refetch } = useQuery({ @@ -58,8 +57,8 @@ export default function ContractViewPage() { consentText: "I confirm this contract on behalf of EDR.", }), onSuccess: () => { - toast.success("Contract signed"); setSignOpen(false); + setSuccessOpen(true); void refetch(); void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT }); @@ -69,6 +68,21 @@ export default function ContractViewPage() { const handlePrint = () => iframeRef.current?.contentWindow?.print(); + const downloadPdf = useCallback(async () => { + if (!id) return; + try { + const blob = await contractsService.downloadContractDocument(id); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `contract-${data?.reference ?? id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast.error("Could not download contract PDF."); + } + }, [id, data?.reference]); + const openSign = () => { setSignerName(data?.savedSignature?.signerDisplayName ?? ""); setSignatureData(null); @@ -124,6 +138,13 @@ export default function ContractViewPage() { > Print + {data.canSignStaff && ( + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 22f2a441b..cf974ceab 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -118,6 +118,7 @@ export const URL_CONSTANTS = { CONFIRM_SUBMIT: (id: string) => `/api/contracts/${id}/confirm-submit`, CONTRACT_GENERATE: (id: string) => `/api/contracts/${id}/contract/generate`, CONTRACT_VIEW: (id: string) => `/api/contracts/${id}/contract/view`, + CONTRACT_DOCUMENT: (id: string) => `/api/contracts/${id}/contract/document`, CONTRACT_SIGN: (id: string) => `/api/contracts/${id}/contract/sign`, RENEW: (id: string) => `/api/contracts/${id}/renew`, CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 538c58fe7..a25bfe489 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -48,8 +48,10 @@ import { useDisclosure } from "@mantine/hooks"; import { isViewable, type ViewableFile } from "@edr/ui-common"; import type { Freight } from "@edr/types"; import { api } from "@/services/api"; +import { contractsService } from "@/services/contracts.service"; import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; +import toast from "react-hot-toast"; import { labelForDocCode } from "@/pages/bookings/resubmit"; import { ContractClearancePanel } from "./ContractClearancePanel"; import { formatRateUnit } from "./new-contract-form/unit-rates"; @@ -223,6 +225,22 @@ export default function ContractDetailPage() { // The generated contract PDF — surfaced via a dedicated "View contract" button // in the header (it's excluded from the Documents tab groups). const contractPdf = files.find((f) => f.code === "contract"); + const hasContractDocument = Boolean(contractPdf || contract.contractGeneratedAt); + + const downloadContractPdf = async () => { + if (!contract?.id) return; + try { + const blob = await contractsService.downloadContractDocument(contract.id); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = contractPdf?.name ?? `contract-${contract.reference}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast.error("Could not download contract PDF."); + } + }; const canSign = contract.status === "CONTRACT_READY"; const customsPath = contract.customsClearingEnabled; @@ -303,6 +321,17 @@ export default function ContractDetailPage() { ) )} + {hasContractDocument && ( + + )} {canBookShipment && ( - {data.canSignCustomer && ( - - )} + + {data.canSignCustomer && !hasScrolledToBottom && ( + + Please scroll through the entire contract before signing. + + )} +