From 49c453b82a44f5be77b9ab70ccc206b9c7f3fdb8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 07:26:41 +0000 Subject: [PATCH 1/9] always-on calendar, currency cards --- .../bookings/booking-pricing.service.spec.ts | 27 +++++++++++++++++++ .../bookings/booking-pricing.service.ts | 25 ++++++++--------- .../contracts/contract-pricing.service.ts | 3 ++- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 4996f45a9..75a179b5c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -87,6 +87,33 @@ describe('BookingPricingService — domestic corridor', () => { expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE)); }); + it('keeps the exchange rate decimals — ETB amounts round to cents, not whole birr', async () => { + exchangeService.getRate.mockResolvedValue(162.2132); + const booking = { + id: 'b-1-frac', + freightType: 'BULK', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'ETB', + cargoTotalWeightVgm: 120, + originYardId: MOJO, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { containers: [] }, + ) => Promise<{ lineItems: Array<{ amount: number }> }>; + } + ).computeBaseRailLinesWithRates(booking, { containers: [] }); + + // 35 × 120 × 162.2132 = 681,295.44 — the .44 must survive (whole-birr + // rounding here billed with the integer part of the rate, in effect). + expect(result.lineItems[0].amount).toBe(681295.44); + }); + it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => { const booking = { id: 'b-1-usd', diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 9bbe6bed5..522bac47e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -6,6 +6,7 @@ import { RatesService } from '../rule-engine/services/rates.service'; import { Rate } from '../rule-engine/entities/rate.entity'; import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; +import { round2 } from '../billing/invoice-settlement.util'; import { ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, @@ -205,14 +206,14 @@ export class BookingPricingService { const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking - ? Math.round(unitUsd * usdToEtb) + ? round2(unitUsd * usdToEtb) : unitUsd; const convertedAmount = frozen ? isEtbBooking - ? Math.round(unitAmount * quantity) + ? round2(unitAmount * quantity) : unitAmount * quantity : isEtbBooking - ? Math.round(usdAmount * usdToEtb) + ? round2(usdAmount * usdToEtb) : usdAmount; const item: PriceLineItemDto = { @@ -583,8 +584,8 @@ export class BookingPricingService { } else { const unitUsd = Number(rate!.rateValue); const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons); - amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd; } if (rate) usedRatesMap.set(rate.id, rate); lines.push({ @@ -652,8 +653,8 @@ export class BookingPricingService { ); } else { const usdAmount = this.amountForRate(fallback, quantity, wagonCount); - amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd; } lines.push({ code: rateType, @@ -763,12 +764,12 @@ export class BookingPricingService { if (frozen) { unitAmount = Number(frozen.unitPrice); amount = isEtbBooking - ? Math.round(unitAmount * quantity) + ? round2(unitAmount * quantity) : unitAmount * quantity; } else { const usdAmount = value * quantity; - amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value; + amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? round2(value * usdToEtb) : value; } // Skip legs that resolve to nothing (zero rate, or zero km / count / tons). if (!(amount > 0)) continue; @@ -971,7 +972,7 @@ export class BookingPricingService { if (!(usdToEtb > 0)) return null; const converted = snap.currency === 'USD' && bookingCurrency === 'ETB' - ? Math.round(unitPrice * usdToEtb) + ? round2(unitPrice * usdToEtb) : snap.currency === 'ETB' && bookingCurrency === 'USD' ? unitPrice / usdToEtb : null; @@ -1027,7 +1028,7 @@ export class BookingPricingService { const currency = booking.paymentCurrency; const isEtb = currency === 'ETB'; const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd); + const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); const onLeg = liveRates.filter( (r) => diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index d54b0b544..3f395c369 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -2,6 +2,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { RatesService } from '../rule-engine/services/rates.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { round2 } from '../billing/invoice-settlement.util'; import { ExchangeService } from '@edr/api-common'; import { ContractsRepository } from './contracts.repository'; import { Contract } from './entities/contract.entity'; @@ -79,7 +80,7 @@ export class ContractPricingService { const currency = contract.paymentCurrency; const isEtb = currency === 'ETB'; const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd); + const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); const lineItems: ContractUnitRateLineItem[] = []; const baseType = this.baseRateType(contract); From ceabe163e79b8feb9fd73af70d4d57b161d10e2d Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 09:46:39 +0000 Subject: [PATCH 2/9] fix issue --- apps/edr-freight-web/backoffice/src/App.tsx | 16 + .../backoffice/src/pages/SettingsPage.tsx | 3 - .../BookingDetailPage/ReadonlyBookingView.tsx | 17 +- .../BookingDetailPage/components/CargoTab.tsx | 465 ++++++++++++++++++ .../src/pages/bookings/NewBookingPage.tsx | 7 +- .../src/pages/contracts/NewShipmentPage.tsx | 18 +- 6 files changed, 507 insertions(+), 19 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CargoTab.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d4892d198..4c1b346e9 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -115,6 +115,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; +import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; @@ -600,6 +601,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/configuration/trade-access", permission: FREIGHT_PERMS.admin, }, + { + label: "Exchange rate", + href: "/dashboard/configuration/exchange-rate", + permission: FREIGHT_PERMS.admin, + }, ], }, { @@ -1574,6 +1580,16 @@ const App = () => { } /> + +
+ +
+ + } + /> {/* - - diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index ac211b5be..147262472 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,5 +1,12 @@ import { Group, Tabs } from "@mantine/core"; -import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react"; +import { + Clock, + CreditCard, + FileText, + LayoutGrid, + Package, + Truck, +} from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useFileViewer } from "@/hooks/useFileViewer"; @@ -9,6 +16,7 @@ import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; import { ActivityCard } from "./components/ActivityCard"; import { ClearanceCard } from "./components/ClearanceCard"; import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner"; +import { CargoTab } from "./components/CargoTab"; import { DocumentsTab } from "./components/DocumentsTab"; import { CompanyInfoCard } from "./components/CompanyInfoCard"; import { ContainersCard } from "./components/ContainersCard"; @@ -199,6 +207,9 @@ export function ReadonlyBookingView({ }> Overview + }> + Cargo + }> Logistics @@ -254,6 +265,10 @@ export function ReadonlyBookingView({ + + + +
+ {icon} + {label} + + ); +} + +function StatTile({ + icon, + label, + value, + sub, +}: { + icon: ReactNode; + label: string; + value: string; + sub?: string; +}) { + return ( + + + {icon} + + {label} + + + + {value} + + {sub && ( + + {sub} + + )} + + ); +} + +function DetailRow({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + + {value} + + + ); +} + +const th = { color: "#9AA8B5", fontSize: 11 } as const; + +// ─── Containers ─────────────────────────────────────────────────────────────── + +function lineTypeLabel(line: BookingContainerLineDetail): string { + const t = line.containerType; + if (t?.label) return t.label; + if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""} container`; + return t?.code ?? "Container"; +} + +function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) { + return ( + + + + {unit.containerNumber} + + + + + {unit.sealNumber || "—"} + + + + + {Number(unit.vgmTons || 0) ? fmtWeight(Number(unit.vgmTons)) : "—"} + + + + + {unit.isHazardous && ( + } label="Hazardous" /> + )} + {unit.isReefer && ( + } label="Reefer" /> + )} + {unit.isReturn && } label="Return" />} + {!unit.isHazardous && !unit.isReefer && !unit.isReturn && ( + + — + + )} + + + + + {unit.grnNumber || "—"} + + + + {unit.receivedToPort ? ( + + + Received + + {unit.receivedAt && ( + + {fmtDate(unit.receivedAt)} + + )} + + ) : ( + + Pending + + )} + + + ); +} + +function ContainerLineCard({ line, index }: { line: BookingContainerLineDetail; index: number }) { + const units = line.units ?? []; + return ( + + + + + + + + + {lineTypeLabel(line)} + + + Line {index + 1} · {line.quantity} unit{line.quantity !== 1 ? "s" : ""} + + + + + {line.isOverweight && ( + } + label={ + Number(line.overweightExcessTons || 0) + ? `Overweight +${Number(line.overweightExcessTons)} t` + : "Overweight" + } + /> + )} + {!!line.hazardousQuantity && ( + } label={`${line.hazardousQuantity} hazardous`} /> + )} + {!!line.reeferQuantity && ( + } label={`${line.reeferQuantity} reefer`} /> + )} + {!!line.returnQuantity && ( + } label={`${line.returnQuantity} return`} /> + )} + + + + + } + label="Quantity" + value={`${line.quantity}`} + sub={`container${line.quantity !== 1 ? "s" : ""}`} + /> + } + label="VGM / unit" + value={fmtWeight(Number(line.vgmPerUnitTons || 0))} + /> + } + label="Line total VGM" + value={fmtWeight(Number(line.totalVgmTons || 0))} + /> + + + {units.length > 0 && ( + + + + + Container no. + Seal no. + VGM + Flags + GRN + Port status + + + + {units.map((u) => ( + + ))} + +
+
+ )} + {units.length === 0 && ( + + Container numbers will appear here once the physical units are assigned. + + )} +
+ ); +} + +// ─── Bulk ───────────────────────────────────────────────────────────────────── + +function BulkCargoCard({ booking }: { booking: BookingDetail }) { + const unit = booking.cargoType?.unitOfMeasure; + const isPerItem = unit === "PER_ITEM"; + // Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT and the + // real tonnage lives in bulkTotalWeightTons; PER_TON stores tons directly. + const quantity = Number(booking.cargoTotalWeightVgm || 0); + const tons = totalVgmTons(booking); + + return ( + + + + + + + + {commodityLabel(booking)} + + + Bulk cargo{booking.cargoType?.code ? ` · ${booking.cargoType.code}` : ""} + + + + + + {isPerItem && ( + } + label="Items" + value={quantity ? quantity.toLocaleString() : "—"} + sub="declared item count" + /> + )} + } + label="Total weight" + value={fmtWeight(tons)} + sub={isPerItem ? "actual tonnage" : "declared tonnage"} + /> + } + label="Billing unit" + value={isPerItem ? "Per item" : "Per ton"} + /> + + + + + {booking.cargoType?.code && ( + + )} + {booking.cargoFreeText && booking.cargoType?.cargoTypeName && ( + + )} + + + + + + + ); +} + +// ─── Tab ────────────────────────────────────────────────────────────────────── + +/** + * Dedicated cargo breakdown tab: bulk bookings get the full bulk declaration + * (commodity, unit of measure, item count vs tonnage, hazardous/reefer + * quantities); container bookings get one card per container line with its + * per-unit numbers, seals, VGM, GRN and port-arrival status. + */ +export function CargoTab({ booking }: { booking: BookingDetail }) { + const isBulk = booking.freightType === "BULK"; + const lines = booking.bookingContainers ?? []; + const totalUnits = lines.reduce((s, c) => s + Number(c.quantity || 0), 0); + const totalVgm = totalVgmTons(booking); + const receivedCount = lines + .flatMap((l) => l.units ?? []) + .filter((u) => u.receivedToPort).length; + + return ( +
+ + Cargo summary + + : } + label="Freight type" + value={isBulk ? "Bulk" : "Container"} + /> + } + label="Commodity" + value={commodityLabel(booking)} + /> + } + label="Total weight" + value={fmtWeight(totalVgm)} + /> + {isBulk ? ( + } + label="Special handling" + value={ + [ + booking.isHazardous && "Hazardous", + booking.isRefrigerated && "Reefer", + ] + .filter(Boolean) + .join(" · ") || "None" + } + /> + ) : ( + } + label="Containers" + value={`${totalUnits}`} + sub={`${receivedCount} received at port`} + /> + )} + + + + {isBulk ? ( + + ) : lines.length > 0 ? ( + lines.map((line, i) => ( + + )) + ) : ( + + + No container details recorded for this booking yet. + + + )} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 5a8705610..6b80ebd32 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -292,7 +292,10 @@ export default function NewBookingPage() { // physically carry the selected cargo/container type. Quantity is NOT part // of this gate — an oversized booking is accepted and gets a partial split // offer later. Only selectable days reach the UI; no capacity counts. - const gateContainerTypeIds = useMemo(() => { + // Computed per render on purpose — NOT useMemo. react-hook-form mutates the + // watched containers array in place on nested edits (containers.0.containerType), + // so a reference-based dep list never sees per-line changes. + const gateContainerTypeIds = (() => { if (watchedCargoKind !== "container") return []; const groups = referenceData?.containers ?? []; const ids = new Set(); @@ -304,7 +307,7 @@ export default function NewBookingPage() { } } return [...ids]; - }, [watchedCargoKind, watchedContainers, referenceData]); + })(); const gateCargoTypeId = watchedCargoKind === "bulk" ? watchedCargoTypePath?.[1] : undefined; const gateReady = diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 4d423abe8..54f678049 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -1006,9 +1006,11 @@ function ScheduleStep({ const isContainer = contract.freightType === "CONTAINER"; const containerLines = form.watch("containers"); const cargoWeightTons = form.watch("cargoWeightTons"); - const itemCount = form.watch("itemCount"); - const cargoQuery = useMemo(() => { + // Computed per render on purpose — NOT useMemo. react-hook-form mutates the + // watched containers array in place on nested edits (containers.0.quantity), + // so a reference-based dep list never sees manual quantity changes. + const cargoQuery = ((): Freight.AvailableDaysForCargoQuery | null => { if (!route?.originYardId || !route?.destinationYardId) return null; if (isContainer) { const containers = (containerLines ?? []) @@ -1036,17 +1038,7 @@ function ScheduleStep({ ?.cargoTypeCode ?? undefined, totalWeightTons: tons, }; - // Tonnage is the sizing input the day-feasibility endpoint takes, and - // PER_ITEM cargo now captures it too — itemCount stays in the deps so the - // query still refreshes when only the item count changes. - }, [ - route, - isContainer, - containerLines, - cargoWeightTons, - itemCount, - contract.pricingBreakdown, - ]); + })(); const isIntercity = contract.tradeDirection === "DOMESTIC"; const { data: availableDays, isLoading } = useQuery({ From b7dcc1bf0aed33cf7e0a9b9b5d006f68c2e1f4fb Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 12:59:37 +0000 Subject: [PATCH 3/9] feat(billing): add PAYMENT_PROCESSING invoice status on payment success redirect (all except CBE bill) --- ...00000000-InvoicePaymentProcessingStatus.ts | 22 ++++++++ .../src/modules/billing/billing.service.ts | 56 +++++++++++++++++++ .../src/modules/payment/payment.controller.ts | 10 ++++ .../src/modules/payment/payment.service.ts | 39 ++++++++++++- .../src/components/customers/badges.tsx | 1 + .../components/warehouses/FeePreviewModal.tsx | 1 + .../src/pages/invoices/InvoicesPage.tsx | 1 + .../src/pages/operations/FirstMilePage.tsx | 1 + .../src/pages/operations/LastMilePage.tsx | 1 + .../src/pages/reports/reportConfigs.ts | 1 + .../warehouses/WarehouseInvoicesPage.tsx | 1 + .../backoffice/src/types/warehouse.ts | 1 + .../portal/src/constants/URLS.ts | 2 + .../portal/src/pages/billing/invoice-ui.tsx | 1 + .../BookingClearanceWorkflowBanner.tsx | 1 + .../src/pages/bookings/booking-display.tsx | 2 + .../src/pages/payments/PaymentSuccessPage.tsx | 14 ++++- .../portal/src/services/payments.service.ts | 5 ++ .../src/modules/cbe-bill/cbe-bill.service.ts | 1 + packages/types/src/freight/index.ts | 2 + 20 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts diff --git a/apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts b/apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts new file mode 100644 index 000000000..76fee7d22 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Adds PAYMENT_PROCESSING to the invoice status enum: the customer completed + * provider checkout (success redirect) and settlement is awaiting the + * provider webhook. + */ +export class InvoicePaymentProcessingStatus3260000000000 + implements MigrationInterface +{ + name = "InvoicePaymentProcessingStatus3260000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PAYMENT_PROCESSING' AFTER 'PENDING'`, + ); + } + + public async down(): Promise { + // Postgres cannot drop an enum value; PAYMENT_PROCESSING stays. Harmless. + } +} 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 bd2355df5..60f173596 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -52,6 +52,8 @@ const DEFAULT_DUE_DAYS = 14; const OPEN_STATUSES: Freight.InvoiceStatus[] = [ Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending, + // Success-redirect ack; still unsettled, so it must stay payable/settleable. + Freight.InvoiceStatus.PaymentProcessing, Freight.InvoiceStatus.PartiallyPaid, Freight.InvoiceStatus.Overdue, ]; @@ -924,6 +926,26 @@ export class BillingService { }); if (!invoice) return null; + // Reconcile-before-expire, caller-proof: an invoice with a payment intent may + // have settled at the gateway without the webhook landing yet. `paid` — leave + // it open, the (re-emitted) payment.succeeded settles it. `unverifiable` — + // never expire on unknown; the caller's next sweep retries. Invoices with no + // intent (`paymentId` null) were never payable at a gateway and expire directly. + if (invoice.paymentId) { + const { paid, unverifiable } = await this.reconcilePayable( + invoice.sourceId, + ); + if (paid || unverifiable) { + this.logger.warn( + `expirePayable skipped for invoice ${invoice.invoiceNumber} (${invoice.id}) — ` + + (paid + ? "gateway reconcile found a settled payment" + : "settlement unverifiable at the gateway"), + ); + return null; + } + } + return this.transition( invoice.id, Freight.InvoiceStatus.Expired, @@ -1028,6 +1050,40 @@ export class BillingService { ); } + /** + * Success-redirect ack (see PaymentService.acknowledgeSuccessRedirect): move + * the invoice linked to a gateway intent to PAYMENT_PROCESSING. Only from + * ISSUED/PENDING — never overwrites a settlement (PAID/PARTIALLY_PAID) and + * is idempotent. Balance untouched: this is a display state, not a + * settlement; settleByPaymentId still performs the real transition. + */ + async markInvoicePaymentProcessing(paymentId: string): Promise { + await this.dataSource.getRepository(Invoice).update( + { + paymentId, + status: In([ + Freight.InvoiceStatus.Issued, + Freight.InvoiceStatus.Pending, + ]), + }, + { status: Freight.InvoiceStatus.PaymentProcessing }, + ); + } + + /** + * Counterpart of {@link markInvoicePaymentProcessing} for a failed intent: + * PAYMENT_PROCESSING → PENDING so the invoice reads payable again for a + * retry. No-op from any other status. + */ + async revertInvoicePaymentProcessing(paymentId: string): Promise { + await this.dataSource + .getRepository(Invoice) + .update( + { paymentId, status: Freight.InvoiceStatus.PaymentProcessing }, + { status: Freight.InvoiceStatus.Pending }, + ); + } + // ── Payment initiation & settlement (the gateway boundary) ─────────────────── /** 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 3602657de..b1dafbc85 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -4,6 +4,7 @@ import { HttpStatus, Param, ParseUUIDPipe, + Post, Query, Res, } from "@nestjs/common"; @@ -84,6 +85,15 @@ export class PaymentController { return this.paymentService.getIntentByBookingId(bookingId); } + @Post("redirect-success/:bookingId") + @ApiOperation({ + summary: + "Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", + }) + acknowledgeSuccessRedirect(@Param("bookingId") bookingId: string) { + return this.paymentService.acknowledgeSuccessRedirect(bookingId); + } + @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 99c4aca46..afe6009ef 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -253,8 +253,10 @@ export class PaymentService { payerAccount: input.payerAccount, payerName: input.payerName, expiresAt: input.expiresAt, + // bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING). returnUrl: - input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + input.returnUrl ?? + `https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`, failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", }); @@ -492,6 +494,37 @@ export class PaymentService { return { alreadyFinalized: false }; } + /** + * Success-redirect ack from the portal: the customer finished provider + * checkout, settlement webhook not (necessarily) in yet. Optimistic + * intermediate only — the webhook stays the source of truth. Never + * downgrades: only action-required → processing, and the invoice moves to + * PAYMENT_PROCESSING only from an open unpaid status. CBE_BILL is excluded + * (bank-counter flow, it has no redirect). + */ + async acknowledgeSuccessRedirect( + referenceId: string, + ): Promise<{ acknowledged: boolean }> { + const intent = await this.paymentRepo.findOneBy({ refId: referenceId }); + if (!intent || intent.method === "cbe-bill") { + return { acknowledged: false }; + } + + if (intent.status === "action-required") { + await this.paymentRepo.update( + { id: intent.id, status: "action-required" }, + { status: "processing" }, + ); + } + // Even if the intent already advanced (e.g. webhook raced the redirect to + // "processing"), the invoice ack is idempotent and status-guarded. + if (intent.status === "action-required" || intent.status === "processing") { + await this.billing.markInvoicePaymentProcessing(intent.id); + return { acknowledged: true }; + } + return { acknowledged: false }; + } + async markPaymentFailed(input: { intentId: string; failureCode?: string; @@ -510,7 +543,9 @@ export class PaymentService { }, ); - // Invoice stays open for retry — nothing to settle. Logged only. + // Invoice stays open for retry — nothing to settle. A redirect-acked + // PAYMENT_PROCESSING invoice is put back to PENDING so it reads payable. + await this.billing.revertInvoicePaymentProcessing(intent.id); this.logger.warn( `Payment ${intent.id} failed for ${intent.refId}` + (input.failureMessage ? `: ${input.failureMessage}` : ""), diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 112026ca6..046493b0f 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -249,6 +249,7 @@ const INVOICE_STATUS_COLOR: Record = { DRAFT: "gray", ISSUED: "cyan", PENDING: "yellow", + PAYMENT_PROCESSING: "indigo", PARTIALLY_PAID: "orange", PAID: "edr-green", OVERDUE: "red", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index d3c8e1897..cb2cbc658 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -14,6 +14,7 @@ import { openPdfBlob } from './pdf'; const INVOICE_STATUS_COLOR: Record = { DRAFT: 'gray', ISSUED: 'orange', + PAYMENT_PROCESSING: 'indigo', PARTIALLY_PAID: 'yellow', PAID: 'edr-green', CANCELLED: 'gray', diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 7eca77ddf..fd839de16 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -183,6 +183,7 @@ export default function InvoicesPage() { data={[ { label: "All", value: "all" }, { label: "Pending", value: "PENDING" }, + { label: "Payment processing", value: "PAYMENT_PROCESSING" }, { label: "Paid", value: "PAID" }, { label: "Overdue", value: "OVERDUE" }, ]} 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 835d1a82f..192a748c3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -79,6 +79,7 @@ const INVOICE_STATUS_META: Record = { PAID: { label: "Paid", color: "green" }, PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, PENDING: { label: "Pending", color: "yellow" }, + PAYMENT_PROCESSING: { label: "Payment Processing", color: "indigo" }, UNPAID: { label: "Unpaid", color: "yellow" }, OPEN: { label: "Open", color: "yellow" }, ISSUED: { label: "Issued", color: "blue" }, 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 33e9bbed8..f402f1792 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -86,6 +86,7 @@ const INVOICE_STATUS_META: Record = { PAID: { label: "Paid", color: "green" }, PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, PENDING: { label: "Pending", color: "yellow" }, + PAYMENT_PROCESSING: { label: "Payment Processing", color: "indigo" }, UNPAID: { label: "Unpaid", color: "yellow" }, OPEN: { label: "Open", color: "yellow" }, ISSUED: { label: "Issued", color: "blue" }, diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts index 9aec4fec0..ced916fbb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts +++ b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts @@ -75,6 +75,7 @@ const CONTRACT_STATUSES = [ const INVOICE_STATUSES = [ "ISSUED", "PENDING", + "PAYMENT_PROCESSING", "PARTIALLY_PAID", "PAID", "OVERDUE", diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 92f807363..c8e3d111d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -42,6 +42,7 @@ import { extractErrorMessage } from '@/components/warehouses/options'; const STATUS_COLOR: Record = { DRAFT: 'gray', ISSUED: 'orange', + PAYMENT_PROCESSING: 'indigo', PARTIALLY_PAID: 'yellow', PAID: 'edr-green', CANCELLED: 'gray', diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 8ff87c251..6b11d4dcd 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -907,6 +907,7 @@ export interface AllocationCriteria { export const WAREHOUSE_INVOICE_STATUSES = [ 'DRAFT', 'ISSUED', + 'PAYMENT_PROCESSING', 'PARTIALLY_PAID', 'PAID', 'CANCELLED', diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 22421400f..f050818ed 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -194,6 +194,8 @@ export const URL_CONSTANTS = { PAYMENTS: { INITIATE: "/api/payments/initiate", INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`, + REDIRECT_SUCCESS: (bookingId: string) => + `/api/payments/redirect-success/${bookingId}`, CHECKOUT: "/api/payments/checkout", }, diff --git a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx index dac9cb18c..39cdc5333 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx @@ -21,6 +21,7 @@ const STATUS_STYLE: Record< [Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" }, [Freight.InvoiceStatus.Issued]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" }, [Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" }, + [Freight.InvoiceStatus.PaymentProcessing]: { label: "Payment processing", bg: "#EAF1FB", fg: "#2563EB" }, [Freight.InvoiceStatus.PartiallyPaid]: { label: "Partially paid", bg: "#FEF9E7", fg: "#A16207" }, [Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" }, [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" }, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx index 6e8546dd8..ad188a206 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx @@ -41,6 +41,7 @@ const INVOICE_STATUS_LABELS: Record = { DRAFT: "Draft", ISSUED: "Issued", PENDING: "Due", + PAYMENT_PROCESSING: "Payment processing", PARTIALLY_PAID: "Partially paid", PAID: "Paid", OVERDUE: "Overdue", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx index 7dc1109eb..134eae3b5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx @@ -120,6 +120,7 @@ const PAYMENT_COLORS: Record = { // Backend emits the long form on some flows; keep the short alias too. VERIFICATION_IN_PROGRESS: "yellow", PAYMENT_VERIFICATION_IN_PROGRESS: "yellow", + PAYMENT_PROCESSING: "yellow", OVERDUE: "red", REFUNDED: "blue", CANCELLED: "gray", @@ -132,6 +133,7 @@ const PAYMENT_LABELS: Record = { PNR_GENERATED: "PNR generated", VERIFICATION_IN_PROGRESS: "Verifying", PAYMENT_VERIFICATION_IN_PROGRESS: "Verifying", + PAYMENT_PROCESSING: "Payment processing", OVERDUE: "Overdue", REFUNDED: "Refunded", CANCELLED: "Cancelled", diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx index 93a040191..d0bb9c348 100644 --- a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx @@ -8,10 +8,22 @@ import { ThemeIcon, } from "@mantine/core"; import { CheckCircle2, FileText, Home } from "lucide-react"; -import { useNavigate } from "react-router-dom"; +import { useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { paymentsService } from "@/services/payments.service"; export default function PaymentSuccessPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const bookingId = searchParams.get("bookingId"); + + // Fire-and-forget ack: payment → processing, invoice → PAYMENT_PROCESSING. + // The provider webhook remains the source of truth for the final PAID state. + useEffect(() => { + if (bookingId) { + paymentsService.acknowledgeSuccessRedirect(bookingId).catch(() => {}); + } + }, [bookingId]); return ( => { + await client.post(P.REDIRECT_SUCCESS(bookingId)); + }, + checkoutUrl: buildCheckoutUrl, checkoutUrlForInvoice: buildCheckoutUrlForInvoice, }; diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts index 5b1a6cb28..9f60ab910 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -107,6 +107,7 @@ export class CbeBillService { consented_on: Math.floor(Date.now() / 1000), }; } + /* ------------------------------------------------------------------ query */ diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 37997ea18..d99dc07f8 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -161,6 +161,8 @@ export enum InvoiceStatus { /** Issued and awaiting payment (alias of PENDING for fee invoices). */ Issued = "ISSUED", Pending = "PENDING", + /** Customer completed provider checkout (success redirect); awaiting webhook confirmation. */ + PaymentProcessing = "PAYMENT_PROCESSING", /** Some, but not all, of the balance has been settled. */ PartiallyPaid = "PARTIALLY_PAID", Paid = "PAID", From 8476a6a00ef9a65dc0307eb2284f758b9bcd4063 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 5 Aug 2026 13:00:43 +0000 Subject: [PATCH 4/9] fix: the fayda and etrade syncing --- .../companies.fayda-identity.spec.ts | 95 +++++++++++++++- .../modules/companies/companies.service.ts | 103 ++++++++++++------ apps/edr-freight-web/backoffice/.env.example | 3 + apps/edr-freight-web/backoffice/package.json | 2 +- .../edr-freight-web/backoffice/vite.config.ts | 7 +- apps/edr-freight-web/portal/package.json | 2 +- apps/edr-freight-web/portal/vite.config.ts | 47 ++++---- 7 files changed, 197 insertions(+), 62 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 32513df4f..51dd1d61f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -280,15 +280,104 @@ describe("Fayda identity verification binds a person to the company", () => { expect(ctx.attributes.poaFaydaSub).toBe("new-sub"); }); - it("refuses to rename a verified person by hand", async () => { - const { service } = makeService({ + it("stages nothing for a verified field an approved company resubmits", async () => { + // Approving it could not move the live row — the verified value is written + // back over it — so it must never reach a reviewer as a pending change. + const { service, deps } = makeService({ + status: CompanyStatus.Active, + attributes: { ...OWNER_VERIFIED, ownerEmail: "abebe@example.com" }, + }); + + await expect( + service.updateProfile("user-1", { + companyEmail: "someone-else@example.com", + } as never), + ).resolves.toBeDefined(); + expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); + expect(deps.changeRequestRepo.update).not.toHaveBeenCalled(); + expect(deps.companiesRepo.update).not.toHaveBeenCalled(); + }); + + // The verified value wins, and it wins by overwriting rather than by + // rejecting: nobody types these fields, so a submission that disagrees is a + // stale form echoing itself back, not an edit. Failing it would block a save + // the customer never made — and leave them no way through, since re-verifying + // returns the same value they are being 400'd for. + it("overwrites a hand-renamed verified person with the verified name", async () => { + const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, files: [paper()], }); await expect( service.updateProfile("user-1", { poaName: "Someone Else" } as never), - ).rejects.toBeInstanceOf(BadRequestException); + ).resolves.toBeDefined(); + expect(ctx.attributes.poaName).toBe(POA_VERIFIED.poaName); + }); + + // Fayda's email and phone claims are optional — a verification can prove the + // person and return neither. Holding the company mirrors to "the owner is + // verified" rather than to "the verification supplied this value" would + // clobber the fallbacks the portal is built to send (account email, eTrade's + // registered phone) with nothing at all. OWNER_VERIFIED is exactly that + // shape: a sub, no contact details. + it("keeps company contact details a Fayda verification never supplied", async () => { + const { service, deps } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await expect( + service.updateProfile("user-1", { + companyEmail: "account@example.com", + companyPhone: "+251911777777", + } as never), + ).resolves.toBeDefined(); + const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!; + expect(patch.email).toBe("account@example.com"); + expect(patch.phone).toBe("+251911777777"); + }); + + it("overwrites company contact details the verification did supply", async () => { + const { service, deps } = makeService({ + attributes: { + ...OWNER_VERIFIED, + ownerEmail: "abebe@example.com", + ownerPhone: "+251911000000", + }, + }); + + await expect( + service.updateProfile("user-1", { + companyEmail: "someone-else@example.com", + companyPhone: "+251911999999", + } as never), + ).resolves.toBeDefined(); + const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!; + expect(patch.email).toBe("abebe@example.com"); + expect(patch.phone).toBe("+251911000000"); + }); + + // "Same as owner" copies `ownerEmail ?? null` onto the GM while setting + // `gmFaydaSub`. Locking that null made generalManagerEmail required by + // onboarding, hidden by the portal's link card and unwritable at once. + it("lets the GM's details be typed when the copied owner identity carried none", async () => { + const { service } = makeService({ + attributes: { + ...OWNER_VERIFIED, + gmSameAsOwner: true, + gmFaydaSub: "owner-sub", + generalManagerName: "Abebe Bikila", + generalManagerEmail: null, + generalManagerPhone: null, + }, + }); + + await expect( + service.updateProfile("user-1", { + generalManagerEmail: "gm@example.com", + generalManagerPhone: "+251911888888", + } as never), + ).resolves.toBeDefined(); }); it("never locks or gates the general manager — it is not the verified subject", async () => { diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index f21263c7f..b5c953cac 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -739,6 +739,39 @@ export class CompaniesService { return out; } + /** + * `UpdateProfileDto` keys this company's completed verifications own — the + * ones `mapProfileDtoToCompanyUpdates` overwrites with the verified value + * whatever a request submits for them. + * + * A key only lands here once there is a verified value to hold it to: Fayda's + * email and phone claims are optional, and a verification that returned + * neither owns nothing to overwrite with. + * + * The map is the enforcement; this is the list used to keep those keys out of + * a change request in the first place. If the two ever drift the map still + * wins — the cost is a staged field that approving turns out not to move. + */ + private faydaOwnedKeys(company: Company): string[] { + const attrs = company.attributes ?? {}; + const held = (key: string) => { + const v = attrs[key]; + return v !== null && v !== undefined && v !== ""; + }; + + const keys: string[] = []; + if (attrs.ownerFaydaSub) { + // The Company-column mirrors of the owner's verified contact details. + if (held("ownerEmail")) keys.push("companyEmail"); + if (held("ownerPhone")) keys.push("companyPhone"); + } + for (const subject of IDENTITY_SUBJECTS) { + if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; + keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held)); + } + return keys; + } + /** * Translate an UpdateProfileDto (or a staged change-request snapshot) into a * `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/ @@ -829,49 +862,46 @@ export class CompaniesService { // lets the customer type them once verified) — lock them the same way // ownerEmail/ownerPhone themselves are locked below, once there is a // verified owner to lock them to. + // + // Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and + // phone claims are optional, so a verification can prove the person while + // supplying neither (see completeIdentityVerification's conditional + // spreads). The portal falls back to the account email / eTrade's + // registered phone in exactly that case and submits it on every save of + // the company step — locking against an absent value would 400 that + // forever, and re-verifying could never clear it because Fayda still has + // nothing to return. if (attrUpdates.ownerFaydaSub) { - if ( - dto.companyEmail !== undefined && - dto.companyEmail !== attrUpdates.ownerEmail - ) { - throw new BadRequestException( - "companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.", - ); - } - if ( - dto.companyPhone !== undefined && - normalizeE164(dto.companyPhone) !== - normalizeE164(String(attrUpdates.ownerPhone ?? "")) - ) { - throw new BadRequestException( - "companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.", - ); - } + if (attrUpdates.ownerEmail && dto.companyEmail !== undefined) + companyUpdates.email = attrUpdates.ownerEmail; + if (attrUpdates.ownerPhone && dto.companyPhone !== undefined) + companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone)); } // Renaming a Fayda-verified person by hand would launder the guarantee - // away, so the fields the verification owns are refused once it exists. + // away, so the verification keeps these fields: a submission that disagrees + // is overwritten with the verified value rather than rejected — the same + // doctrine `applyEtradeSourcedFields` uses for eTrade's fields, and for the + // same reason. The customer never types these (the portal derives them, and + // a stale form or a re-render can echo back something else entirely), so a + // 400 punishes a save they never made while an overwrite lands the truth. for (const subject of IDENTITY_SUBJECTS) { if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; for (const field of IDENTITY_OWNED_FIELDS[subject]) { - const incoming = (dto as Record)[field]; - if (incoming === undefined) continue; - // The verification itself is allowed to write them; anything else is - // compared against what is already stored, not against the value this - // same call just copied into the patch. Phones are compared normalized: - // a form that re-renders +251911000000 as 0911000000 is echoing the - // stored value back, not trying to change it. + if ((dto as Record)[field] === undefined) continue; + // The verification itself is what writes them; it must not be undone by + // the value this same call just copied into the patch. if (dto.faydaIdentity && field in dto.faydaIdentity) continue; const stored = company.attributes?.[field]; - const same = field.endsWith("Phone") - ? normalizeE164(String(incoming)) === - normalizeE164(String(stored ?? "")) - : incoming === stored; - if (!same) { - throw new BadRequestException( - `${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`, - ); - } + // A verification that supplied nothing for this field left no guarantee + // to protect, so it stays typeable. Matters most for the GM — + // `setGmSameAsOwner` copies `ownerEmail ?? null` onto + // `generalManagerEmail` while setting `gmFaydaSub`, and + // REQUIRED_COMPANY_INFO still demands that email, so holding a null + // here makes it required, hidden by the portal's "same as owner" card, + // and unwritable all at once. + if (stored === null || stored === undefined || stored === "") continue; + attrUpdates[field] = stored; } } @@ -977,6 +1007,11 @@ export class CompaniesService { // for review with the live row left intact. await this.assertTinAvailable(company, dto.tin); const fields = this.pickDefined(dto); + // Drop what the verifications own before anything is staged. Approving one + // of these could not change the live row — mapProfileDtoToCompanyUpdates + // writes the verified value back over it — so showing it to a reviewer + // asks them to rule on a change that does not exist. + for (const key of this.faydaOwnedKeys(company)) delete fields[key]; const selfService: Record = {}; const staged: Record = {}; for (const [key, value] of Object.entries(fields)) { diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index c840d18a1..454817139 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1,3 +1,6 @@ +# Dev server port. Default: 5283. +PORT=5283 + VITE_API_URL=http://localhost:3001 VITE_BASE_API_URL=http://localhost:3001 diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 675eced1a..68fdb2a8f 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 --clearScreen false", + "dev": "vite --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/backoffice/vite.config.ts b/apps/edr-freight-web/backoffice/vite.config.ts index 6e7326189..63735d72e 100644 --- a/apps/edr-freight-web/backoffice/vite.config.ts +++ b/apps/edr-freight-web/backoffice/vite.config.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; +import { loadEnv } from "vite"; import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -10,7 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const streamBrowserifyPath = require.resolve("stream-browserify"); -export default defineConfig(() => { +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, ""); + return { plugins: [react(), tailwindcss()], resolve: { @@ -31,7 +34,7 @@ export default defineConfig(() => { dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"], }, server: { - port: 5183, + port: Number(env.PORT) || 5283, host: "0.0.0.0", }, test: { diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index f3b493aca..4bb96ee4f 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 3000 --clearScreen false", + "dev": "vite --clearScreen false", "build": "tsc -b && vite build", "preview": "vite preview --port 5173", "lint": "eslint src", diff --git a/apps/edr-freight-web/portal/vite.config.ts b/apps/edr-freight-web/portal/vite.config.ts index 99e2a41d5..89483c7dd 100644 --- a/apps/edr-freight-web/portal/vite.config.ts +++ b/apps/edr-freight-web/portal/vite.config.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import { loadEnv } from "vite"; import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -13,26 +14,30 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core"); const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks"); -export default defineConfig({ - plugins: [react(), tailwindcss()], - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - // Resolve from TS source so Vite gets ESM named exports (dist is CommonJS). - "@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"), - "@mantine/core": mantineCore, - "@mantine/hooks": mantineHooks, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, ""); + + return { + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + // Resolve from TS source so Vite gets ESM named exports (dist is CommonJS). + "@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"), + "@mantine/core": mantineCore, + "@mantine/hooks": mantineHooks, + }, + dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"], }, - dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"], - }, - optimizeDeps: { - include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"], - }, - server: { - port: 5173, - host: "0.0.0.0", - }, - test: { - environment: "node", - }, + optimizeDeps: { + include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"], + }, + server: { + port: Number(env.PORT) || 5273, + host: "0.0.0.0", + }, + test: { + environment: "node", + }, + }; }); From d5622ed36088c425293957c678e112090624ada9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 5 Aug 2026 13:39:45 +0000 Subject: [PATCH 5/9] chore: install auditlog pkg --- apps/edr-freight-api/package.json | 1 + local-packages/tria-plc-auditlog-1.1.2.tgz | Bin 0 -> 25758 bytes pnpm-lock.yaml | 313 ++++++--------------- 3 files changed, 84 insertions(+), 230 deletions(-) create mode 100644 local-packages/tria-plc-auditlog-1.1.2.tgz diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index d10f19f10..c4837c8a7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -59,6 +59,7 @@ "@nestjs/typeorm": "^11.0.1", "@nestjs/websockets": "^11.1.27", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz", + "@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz", "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", diff --git a/local-packages/tria-plc-auditlog-1.1.2.tgz b/local-packages/tria-plc-auditlog-1.1.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3f59c4394bd25018dd39677e7cf87a0dba3a8dc4 GIT binary patch literal 25758 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'} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/swagger': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + rxjs: ^7.0.0 + typeorm: ^0.3.0 + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz': resolution: {integrity: sha512-rfHSXOm/0VUMTj7HrvYysrEAqxItqfPs4Rd35qWJyaAk+snF1wTKFRVz6cRGPoQNj8fhplkVAefnvuKBuHT+xQ==, tarball: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz} version: 1.0.0 @@ -12298,11 +12314,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12337,7 +12353,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -12346,14 +12362,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12368,9 +12377,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -12385,13 +12394,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12544,18 +12553,6 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -12781,7 +12778,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -12947,7 +12944,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -13107,7 +13104,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -14304,7 +14301,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -16372,7 +16369,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -16467,6 +16464,18 @@ snapshots: - debug - supports-color + '@tria-plc/auditlog@file:local-packages/tria-plc-auditlog-1.1.2.tgz(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2))(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))))(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))': + dependencies: + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + amqp-connection-manager: 5.0.0(amqplib@0.10.9) + amqplib: 0.10.9 + rxjs: 7.8.2 + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(cc085a020c559b355f168432c579a024)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) @@ -16659,130 +16668,6 @@ snapshots: - utf-8-validate - vite - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': - dependencies: - '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) - '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) - '@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/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) - '@radix-ui/react-alert-dialog': 1.1.16(@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) - '@radix-ui/react-avatar': 1.1.12(@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) - '@radix-ui/react-checkbox': 1.3.4(@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) - '@radix-ui/react-collapsible': 1.1.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) - '@radix-ui/react-context-menu': 2.3.0(@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) - '@radix-ui/react-dialog': 1.1.16(@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) - '@radix-ui/react-dropdown-menu': 2.1.17(@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) - '@radix-ui/react-hover-card': 1.1.16(@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) - '@radix-ui/react-label': 2.1.9(@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) - '@radix-ui/react-navigation-menu': 1.2.15(@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) - '@radix-ui/react-popover': 1.1.16(@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) - '@radix-ui/react-progress': 1.1.9(@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) - '@radix-ui/react-radio-group': 1.4.0(@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) - '@radix-ui/react-scroll-area': 1.2.11(@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) - '@radix-ui/react-select': 2.3.0(@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) - '@radix-ui/react-separator': 1.1.9(@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) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) - '@radix-ui/react-switch': 1.3.0(@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) - '@radix-ui/react-tabs': 1.1.14(@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) - '@radix-ui/react-toast': 1.2.16(@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) - '@radix-ui/react-tooltip': 1.2.9(@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) - '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf/renderer': 4.5.1(react@19.2.6) - '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) - '@tabler/icons-react': 3.44.0(react@19.2.6) - '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@tanstack/react-query': 5.101.0(react@19.2.6) - '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) - '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) - '@types/dompurify': 3.2.0 - '@types/node': 24.13.1 - '@types/tinymce': 4.6.9 - axios: 1.17.0 - class-variance-authority: 0.7.1 - clsx: 2.1.1 - cmdk: 1.1.1(@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) - date-fns: 3.6.0 - dayjs: 1.11.21 - dompurify: 3.4.8 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-calendar-new: 1.1.0 - file-type: 18.7.0 - framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - html2canvas: 1.4.1 - i18next: 25.10.10(typescript@5.9.3) - i18next-browser-languagedetector: 8.2.1 - jquery: 3.7.1 - js-cookie: 3.0.8 - jspdf: 3.0.4 - lodash: 4.18.1 - lucide-react: 0.513.0(react@19.2.6) - 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) - mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) - next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - path: 0.12.7 - pdf-lib: 1.17.1 - qs: 6.15.2 - react: 19.2.6 - react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) - react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) - react-dropzone: 14.4.1(react@19.2.6) - react-hook-form: 7.77.0(react@19.2.6) - react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - react-icons: 5.6.0(react@19.2.6) - react-image-crop: 11.0.10(react@19.2.6) - react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) - react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) - react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(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) - rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) - socket.io-client: 4.8.3 - sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - tailwind-merge: 3.6.0 - tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) - tailwindcss: 4.3.0 - tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) - tinymce: 7.9.3 - url: 0.11.4 - vaul: 1.1.2(@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) - xlsx: 0.18.5 - zod: 3.25.76 - transitivePeerDependencies: - - '@babel/core' - - '@emotion/is-prop-valid' - - '@mui/icons-material' - - '@mui/material' - - '@mui/x-date-pickers' - - '@types/prop-types' - - '@types/react' - - '@types/react-dom' - - bufferutil - - debug - - pdfjs-dist - - prop-types - - react-is - - react-native - - redux - - rolldown - - rollup - - supports-color - - typescript - - utf-8-validate - - vite - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -17161,7 +17046,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -17171,7 +17056,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -17190,7 +17075,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -17205,7 +17090,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -17494,7 +17379,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -17547,6 +17432,11 @@ snapshots: amqplib: 0.10.9 promise-breaker: 6.0.0 + amqp-connection-manager@5.0.0(amqplib@0.10.9): + dependencies: + amqplib: 0.10.9 + promise-breaker: 6.0.0 + amqp-connection-manager@5.0.0(amqplib@2.0.1): dependencies: amqplib: 2.0.1 @@ -18003,16 +17893,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): - dependencies: - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - picomatch: 4.0.4 - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - supports-color - babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -18166,7 +18046,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -19163,7 +19043,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -19183,7 +19063,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -19412,7 +19292,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -19540,7 +19420,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -19770,7 +19650,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -19823,7 +19703,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -19974,7 +19854,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -20220,7 +20100,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20501,7 +20381,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20514,14 +20394,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20946,7 +20826,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -21592,7 +21472,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -22389,7 +22269,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -22727,7 +22607,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -22756,7 +22636,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -23009,15 +22889,6 @@ snapshots: - '@babel/core' - react-is - react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - '@babel/core' - - react-is - react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -23591,7 +23462,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -23709,7 +23580,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -23925,7 +23796,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -23935,7 +23806,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -23946,7 +23817,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -23955,7 +23826,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -23967,7 +23838,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -24245,24 +24116,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@emotion/is-prop-valid': 1.4.0 - '@emotion/stylis': 0.8.5 - '@emotion/unitless': 0.7.5 - babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) - css-to-react-native: 3.2.0 - hoist-non-react-statics: 3.3.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-is: 19.2.7 - shallowequal: 1.1.0 - supports-color: 5.5.0 - transitivePeerDependencies: - - '@babel/core' - styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -24288,7 +24141,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -24797,7 +24650,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -24821,7 +24674,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -25124,7 +24977,7 @@ snapshots: vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -25142,7 +24995,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -25189,7 +25042,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -25225,7 +25078,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 From b5eeb7c3a37d70470fb6b964b20ca4630337bdbc Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 14:01:01 +0000 Subject: [PATCH 6/9] fix issue --- apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index 7515befb9..2ee9cd719 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -550,6 +550,7 @@ export const INVOICE_BADGE: Record< [Freight.InvoiceStatus.Issued]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, [Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, [Freight.InvoiceStatus.PartiallyPaid]: { label: "Partially paid", bg: "edr-amber-soft", text: "edr-amber-text" }, + [Freight.InvoiceStatus.PaymentProcessing]: { label: "Payment processing", bg: "edr-blue-soft", text: "edr-blue" }, [Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" }, [Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" }, From 595c165820c8468eee78dbaede766eba4f3b2de2 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 5 Aug 2026 14:17:00 +0000 Subject: [PATCH 7/9] feat: implement audit logs --- apps/edr-freight-api/.env.example | 12 ++ apps/edr-freight-api/src/app.module.ts | 20 ++ .../src/config/database.config.ts | 11 +- apps/edr-freight-api/src/main.ts | 8 + .../src/modules/audit/audit.controller.ts | 32 +++ .../src/modules/audit/audit.module.ts | 13 ++ .../src/modules/audit/audit.service.ts | 59 ++++++ .../src/modules/billing/billing.service.ts | 12 ++ .../src/seed/freight-permissions.registry.ts | 4 + apps/edr-freight-web/backoffice/src/App.tsx | 16 ++ .../src/components/layout/route-meta.ts | 7 + .../backoffice/src/constants/URLS.ts | 4 + .../backoffice/src/lib/permissions.ts | 3 + .../src/pages/audit/AuditLogsPage.tsx | 185 ++++++++++++++++++ .../backoffice/src/services/api.ts | 14 ++ .../backoffice/src/services/audit.service.ts | 61 ++++++ .../intents/intents.service.cbe-bill.spec.ts | 48 ++++- .../src/modules/intents/intents.service.ts | 26 +++ 18 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/audit/audit.controller.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit.module.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/audit.service.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index c579eb388..16ee9cd57 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -1,5 +1,17 @@ # Copy to .env for local/docker compose (not committed). PORT=3001 +# @tria-plc/auditlog's client interceptor stamps every AuditLog row's +# `application` from this env var directly, bypassing MezgebModule.forRoot's +# applicationName option (package quirk). audit.controller.ts reads the same +# var when filtering reads, so this can be anything as long as it's set. +APPLICATION_NAME=freight-api +# Also required for @tria-plc/auditlog: its producer (AuditClientModule) +# reads the RMQ URL at package IMPORT time, before MezgebModule.forRoot's +# rmqUrl option ever runs, so only an env var reaches it — an in-code +# override is too late. Without this, audit events are silently dropped +# (no error, nothing published). Point it at whatever broker/vhost your +# RabbitMQ actually has a user provisioned on. +RABBITMQ_URL=amqp://localhost:5672 # GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables. GT06_TCP_PORT=5023 DB_HOST=localhost diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 73e19709e..adb92c3b0 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -16,6 +16,7 @@ import { import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; +import { MezgebModule } from "@tria-plc/auditlog"; import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; @@ -105,9 +106,14 @@ import { LastMileModule } from "./modules/last-mile/last-mile.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; +import { AuditModule } from "./modules/audit/audit.module"; import { LoggerMiddleware } from "./logger.middleware"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; +if (!process.env.APPLICATION_NAME) { + process.env.APPLICATION_NAME = "freight"; +} + @Module({ imports: [ ConfigModule.forRoot({ @@ -155,6 +161,19 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar return dataSource; }, }), + // Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog). + // Must come after TypeOrmModule above so it picks up this app's DataSource. + // rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL + // does: the dev broker only provisions the `edr` user on the `payment` + // vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset + // RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED). + MezgebModule.forRoot({ + applicationName: "freight-api", + rmqUrl: + process.env.RABBITMQ_URL ?? + process.env.PAYMENT_RABBITMQ_URL ?? + "amqp://localhost:5672", + }), SharedAuthModule, IamModule.forRoot({ applications: [EDR_FREIGHT_APPLICATION], @@ -227,6 +246,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar VerifaydaModule, FleetHistoryModule, AiModule, + AuditModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index f699b193d..5de529d15 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -56,6 +56,11 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity"; import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity"; import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity"; +import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog"; + +// @tria-plc/auditlog's entities live in node_modules, same as the iam ones — +// the glob below only matches this app's own src/**/*.entity.ts. +const auditEntities = [AuditLog, AuditLogCommand]; const iamEntities = [ UnitSetting, @@ -177,7 +182,11 @@ export function buildDataSourceOptions(): DataSourceOptions { return { ...buildConnectionOptions(), schema: "public", - entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], + entities: [ + __dirname + "/../**/*.entity.{ts,js}", + ...iamEntities, + ...auditEntities, + ], migrations: [], }; } diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index a4fbefdd2..c9a718f5d 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -10,6 +10,7 @@ import { ResponseTransformInterceptor, createValidationPipe, } from "@edr/api-common"; +import { getAuditLoggerConfig } from "@tria-plc/auditlog"; import { AppModule } from "./app.module"; @@ -160,6 +161,13 @@ export async function createFreightApp(): Promise { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); + // Audit listener: consumes the RMQ events MezgebModule's client interceptor + // (app.module.ts) emits and persists them via the AuditLogController / + // AuditLogCommandController @EventPattern handlers. Same queue config the + // client side uses, reused from the package so the two never drift apart. + app.connectMicroservice(getAuditLoggerConfig()); + await app.startAllMicroservices(); + const config = new DocumentBuilder() .setTitle("EDR Freight API") .setDescription("API for the EDR Freight Management application") diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts new file mode 100644 index 000000000..a7c8782b2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { AuditService } from "./audit.service"; + +@ApiTags("audit") +@Controller("audit") +@BookingStaff(FREIGHT_PERMS.audit.view) +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get("logs") + @ApiOperation({ summary: "List freight-api audit log commands" }) + @ApiQuery({ name: "skip", type: Number, required: false }) + @ApiQuery({ name: "take", type: Number, required: false }) + list(@Query("skip") skip?: string, @Query("take") take?: string) { + // Same fallback chain @tria-plc/auditlog's client interceptor uses to + // stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js) + // — reading it here instead of a hardcoded literal means this can't + // silently drift out of sync with whatever APPLICATION_NAME/APP_NAME + // actually is at runtime. + const application = + process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT"; + return this.auditService.list( + application, + skip !== undefined ? parseInt(skip, 10) : undefined, + take !== undefined ? parseInt(take, 10) : undefined, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.module.ts b/apps/edr-freight-api/src/modules/audit/audit.module.ts new file mode 100644 index 000000000..635973fc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { AuditLogCommand } from "@tria-plc/auditlog"; + +import { AuditController } from "./audit.controller"; +import { AuditService } from "./audit.service"; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLogCommand])], + controllers: [AuditController], + providers: [AuditService], +}) +export class AuditModule {} diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts new file mode 100644 index 000000000..8bc792591 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { AuditLogCommand } from "@tria-plc/auditlog"; + +export interface AuditLogListResult { + count: number; + items: AuditLogCommand[]; +} + +/** + * Own read path onto @tria-plc/auditlog's tables, gated by AuditController's + * @BookingStaff — the package's own AuditLogCommandController (mounted at + * /api/audit-log-commands) ships with no guards at all, so it can't be used + * directly for a permission-gated UI. Query mirrors the package's + * AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly. + */ +@Injectable() +export class AuditService { + constructor( + @InjectRepository(AuditLogCommand) + private readonly auditLogCommandRepository: Repository, + ) {} + + async list( + application: string, + skip = 0, + take = 10, + ): Promise { + const [items, count] = await this.auditLogCommandRepository + .createQueryBuilder("audit_log_commands") + .leftJoinAndSelect("audit_log_commands.auditLog", "auditLog") + .andWhere( + "(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)", + { application }, + ) + .andWhere( + "(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)", + { status: "Commit" }, + ) + .select([ + "audit_log_commands.id", + "audit_log_commands.createdAt", + "audit_log_commands.deletedAt", + "audit_log_commands.entityName", + "audit_log_commands.queryMethod", + "audit_log_commands.changes", + "audit_log_commands.payload", + "auditLog.id", + "auditLog.user", + ]) + .addOrderBy("audit_log_commands.createdAt", "DESC") + .skip(skip) + .take(take) + .getManyAndCount(); + + return { count, items }; + } +} 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 60f173596..4cd7249aa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -10,6 +10,7 @@ import { import { EventEmitter2 } from "@nestjs/event-emitter"; import { DataSource, EntityManager, In } from "typeorm"; +import { Booking } from "../bookings/entities/booking.entity"; import { CompaniesService } from "../companies/companies.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; @@ -1192,6 +1193,17 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); + // CBE_BILL: the bill reference IS the booking's PNR — the number the customer pays against + // at any CBE channel. Persist it on the booking so it survives the initiate response and + // shows on the booking/contract everywhere. The payment service reissues the same reference + // while the bill stays open, so re-initiating overwrites with an identical value. + const billReference = result.response.clientAction?.billReference; + if (billReference && invoice.source === Freight.InvoiceSource.Booking) { + await this.dataSource + .getRepository(Booking) + .update({ id: invoice.sourceId }, { pnrCode: billReference }); + } + // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept for local demos only. // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index c30a2ebda..0c44e704d 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -366,6 +366,7 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'), perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'), perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'), + perm('b4c00001-0001-4000-8000-000000000001', 'edr_freight_app:audit:view', 'View audit logs'), ]; // M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment @@ -700,6 +701,9 @@ export const FREIGHT_PERMS = { manage: 'edr_freight_app:settings:dropdown:manage', }, }, + audit: { + view: 'edr_freight_app:audit:view', + }, staff: { roles: { view: 'edr_freight_app:staff:roles:view', diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6e0b5759f..462414480 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -7,6 +7,7 @@ import { FileSignature, FileText, Hammer, + History, LayoutDashboard, LayoutGrid, MapPin, @@ -76,6 +77,7 @@ import ReportsHubPage from "./pages/reports/ReportsHubPage"; import ReportPage from "./pages/reports/ReportPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; import PaymentsPage from "./pages/payments/PaymentsPage"; +import AuditLogsPage from "./pages/audit/AuditLogsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; import { @@ -582,6 +584,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.audit.view, + }, { label: "Configuration", href: "/dashboard/configuration", @@ -1595,6 +1603,14 @@ const App = () => { } /> + + + + } + /> = [ subtitle: "Manage dropdown options used across the platform", }, }, + { + prefix: "/dashboard/audit-logs", + meta: { + title: "Audit Logs", + subtitle: "Request and entity-level activity recorded across the freight API", + }, + }, { prefix: "/dashboard/configuration/contract-validity-periods", meta: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d8993df04..dbeb641cf 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -309,6 +309,10 @@ export const URL_CONSTANTS = { SUMMARY: "/payments/summary", }, + AUDIT: { + LOGS: "/audit/logs", + }, + LOCOMOTIVES: { BASE: "/locomotives", BY_ID: (id: string) => `/locomotives/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index bc70c95a2..9596dc4de 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -276,6 +276,9 @@ export const FREIGHT_PERMS = { manage: "edr_freight_app:settings:dropdown:manage", }, }, + audit: { + view: "edr_freight_app:audit:view", + }, staff: { roles: { view: "edr_freight_app:staff:roles:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx new file mode 100644 index 000000000..d4451c2f2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx @@ -0,0 +1,185 @@ +import { Badge, Box, Card, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { api } from "@/services/api"; +import type { + AuditLogRow, + AuditQueryMethod, + AuditUser, +} from "@/services/audit.service"; +import { + DataTable, + DataTableFooter, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; + +const ACTION_LABELS: Record = { + INSERT: "Created", + UPDATE: "Updated", + DELETE: "Deleted", + INSERT_CHILD: "Linked child", + DELETE_CHILD: "Unlinked child", +}; + +const ACTION_COLORS: Record = { + INSERT: "edr-green", + UPDATE: "yellow", + DELETE: "red", + INSERT_CHILD: "indigo", + DELETE_CHILD: "gray", +}; + +function formatDateTime(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +// The producer (@tria-plc/auditlog's ClientLoggerInterceptor) builds +// `name` from `${auditUser?.firstName} ${auditUser?.lastName}` — this app's +// user model only has a single `name` field, and unauthenticated/customer +// flows (e.g. Fayda verification) have no auditUser at all, so this literal +// "undefined undefined" ends up stored as-is. Filter it back out on render +// rather than showing raw garbage. +function formatUser(user: AuditUser | null | undefined): string { + const name = user?.name; + if (typeof name === "string" && /^undefined(\s+undefined)?$/.test(name.trim())) { + return "—"; + } + return name ?? user?.id ?? "—"; +} + +function summarize(row: AuditLogRow): string { + if (row.changes?.length) { + return row.changes + .slice(0, 2) + .map((c) => c.field) + .join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : ""); + } + if (row.payload) { + return row.payload.name ?? row.payload.title ?? row.payload.id ?? "—"; + } + return "—"; +} + +const tableHeader = + "text-xs font-semibold uppercase tracking-wide text-muted-foreground"; + +export default function AuditLogsPage() { + const { pagination, setPagination } = usePagination({ pageSize: 20 }); + + const filter = { + skip: pagination.pageIndex * pagination.pageSize, + take: pagination.pageSize, + }; + + const { data, isLoading, isError } = useQuery( + api.audit.list.queryOptions({ input: { filter } }), + ); + + const rows = data?.items ?? []; + const total = data?.count ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const columns: ColumnDef[] = [ + { + id: "time", + header: () => Time, + cell: ({ row }) => ( + + {formatDateTime(row.original.createdAt)} + + ), + }, + { + id: "action", + header: () => Action, + cell: ({ row }) => ( + + {ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod} + + ), + }, + { + id: "entity", + header: () => Entity, + cell: ({ row }) => ( + + {row.original.entityName} + + ), + }, + { + id: "user", + header: () => User, + cell: ({ row }) => ( + + {formatUser(row.original.auditLog?.user)} + + ), + }, + { + id: "summary", + header: () => Summary, + cell: ({ row }) => ( + + {summarize(row.original)} + + ), + }, + ]; + + return ( + + + + + + + + {total} record{total !== 1 ? "s" : ""} + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index a2d5f1b76..2aa864610 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -163,6 +163,11 @@ import { type SaveLocomotivePayload, } from "./locomotives.service"; import { overviewService } from "./overview.service"; +import { + auditService, + type AuditLogListFilter, + type PaginatedAuditLogs, +} from "./audit.service"; import { reportsService } from "./reports.service"; import type { ReportQueryInput, ReportResult } from "@/types/reports"; import { @@ -2136,6 +2141,15 @@ export const api = { ), }, + audit: { + list: endpoint<{ filter?: AuditLogListFilter }, PaginatedAuditLogs>( + "audit", + "list", + ({ filter }) => auditService.list(filter), + ({ filter }) => ["audit", "list", filter ?? {}], + ), + }, + signatures: { mySignature: endpoint( "me", diff --git a/apps/edr-freight-web/backoffice/src/services/audit.service.ts b/apps/edr-freight-web/backoffice/src/services/audit.service.ts new file mode 100644 index 000000000..498d1c77c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/audit.service.ts @@ -0,0 +1,61 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; + +const A = URL_CONSTANTS.AUDIT; + +// Shape from @tria-plc/auditlog's AuditLogCommandController — see +// local-packages/FRONTEND_GUIDE.md. +export type AuditQueryMethod = + | "INSERT" + | "UPDATE" + | "DELETE" + | "INSERT_CHILD" + | "DELETE_CHILD"; + +export interface AuditFieldChange { + field: string; + from: unknown; + to: unknown; +} + +export interface AuditUser { + id?: string; + name?: string; + organizationId?: string; + organizationName?: string; + [key: string]: unknown; +} + +export interface AuditLogRow { + id?: string; + createdAt: string; + deletedAt?: string | null; + entityName: string; + queryMethod: AuditQueryMethod; + changes?: AuditFieldChange[] | null; + payload?: { name?: string; title?: string; id?: string } | null; + auditLog?: { id?: string; user?: AuditUser | null }; +} + +export interface AuditLogListFilter { + skip?: number; + take?: number; +} + +export interface PaginatedAuditLogs { + items: AuditLogRow[]; + count: number; +} + +export const auditService = { + list: async (filter?: AuditLogListFilter): Promise => { + const params: Record = { + skip: filter?.skip, + take: filter?.take, + }; + const response = await client.get(A.LOGS, { params }); + const data = unwrap(response.data) as PaginatedAuditLogs; + return { items: data.items ?? [], count: data.count ?? 0 }; + }, +}; diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts index 715184424..acbfef4ee 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts @@ -24,7 +24,11 @@ describe("IntentsService CBE_BILL", () => { let repository: jest.Mocked< Pick< IntentsRepository, - "create" | "findById" | "findByIdempotencyKey" | "update" + | "create" + | "findById" + | "findByIdempotencyKey" + | "findAllByReference" + | "update" > >; let billReferenceService: { generate: jest.Mock }; @@ -46,6 +50,7 @@ describe("IntentsService CBE_BILL", () => { create: jest.fn(async (data) => ({ id: "intent-1", ...data })), findById: jest.fn(), findByIdempotencyKey: jest.fn().mockResolvedValue(null), + findAllByReference: jest.fn().mockResolvedValue([]), update: jest.fn(), } as never; billReferenceService = { @@ -79,6 +84,47 @@ describe("IntentsService CBE_BILL", () => { ); }); + it("reuses the open bill instead of minting a second reference", async () => { + repository.findAllByReference.mockResolvedValue([ + { + id: "intent-1", + provider: ProviderMethod.CBE_BILL, + status: ProviderPaymentStatus.REQUIRES_ACTION, + billReference: "000100000015", + amountMinor: 1500, + currency: "ETB", + clientAction: { + type: "SHOW_BILL_REFERENCE", + billReference: "000100000015", + }, + }, + ] as never); + + const snapshot = await service.initiate(request); + + expect(snapshot.billReference).toBe("000100000015"); + expect(billReferenceService.generate).not.toHaveBeenCalled(); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it("mints a new bill when the amount changed", async () => { + repository.findAllByReference.mockResolvedValue([ + { + id: "intent-1", + provider: ProviderMethod.CBE_BILL, + status: ProviderPaymentStatus.REQUIRES_ACTION, + billReference: "000100000015", + amountMinor: 900, + currency: "ETB", + }, + ] as never); + billReferenceService.generate.mockResolvedValue("000100000023"); + + const snapshot = await service.initiate(request); + + expect(snapshot.billReference).toBe("000100000023"); + }); + it("rejects non-ETB currency (plan D8)", async () => { await expect( service.initiate({ ...request, currency: "DJF" }), diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index c0b246b83..0f5b41f4e 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -163,6 +163,32 @@ export class IntentsService { ); } + // The bill reference is issued ONCE per order: the domain app persists it (freight stores it + // as the booking's PNR) and the payer may already have written it down, so re-initiating the + // same open bill must hand back the same number. A different amount/currency means a + // different debt — /cbe/payment verifies the debited amount against the intent — so that + // case mints a fresh bill instead of silently repricing an outstanding one. + const open = ( + await this.intentsRepository.findAllByReference( + request.service, + request.referenceType, + request.referenceId, + ) + ).find( + (i) => + i.provider === ProviderMethod.CBE_BILL && + i.status === ProviderPaymentStatus.REQUIRES_ACTION && + !!i.billReference && + i.amountMinor === request.amountMinor && + i.currency === request.currency, + ); + if (open) { + this.logger.log( + `intent ${open.id} reused for ${request.service}/${request.referenceType}/${request.referenceId} via CBE_BILL (bill ${open.billReference})`, + ); + return this.toSnapshot(open); + } + const merchantOrderId = createMerchantOrderId(); const billReference = await this.billReferenceService.generate(); // expiresAt is the BOOKING's payment deadline passed by the domain app — never a provider From f7d7bab702708eb66cb09d38c803a66acb80da08 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 14:19:45 +0000 Subject: [PATCH 8/9] add log for payment --- .../src/modules/cbe-bill/cbe-bill.service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts index 9f60ab910..9389280c5 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -169,6 +169,14 @@ export class CbeBillService { async pay(dto: CbePaymentRequestDto): Promise { this.assertEnabled(); + this.logger.log({ + msg: "cbe.payment.request", + billId: dto.Bill_Id, + endToEndTxnId: dto.End_To_End_Txn_Id, + cbeTxnRef: dto.Cbe_Txn_Ref, + request: dto, + }); + // §6.5 idempotency on CBE's per-attempt id, in order. const prior = await this.cbeBillRepository.findByEndToEndTxnId( dto.End_To_End_Txn_Id, From 2133d6a574e48e817c63686a9827d386a70cf645 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 5 Aug 2026 14:23:00 +0000 Subject: [PATCH 9/9] fix: fitler out the client side request --- .../src/modules/audit/audit.service.ts | 11 +++++++ .../src/pages/audit/AuditLogsPage.tsx | 29 +++++++++++-------- .../backoffice/src/services/audit.service.ts | 13 +++++++-- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts index 8bc792591..04ea4beed 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.service.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -3,6 +3,8 @@ import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { AuditLogCommand } from "@tria-plc/auditlog"; +import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware"; + export interface AuditLogListResult { count: number; items: AuditLogCommand[]; @@ -38,6 +40,15 @@ export class AuditService { "(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)", { status: "Commit" }, ) + // Backoffice-only view: portal (customer-facing) writes carry the same + // request-header set by every axios call from that app — see + // login-audience.middleware.ts. Rows with no linked auditLog (child/ + // event commands with no request context) stay visible; they aren't + // attributable to any frontend, so they're not portal noise either. + .andWhere( + "(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)", + { clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" }, + ) .select([ "audit_log_commands.id", "audit_log_commands.createdAt", diff --git a/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx index d4451c2f2..ef3412a3a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx @@ -7,6 +7,7 @@ import type { AuditLogRow, AuditQueryMethod, AuditUser, + LocalizedText, } from "@/services/audit.service"; import { DataTable, @@ -44,18 +45,20 @@ function formatDateTime(iso: string): string { }); } -// The producer (@tria-plc/auditlog's ClientLoggerInterceptor) builds -// `name` from `${auditUser?.firstName} ${auditUser?.lastName}` — this app's -// user model only has a single `name` field, and unauthenticated/customer -// flows (e.g. Fayda verification) have no auditUser at all, so this literal -// "undefined undefined" ends up stored as-is. Filter it back out on render -// rather than showing raw garbage. +// See LocalizedText: `name`/`title` lifted from a raw audited entity can be +// a plain string or IAM's { am, en } — never render either directly. +// "undefined undefined" is the producer's own broken template when no user +// was attached at all (unauthenticated/customer flows, e.g. Fayda +// verification) — filtered out here rather than shown as raw garbage. +function localize(value: LocalizedText | null | undefined): string | undefined { + if (!value) return undefined; + if (typeof value === "object") return value.en ?? value.am ?? undefined; + if (/^undefined(\s+undefined)?$/.test(value.trim())) return undefined; + return value; +} + function formatUser(user: AuditUser | null | undefined): string { - const name = user?.name; - if (typeof name === "string" && /^undefined(\s+undefined)?$/.test(name.trim())) { - return "—"; - } - return name ?? user?.id ?? "—"; + return localize(user?.name) ?? user?.id ?? "—"; } function summarize(row: AuditLogRow): string { @@ -66,7 +69,9 @@ function summarize(row: AuditLogRow): string { .join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : ""); } if (row.payload) { - return row.payload.name ?? row.payload.title ?? row.payload.id ?? "—"; + return ( + localize(row.payload.name) ?? localize(row.payload.title) ?? row.payload.id ?? "—" + ); } return "—"; } diff --git a/apps/edr-freight-web/backoffice/src/services/audit.service.ts b/apps/edr-freight-web/backoffice/src/services/audit.service.ts index 498d1c77c..ed0642d83 100644 --- a/apps/edr-freight-web/backoffice/src/services/audit.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/audit.service.ts @@ -19,9 +19,18 @@ export interface AuditFieldChange { to: unknown; } +// IAM entities (users, orgs, positions, ...) name themselves bilingually — +// see edr-org.seeder.ts. Any `name`/`title` field lifted from a raw audited +// entity (auditLog.user, payload) can come back as either a plain string or +// this shape; both `name` fields below reflect that. +export type LocalizedText = string | { am?: string; en?: string }; + +// The vendored interceptor's own broken template produces a plain string +// ("undefined undefined") when no user was attached at all (unauthenticated/ +// customer flows) — that's the non-bilingual string case for `name` here. export interface AuditUser { id?: string; - name?: string; + name?: LocalizedText; organizationId?: string; organizationName?: string; [key: string]: unknown; @@ -34,7 +43,7 @@ export interface AuditLogRow { entityName: string; queryMethod: AuditQueryMethod; changes?: AuditFieldChange[] | null; - payload?: { name?: string; title?: string; id?: string } | null; + payload?: { name?: LocalizedText; title?: LocalizedText; id?: string } | null; auditLog?: { id?: string; user?: AuditUser | null }; }