diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 78f8db410..de7a01484 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -210,6 +210,7 @@ export class CompaniesController { const data = await this.companiesService.fetchETradeData( dto.tin, companyId, + dto.licenceNumber, ); return new ETradeResponseDto(data); } 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 0ed91c9e3..5dd55e704 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -3541,9 +3541,10 @@ export class CompaniesService { /** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */ private async resolveEtradeRegistration( tin: string, + licenceNumber?: string, ): Promise { const { businessInfo, companyInfo } = - await this.etradeService.resolveCompanyData(tin); + await this.etradeService.resolveCompanyData(tin, licenceNumber); if (!businessInfo) { throw new BadRequestException( "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", @@ -3555,8 +3556,15 @@ export class CompaniesService { ); } - async fetchETradeData(tin: string, excludeCompanyId?: string) { - const registrationData = await this.resolveEtradeRegistration(tin); + async fetchETradeData( + tin: string, + excludeCompanyId?: string, + licenceNumber?: string, + ) { + const registrationData = await this.resolveEtradeRegistration( + tin, + licenceNumber, + ); const tinTaken = await this.companiesRepo.existsByTin( tin, excludeCompanyId, @@ -3583,7 +3591,13 @@ export class CompaniesService { if (!touched) return; const tin = dto.tin ?? company.tin; - const registration = await this.resolveEtradeRegistration(tin); + // Re-verify the licence the customer actually chose. Without it a TIN + // holding several licences would silently snap back to eTrade's first one on + // every save, overwriting the selection with a different business's record. + const registration = await this.resolveEtradeRegistration( + tin, + dto.licenceNumber ?? company.licenceNumber ?? undefined, + ); const fresh: Partial< Record<(typeof ETRADE_SOURCED_FIELDS)[number], string> > = { diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index bfe1f9b72..40b1f308e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -1,4 +1,4 @@ -import { CompanyRegistrationData } from "@edr/types"; +import { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types"; export class ETradeResponseDto implements CompanyRegistrationData { companyName!: string; @@ -19,6 +19,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { managerEmail?: string; managerPhone!: string; tinTaken?: boolean; + businesses?: ETradeBusinessOption[]; constructor(data: CompanyRegistrationData) { this.companyName = data.companyName; @@ -39,5 +40,6 @@ export class ETradeResponseDto implements CompanyRegistrationData { this.managerEmail = data.managerEmail; this.managerPhone = data.managerPhone; this.tinTaken = data.tinTaken; + this.businesses = data.businesses; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts index 466c03ed6..9ca533835 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty } from "class-validator"; +import { IsString, IsNotEmpty, IsOptional, MaxLength } from "class-validator"; import { IsTin } from "../../../common/validators/is-tin.validator"; export class FetchETradeDto { @@ -6,4 +6,14 @@ export class FetchETradeDto { @IsNotEmpty() @IsTin({ message: "TIN must be exactly 10 digits" }) tin!: string; + + /** + * Which of the TIN's business licences to resolve. Omitted on the first + * lookup — the response lists them all so the customer can pick, and the pick + * comes back here. + */ + @IsOptional() + @IsString() + @MaxLength(100) + licenceNumber?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 214c3858a..c89326fa1 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -36,13 +36,13 @@ export class UpdateProfileDto { @IsTin({ message: "TIN must be exactly 10 digits" }) tin?: string; - // Ethiopian VAT registration numbers are 10 digits, the same shape as the - // TIN. Both portal forms enforce that; without it here the API happily stored - // whatever a stale client sent, and the two layers disagreed about what the - // column may hold. + // Ethiopian VAT registration numbers are 10 digits (the same shape as the + // TIN), but some are issued with an 11th. Both portal forms enforce the same + // range; without it here the API happily stored whatever a stale client sent, + // and the two layers disagreed about what the column may hold. @IsOptional() @IsString() - @Matches(/^\d{10}$/, { message: "VAT number must be exactly 10 digits" }) + @Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" }) vatNumber?: string; // `fanNumber` is deliberately absent: the FAN is the Fayda number of the diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts new file mode 100644 index 000000000..0719d7fe8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts @@ -0,0 +1,87 @@ +import { ETradeService } from './etrade.service'; +import type { ETradeBusinessInfo, ETradeCompanyInfo } from '@edr/types'; + +/** + * A TIN routinely holds several business licences (import of vehicles, export of + * coffee, freight forwarding…), all under the same trade name. The customer + * picks one, and every later lookup has to resolve that same licence — snapping + * back to eTrade's first would silently swap their company record. + */ +const companyInfo = (): ETradeCompanyInfo => + ({ + Tin: '0045014036', + BusinessName: 'PAVE LOGISTICS AND TRADING P L C', + Businesses: [ + { + LicenceNumber: 'MT/AA/14/670/11551235/2017', + TradesName: 'PAVE LOGISTICS AND TRADING P L C', + RenewedTo: '7/7/2026', + SubGroups: [ + { Code: 66331, Description: 'Export trade in minerals' }, + ], + }, + { + LicenceNumber: 'MT/AA/14/670/128936/2007', + TradesName: 'PAVE LOGISTICS AND TRADING P L C', + RenewedTo: '7/7/2026', + SubGroups: [{ Code: 72131, Description: '(72131)Freight Forwarders' }], + }, + ], + }) as unknown as ETradeCompanyInfo; + +describe('ETradeService business selection', () => { + const build = () => { + const service = new ETradeService({} as never); + const fetched: string[] = []; + jest + .spyOn(service, 'getCompanyInfoByTin') + .mockResolvedValue(companyInfo()); + jest + .spyOn(service, 'getBusinessByLicenseNo') + .mockImplementation(async (licenceNo: string) => { + fetched.push(licenceNo); + return { LicenceNumber: licenceNo } as ETradeBusinessInfo; + }); + return { service, fetched }; + }; + + it('defaults to the first licence when none is chosen', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036'); + expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']); + }); + + it('resolves the chosen licence', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036', 'MT/AA/14/670/128936/2007'); + expect(fetched).toEqual(['MT/AA/14/670/128936/2007']); + }); + + it('falls back to the first licence when the chosen one is gone', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036', 'NO/SUCH/LICENCE'); + expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']); + }); + + it('lists every licence for the picker, code prefixes stripped', () => { + const { service } = build(); + const data = service.extractRegistrationData( + { LicenceNumber: 'x' } as ETradeBusinessInfo, + companyInfo(), + ); + expect(data.businesses).toEqual([ + { + licenceNumber: 'MT/AA/14/670/11551235/2017', + tradeName: 'PAVE LOGISTICS AND TRADING P L C', + activity: 'Export trade in minerals', + renewedTo: '7/7/2026', + }, + { + licenceNumber: 'MT/AA/14/670/128936/2007', + tradeName: 'PAVE LOGISTICS AND TRADING P L C', + activity: 'Freight Forwarders', + renewedTo: '7/7/2026', + }, + ]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 3292ee2d3..45cc8e840 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -66,7 +66,15 @@ export class ETradeService { } } - async resolveCompanyData(tin: string): Promise<{ + /** + * @param licenceNumber which of the TIN's licences to resolve. Defaults to the + * first one — a TIN with several licences is only unambiguous once the + * customer has picked one (see {@link ETradeBusinessOption}). + */ + async resolveCompanyData( + tin: string, + licenceNumber?: string, + ): Promise<{ companyInfo: ETradeCompanyInfo; businessInfo: ETradeBusinessInfo | null; }> { @@ -76,10 +84,15 @@ export class ETradeService { return { companyInfo, businessInfo: null }; } - const latestBusiness = companyInfo.Businesses[0]; + // An unknown licence falls back to the first rather than 400-ing: eTrade can + // drop or renumber a licence between the customer picking it and the save + // that re-verifies it, and that must not lock them out of their own profile. + const selected = + companyInfo.Businesses.find((b) => b.LicenceNumber === licenceNumber) ?? + companyInfo.Businesses[0]; try { const businessInfo = await this.getBusinessByLicenseNo( - latestBusiness.LicenceNumber, + selected.LicenceNumber, tin, ); return { companyInfo, businessInfo }; @@ -124,6 +137,16 @@ export class ETradeService { regularPhone: businessInfo.AddressInfo?.RegularPhone || "", managerName: primaryManager?.ManagerNameEng || "", managerPhone: primaryManager?.RegularPhone || "", + businesses: (companyInfo?.Businesses ?? []).map((b) => ({ + licenceNumber: b.LicenceNumber, + tradeName: b.TradesName?.trim() || "", + activity: (b.SubGroups ?? []) + // Some descriptions repeat the code inline ("(65611)Import trade …"). + .map((g) => g.Description?.replace(/^\(\d+\)\s*/, "").trim()) + .filter(Boolean) + .join(", "), + renewedTo: b.RenewedTo || "", + })), }; } } diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index ee7b703d2..92ff7dc38 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -69,6 +69,7 @@ export class ClientActionDto { "LAUNCH_APP", "INVOKE_BRIDGE", "COLLECT_OTP", + "AWAIT_PUSH", "SHOW_BILL_REFERENCE", ], }) @@ -77,6 +78,7 @@ export class ClientActionDto { | "LAUNCH_APP" | "INVOKE_BRIDGE" | "COLLECT_OTP" + | "AWAIT_PUSH" | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) @@ -104,9 +106,16 @@ export class ClientActionDto { @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) providerOrderId?: string; - @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + @ApiPropertyOptional({ + description: "Set when type=COLLECT_OTP or type=AWAIT_PUSH", + }) message?: string; + @ApiPropertyOptional({ + description: "Set when type=AWAIT_PUSH (masked MSISDN the push prompt went to)", + }) + payerAccountMasked?: string; + @ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)", }) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index fbb5301fb..9da3cecf0 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -510,6 +510,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, }); await this.resequenceWagons(manager, train.id); await this.syncLiveScheduleAfterConsistChange( @@ -544,6 +546,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Maintenance, + importTrainNumber: null, + exportTrainNumber: null, }); // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history @@ -761,7 +765,13 @@ export class TrainBuilderService { .getRepository(Wagon) .update( { trainId: train.id }, - { trainId: null, sequenceNumber: null, status: WagonStatus.Available }, + { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, + }, ); await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); await manager.getRepository(Train).remove(train); @@ -1021,6 +1031,10 @@ export class TrainBuilderService { trainId: train.id, sequenceNumber: sequence, status: WagonStatus.Assigned, + // Wagon inherits the train's run numbers on coupling — no per-wagon + // number entry, they ride whatever numbers the train was built with. + importTrainNumber: train.importTrainNumber, + exportTrainNumber: train.exportTrainNumber, }); } return toAttach; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 6bea10a8c..06d9a20ab 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -102,6 +102,7 @@ const FleetResourcePage = () => { const currentYardId = listFilterValues.currentYardId; const availability = listFilterValues.availability; const trainNumber = listFilterValues.trainNumber; + const trainId = listFilterValues.trainId; if (status && status !== "ALL") { (filters as { status?: string }).status = status; } @@ -114,6 +115,9 @@ const FleetResourcePage = () => { if (trainNumber && trainNumber !== "ALL") { (filters as { trainNumber?: string }).trainNumber = trainNumber; } + if (trainId && trainId !== "ALL") { + filters.trainId = trainId; + } // Wagons only: narrow the fleet to one wagon type (the API filters on it). const wagonTypeId = listFilterValues.wagonTypeId; if (wagonTypeId && wagonTypeId !== "ALL") { @@ -191,6 +195,11 @@ const FleetResourcePage = () => { const { data: drivers = [] } = useQuery( api.fleet.list.queryOptions({ input: { slug: "drivers" } }), ); + // Wagons-only: "Train" list filter needs every train's code to pick from. + const { data: trains = [], isLoading: trainsLoading } = useQuery({ + ...api.trains.list.queryOptions(), + enabled: slug === "wagons", + }); useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); @@ -247,6 +256,9 @@ const FleetResourcePage = () => { const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map( (y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }), ); + const trainOpts = (trains as Array<{ id: string; code: string; trainName?: string | null }>).map( + (t) => ({ value: t.id, label: t.trainName ? `${t.code} - ${t.trainName}` : t.code }), + ); // Carries capacity + trailer configuration so picking a truck type can // pre-fill the vehicle's capacity and drop the trailer plate on a rigid type. @@ -274,8 +286,9 @@ const FleetResourcePage = () => { wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts], containers: containerOpts, yards: yardOpts, + trains: trainOpts, }; - }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]); + }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]); const listFilterSelects = useMemo(() => { if (!config?.listFilters?.length) return null; @@ -327,7 +340,8 @@ const FleetResourcePage = () => { truckTypesLoading || wagonsLoading || containersLoading || - yardsLoading; + yardsLoading || + trainsLoading; const filteredRows = useMemo(() => { if (!config) return allRows; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 9ba826e6f..11ca236be 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -33,7 +33,8 @@ export type FleetDynamicOptions = | "truckTypes" | "wagons" | "containers" - | "yards"; + | "yards" + | "trains"; /** * A dynamic select option that can carry the record it came from. Picking a @@ -324,6 +325,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ allLabel: "All trains", options: TRAIN_RUN_FILTER_OPTIONS, }, + { + key: "trainId", + label: "Train", + allLabel: "All trains", + dynamicOptions: "trains", + }, ], cardTitleKey: "wagonNumber", cardSubtitleKey: "currentYard", diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx index dff9cdaa3..807745b73 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx @@ -35,6 +35,7 @@ import { STATUS_META, TransferProgress, TransferStatusBadge, + stripHtmlToText, wagonTypeLabel, yardLabel, } from "./wagon-transfer-ui"; @@ -119,9 +120,9 @@ function RequestItem({ request }: { request: WagonTransferRequest }) { {wagonTypeLabel(request.wagonType)} - {request.reason ? ( + {stripHtmlToText(request.reason) ? ( - {request.reason} + {stripHtmlToText(request.reason)} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx index 95705e964..1a08b5c54 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx @@ -10,6 +10,7 @@ import { Tabs, Text, TextInput, + UnstyledButton, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; @@ -31,6 +32,7 @@ import { useMutation } from "@tanstack/react-query"; import { useAuth } from "@/auth/useAuth"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { sanitizeHtml } from "@/shared/lib/sanitize"; import { api } from "@/services/api"; import type { TransferRequestListFilter, @@ -55,6 +57,7 @@ import { fmtDateTime, isOpenRequest, outstandingOn, + stripHtmlToText, wagonTypeLabel, yardLabel, } from "./wagon-transfer-ui"; @@ -108,6 +111,9 @@ export default function WagonTransfersPage() { const [closingShort, setClosingShort] = useState( null, ); + const [viewingReason, setViewingReason] = useState( + null, + ); const filter: TransferRequestListFilter = useMemo( () => ({ @@ -197,11 +203,29 @@ export default function WagonTransfersPage() { { id: "reason", header: () => Reason, - cell: ({ row }) => ( - - {row.original.reason || "—"} - - ), + cell: ({ row }) => { + const text = stripHtmlToText(row.original.reason); + return text ? ( + setViewingReason(row.original)} + data-stop-row-click + > + + {text} + + + ) : ( + + — + + ); + }, }, { id: "filed", @@ -522,6 +546,33 @@ export default function WagonTransfersPage() { )} + setViewingReason(null)} + radius="md" + title="Reason" + > + {!viewingReason ? null : ( + + + {yardLabel(viewingReason.fromYard)}{" "} + {" "} + {yardLabel(viewingReason.toYard)} ·{" "} + {wagonTypeLabel(viewingReason.wagonType)} ·{" "} + {viewingReason.quantity} wagon(s) + + + + )} + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx index e60480590..ea73a95d2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx @@ -3,6 +3,16 @@ import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core"; import type { WagonTransferRequest } from "@/services/wagon.service"; +/** Reason/note fields come from a rich-text editor and store HTML — this + * gives a plain-text preview for list/table contexts (full formatting is + * shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */ +export const stripHtmlToText = (html?: string | null): string => + (html ?? "") + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + export const yardLabel = (y?: { label?: string; code?: string } | null) => y?.label || y?.code || "—"; diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 74e267a30..56273351d 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -1,7 +1,17 @@ -import { Alert, Button, Loader, Stack, TextInput } from "@mantine/core"; -import { useEffect, useRef } from "react"; +import { + Alert, + Button, + Card, + Group, + Loader, + Radio, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useEffect, useRef, useState } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, Download } from "lucide-react"; +import { AlertCircle, Building2, Download } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; @@ -12,6 +22,8 @@ export type ETradeStatus = | "verified" | "not-found" | "taken" + /** eTrade returned several business licences; the customer must pick one. */ + | "choose-business" | "error"; interface ETradeInfoProps { @@ -36,6 +48,12 @@ interface ETradeInfoProps { * stays available for a deliberate re-verify. */ alreadyVerified?: boolean; + /** + * The licence this company already operates under, if any. Pre-selects it in + * the picker so a deliberate re-verify refreshes that same business rather + * than silently snapping to eTrade's first one. + */ + selectedLicenceNumber?: string; } // Digits, not just length: a 10-character non-numeric TIN used to fire a lookup @@ -51,6 +69,7 @@ export default function ETradeInfo({ onStatusChange, onReset, alreadyVerified, + selectedLicenceNumber, }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; @@ -61,14 +80,39 @@ export default function ETradeInfo({ // user has typed a different TIN and overwrite its fields with stale data. const requestIdRef = useRef(0); - const handleFetch = async () => { + // Which of the TIN's licences the customer operates as. A ref alongside the + // state because handleFetch is called from an effect that doesn't re-run on + // this value. + const [licence, setLicence] = useState( + selectedLicenceNumber || null, + ); + const licenceRef = useRef(licence); + licenceRef.current = licence; + + const handleFetch = async (chosen = licenceRef.current) => { if (!isValidTin(tin)) return; const requestId = ++requestIdRef.current; - const result = await mutation.mutateAsync(tin); + const result = await mutation.mutateAsync({ + tin, + licenceNumber: chosen ?? undefined, + }); if (requestIdRef.current !== requestId) return; - if (result && !result.tinTaken) { - onDataLoaded(result); - } + if (!result || result.tinTaken) return; + // Several licences and no pick yet: the registration data describes only + // eTrade's first one, so it must not be adopted as this company's record + // until the customer says which business they're acting as. + if (!chosen && (result.businesses?.length ?? 0) > 1) return; + onDataLoaded(result); + }; + + // The picker collapses to a one-line summary + "Change" once a business is + // settled on; it only stays open while the choice is still outstanding. + const [pickerOpen, setPickerOpen] = useState(false); + + const handleChooseBusiness = (value: string) => { + setLicence(value); + setPickerOpen(false); + handleFetch(value); }; // Auto-fetch as soon as the TIN reaches its full 10-digit length — only @@ -87,8 +131,12 @@ export default function ETradeInfo({ if (tin !== lastFetchedTin.current) { // TIN moved away from whatever we last fetched — that result (verified // data, "taken", or an error) no longer describes this TIN. Drop it so - // the UI doesn't keep showing the previous TIN's outcome. + // the UI doesn't keep showing the previous TIN's outcome. The licence pick + // belongs to the old TIN too, so it goes with it (via the ref as well, so + // the fetch below doesn't reuse it before the state lands). requestIdRef.current++; + licenceRef.current = null; + setLicence(null); if (mutation.data || mutation.error) { mutation.reset(); onReset?.(); @@ -116,17 +164,28 @@ export default function ETradeInfo({ : apiError.message : null; + const businesses = mutation.data?.businesses ?? []; + const chosenBusiness = businesses.find((b) => b.licenceNumber === licence); + // More than one licence and none of them picked: the lookup succeeded but + // this company's record is still undecided, so it must not read as verified. + // Matched against the list rather than `licence` alone — a saved licence + // eTrade no longer lists is not a choice among what it offers today. + const needsChoice = businesses.length > 1 && !chosenBusiness; + const showPicker = needsChoice || pickerOpen; + const status: ETradeStatus = isLoading ? "loading" : tinTaken ? "taken" - : mutation.isSuccess && mutation.data && !mutation.data.tinTaken - ? "verified" - : notFound - ? "not-found" - : errorMessage - ? "error" - : "idle"; + : needsChoice + ? "choose-business" + : mutation.isSuccess && mutation.data && !mutation.data.tinTaken + ? "verified" + : notFound + ? "not-found" + : errorMessage + ? "error" + : "idle"; const lastReportedStatus = useRef(null); useEffect(() => { @@ -142,7 +201,11 @@ export default function ETradeInfo({ const willAutoFetch = isValidTin(tin) && lastFetchedTin.current !== tin; const showLoading = isLoading || willAutoFetch; - const showRetry = isValidTin(tin) && status !== "verified" && !showLoading; + const showRetry = + isValidTin(tin) && + status !== "verified" && + status !== "choose-business" && + !showLoading; return ( @@ -170,7 +233,7 @@ export default function ETradeInfo({ className="max-w-none" variant="filled" color="edr-green" - onClick={handleFetch} + onClick={() => handleFetch()} disabled={!isValidTin(tin)} leftSection={} > @@ -179,6 +242,91 @@ export default function ETradeInfo({ )} + {businesses.length > 1 && !showPicker && chosenBusiness && ( + + + + + + + {chosenBusiness.activity || + chosenBusiness.tradeName || + chosenBusiness.licenceNumber} + + + + {chosenBusiness.licenceNumber} + + {chosenBusiness.renewedTo && ( + + · valid to {chosenBusiness.renewedTo} + + )} + + + + + + + )} + + {businesses.length > 1 && showPicker && ( + + } + color={needsChoice ? "yellow" : "blue"} + title={ + needsChoice + ? `This TIN holds ${businesses.length} business licences` + : "Change business" + } + > + Pick the business you're registering as — its licence and registered + address become this account's record. + + + + {businesses.map((b) => ( + + + + {b.activity || b.tradeName || b.licenceNumber} + + + + {b.licenceNumber} + + {b.renewedTo && ( + + · valid to {b.renewedTo} + + )} + + + } + /> + + ))} + + + + )} + {notFound && ( } diff --git a/apps/edr-freight-web/portal/src/hooks/useETradeData.ts b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts index 9fbce53d9..e764ecad5 100644 --- a/apps/edr-freight-web/portal/src/hooks/useETradeData.ts +++ b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts @@ -3,10 +3,17 @@ import { companiesService } from "@/services/companies.service"; import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; +/** + * `licenceNumber` picks which of the TIN's business licences to resolve — a TIN + * routinely holds several, and the customer says which one they operate as. + */ export function useETradeData() { return useMutation({ - mutationFn: async (tin: string): Promise => { - return companiesService.fetchETradeInfo({ tin }); + mutationFn: async (vars: { + tin: string; + licenceNumber?: string; + }): Promise => { + return companiesService.fetchETradeInfo(vars); }, onError: (error) => { const { message } = extractApiError(error); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 21956c9a1..92e1a7118 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -989,7 +989,9 @@ export default function CompanyProfileForm({ } if (step === "company" && !tinVerified) { setSaveError( - "We need to confirm your TIN with eTrade before continuing.", + tinStatus === "choose-business" + ? "This TIN holds more than one business licence — pick the one you're registering as." + : "We need to confirm your TIN with eTrade before continuing.", ); return; } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx index 2634fed0d..4469009ed 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx @@ -1,70 +1,27 @@ -import { Badge, Card, Group, Select, SimpleGrid, Text, TextInput } from "@mantine/core"; +import { Badge, Card, Group, SimpleGrid, Text } from "@mantine/core"; import { CheckCircle2 } from "lucide-react"; -import { Controller } from "react-hook-form"; -import type { - Control, - FieldErrors, - UseFormRegister, - UseFormWatch, -} from "react-hook-form"; -import { ETHIOPIAN_REGIONS } from "@edr/types"; +import type { UseFormWatch } from "react-hook-form"; import type { FormData } from "./schema"; import { ReadOnlyField } from "./ReadOnlyField"; /** - * One field of the verified-registration card: locked read-only once eTrade - * supplied a value, but falls back to an editable input when eTrade left it - * blank — otherwise a gap in eTrade's own data would leave the field - * permanently empty and the user stuck (zod requires all of these). + * The verified eTrade record, rendered strictly read-only. * - * A value that fails validation unlocks the same way. eTrade (or a row saved - * before the current rules) can supply something the schema rejects, and a - * rejected value rendered read-only is a step that can never be completed and - * never says why. + * Nothing here is typeable — not even a field eTrade left blank. These values + * are the government's record of the company, so a customer-typed substitute + * would be an unverified claim wearing the badge of a verified one. A gap stays + * a visible gap ("—"), and the schema no longer requires these fields, so it + * cannot block the step either. */ -function LockedField({ - label, - name, - register, - watch, - errors, -}: { - label: string; - name: keyof FormData; - register: UseFormRegister; - watch: UseFormWatch; - errors: FieldErrors; -}) { - const value = watch(name) as string | undefined; - if (value && value.trim() && !errors[name]) { - return ; - } - return ( - - ); -} - export default function ETradeCompanyCard({ tin, - register, watch, - errors, - control, }: { tin: string; - register: UseFormRegister; watch: UseFormWatch; - errors: FieldErrors; - control: Control; }) { const companyName = watch("companyName"); - const region = watch("region"); return ( @@ -88,73 +45,18 @@ export default function ETradeCompanyCard({ - + - {/* Membership of the catalog, not mere presence: eTrade's normalizer - returns null for a region it doesn't recognise, and older rows can - hold a spelling that isn't in the list. Showing such a value - read-only left the customer with a required field they could not - correct. */} - {(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? ( - - ) : ( - ( - ({ value: r, label: r }))} - error={error} - value={field.value || null} - onChange={(v) => field.onChange(v ?? "")} - onBlur={field.onBlur} - /> - )} - /> - ); -} diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 58de2bd16..d6a78f1af 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -480,7 +480,10 @@ export const companiesService = { }, /** Fetch company registration data from eTrade by TIN. */ - fetchETradeInfo: async (payload: { tin: string }): Promise => { + fetchETradeInfo: async (payload: { + tin: string; + licenceNumber?: string; + }): Promise => { const response = await client.post>( URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO, payload, diff --git a/packages/types/src/freight/etrade.ts b/packages/types/src/freight/etrade.ts index 67a7de28b..6f33567e4 100644 --- a/packages/types/src/freight/etrade.ts +++ b/packages/types/src/freight/etrade.ts @@ -55,10 +55,24 @@ export interface ETradeCompanyInfo { RenewedFrom: string; RenewedTo: string; BusinessLicensingGroupMain: string | null; - SubGroups: string | null; + SubGroups: Array<{ Code: number; Description: string }> | null; }>; } +/** + * One business licence held under a TIN. A single owner routinely holds many + * (import of vehicles, export of coffee, freight forwarding…), all sharing the + * same trade name — the licensed activity is what tells them apart, so that is + * what the customer picks by. + */ +export interface ETradeBusinessOption { + licenceNumber: string; + tradeName: string; + /** The licensed activities ("Import trade in …"), joined. May be empty. */ + activity: string; + renewedTo: string; +} + export interface CompanyRegistrationData { /** * The registered organization name — `ETradeCompanyInfo.BusinessName`, falling @@ -84,4 +98,11 @@ export interface CompanyRegistrationData { managerPhone: string; /** True when this TIN is already registered to an existing company. */ tinTaken?: boolean; + /** + * Every licence this TIN holds. More than one means the customer has to say + * which business they are acting as before the registration data above can be + * trusted — it describes whichever licence was selected (the first, by + * default). + */ + businesses?: ETradeBusinessOption[]; }