Merge pull request #1222 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-10 17:52:31 +03:00
committed by GitHub
19 changed files with 466 additions and 341 deletions

View File

@@ -210,6 +210,7 @@ export class CompaniesController {
const data = await this.companiesService.fetchETradeData( const data = await this.companiesService.fetchETradeData(
dto.tin, dto.tin,
companyId, companyId,
dto.licenceNumber,
); );
return new ETradeResponseDto(data); return new ETradeResponseDto(data);
} }

View File

@@ -3541,9 +3541,10 @@ export class CompaniesService {
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */ /** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
private async resolveEtradeRegistration( private async resolveEtradeRegistration(
tin: string, tin: string,
licenceNumber?: string,
): Promise<CompanyRegistrationData> { ): Promise<CompanyRegistrationData> {
const { businessInfo, companyInfo } = const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin); await this.etradeService.resolveCompanyData(tin, licenceNumber);
if (!businessInfo) { if (!businessInfo) {
throw new BadRequestException( throw new BadRequestException(
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", "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) { async fetchETradeData(
const registrationData = await this.resolveEtradeRegistration(tin); tin: string,
excludeCompanyId?: string,
licenceNumber?: string,
) {
const registrationData = await this.resolveEtradeRegistration(
tin,
licenceNumber,
);
const tinTaken = await this.companiesRepo.existsByTin( const tinTaken = await this.companiesRepo.existsByTin(
tin, tin,
excludeCompanyId, excludeCompanyId,
@@ -3583,7 +3591,13 @@ export class CompaniesService {
if (!touched) return; if (!touched) return;
const tin = dto.tin ?? company.tin; 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< const fresh: Partial<
Record<(typeof ETRADE_SOURCED_FIELDS)[number], string> Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>
> = { > = {

View File

@@ -1,4 +1,4 @@
import { CompanyRegistrationData } from "@edr/types"; import { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types";
export class ETradeResponseDto implements CompanyRegistrationData { export class ETradeResponseDto implements CompanyRegistrationData {
companyName!: string; companyName!: string;
@@ -19,6 +19,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
managerEmail?: string; managerEmail?: string;
managerPhone!: string; managerPhone!: string;
tinTaken?: boolean; tinTaken?: boolean;
businesses?: ETradeBusinessOption[];
constructor(data: CompanyRegistrationData) { constructor(data: CompanyRegistrationData) {
this.companyName = data.companyName; this.companyName = data.companyName;
@@ -39,5 +40,6 @@ export class ETradeResponseDto implements CompanyRegistrationData {
this.managerEmail = data.managerEmail; this.managerEmail = data.managerEmail;
this.managerPhone = data.managerPhone; this.managerPhone = data.managerPhone;
this.tinTaken = data.tinTaken; this.tinTaken = data.tinTaken;
this.businesses = data.businesses;
} }
} }

View File

@@ -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"; import { IsTin } from "../../../common/validators/is-tin.validator";
export class FetchETradeDto { export class FetchETradeDto {
@@ -6,4 +6,14 @@ export class FetchETradeDto {
@IsNotEmpty() @IsNotEmpty()
@IsTin({ message: "TIN must be exactly 10 digits" }) @IsTin({ message: "TIN must be exactly 10 digits" })
tin!: string; 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;
} }

View File

@@ -36,13 +36,13 @@ export class UpdateProfileDto {
@IsTin({ message: "TIN must be exactly 10 digits" }) @IsTin({ message: "TIN must be exactly 10 digits" })
tin?: string; tin?: string;
// Ethiopian VAT registration numbers are 10 digits, the same shape as the // 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 // TIN), but some are issued with an 11th. Both portal forms enforce the same
// whatever a stale client sent, and the two layers disagreed about what the // range; without it here the API happily stored whatever a stale client sent,
// column may hold. // and the two layers disagreed about what the column may hold.
@IsOptional() @IsOptional()
@IsString() @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; vatNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the // `fanNumber` is deliberately absent: the FAN is the Fayda number of the

View File

@@ -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',
},
]);
});
});

View File

@@ -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; companyInfo: ETradeCompanyInfo;
businessInfo: ETradeBusinessInfo | null; businessInfo: ETradeBusinessInfo | null;
}> { }> {
@@ -76,10 +84,15 @@ export class ETradeService {
return { companyInfo, businessInfo: null }; 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 { try {
const businessInfo = await this.getBusinessByLicenseNo( const businessInfo = await this.getBusinessByLicenseNo(
latestBusiness.LicenceNumber, selected.LicenceNumber,
tin, tin,
); );
return { companyInfo, businessInfo }; return { companyInfo, businessInfo };
@@ -124,6 +137,16 @@ export class ETradeService {
regularPhone: businessInfo.AddressInfo?.RegularPhone || "", regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
managerName: primaryManager?.ManagerNameEng || "", managerName: primaryManager?.ManagerNameEng || "",
managerPhone: primaryManager?.RegularPhone || "", 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 || "",
})),
}; };
} }
} }

View File

@@ -69,6 +69,7 @@ export class ClientActionDto {
"LAUNCH_APP", "LAUNCH_APP",
"INVOKE_BRIDGE", "INVOKE_BRIDGE",
"COLLECT_OTP", "COLLECT_OTP",
"AWAIT_PUSH",
"SHOW_BILL_REFERENCE", "SHOW_BILL_REFERENCE",
], ],
}) })
@@ -77,6 +78,7 @@ export class ClientActionDto {
| "LAUNCH_APP" | "LAUNCH_APP"
| "INVOKE_BRIDGE" | "INVOKE_BRIDGE"
| "COLLECT_OTP" | "COLLECT_OTP"
| "AWAIT_PUSH"
| "SHOW_BILL_REFERENCE"; | "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) @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)" }) @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" })
providerOrderId?: string; providerOrderId?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) @ApiPropertyOptional({
description: "Set when type=COLLECT_OTP or type=AWAIT_PUSH",
})
message?: string; message?: string;
@ApiPropertyOptional({
description: "Set when type=AWAIT_PUSH (masked MSISDN the push prompt went to)",
})
payerAccountMasked?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)", description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
}) })

View File

@@ -1,7 +1,17 @@
import { Alert, Button, Loader, Stack, TextInput } from "@mantine/core"; import {
import { useEffect, useRef } from "react"; 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 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 { useETradeData } from "@/hooks/useETradeData";
import { extractApiError } from "@/utils/result"; import { extractApiError } from "@/utils/result";
import type { CompanyRegistrationData } from "@edr/types"; import type { CompanyRegistrationData } from "@edr/types";
@@ -12,6 +22,8 @@ export type ETradeStatus =
| "verified" | "verified"
| "not-found" | "not-found"
| "taken" | "taken"
/** eTrade returned several business licences; the customer must pick one. */
| "choose-business"
| "error"; | "error";
interface ETradeInfoProps { interface ETradeInfoProps {
@@ -36,6 +48,12 @@ interface ETradeInfoProps {
* stays available for a deliberate re-verify. * stays available for a deliberate re-verify.
*/ */
alreadyVerified?: boolean; 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 // Digits, not just length: a 10-character non-numeric TIN used to fire a lookup
@@ -51,6 +69,7 @@ export default function ETradeInfo({
onStatusChange, onStatusChange,
onReset, onReset,
alreadyVerified, alreadyVerified,
selectedLicenceNumber,
}: ETradeInfoProps) { }: ETradeInfoProps) {
const mutation = useETradeData(); const mutation = useETradeData();
const isLoading = mutation.isPending; 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. // user has typed a different TIN and overwrite its fields with stale data.
const requestIdRef = useRef(0); 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<string | null>(
selectedLicenceNumber || null,
);
const licenceRef = useRef(licence);
licenceRef.current = licence;
const handleFetch = async (chosen = licenceRef.current) => {
if (!isValidTin(tin)) return; if (!isValidTin(tin)) return;
const requestId = ++requestIdRef.current; 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 (requestIdRef.current !== requestId) return;
if (result && !result.tinTaken) { if (!result || result.tinTaken) return;
onDataLoaded(result); // 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 // 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) { if (tin !== lastFetchedTin.current) {
// TIN moved away from whatever we last fetched — that result (verified // TIN moved away from whatever we last fetched — that result (verified
// data, "taken", or an error) no longer describes this TIN. Drop it so // 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++; requestIdRef.current++;
licenceRef.current = null;
setLicence(null);
if (mutation.data || mutation.error) { if (mutation.data || mutation.error) {
mutation.reset(); mutation.reset();
onReset?.(); onReset?.();
@@ -116,17 +164,28 @@ export default function ETradeInfo({
: apiError.message : apiError.message
: null; : 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 const status: ETradeStatus = isLoading
? "loading" ? "loading"
: tinTaken : tinTaken
? "taken" ? "taken"
: mutation.isSuccess && mutation.data && !mutation.data.tinTaken : needsChoice
? "verified" ? "choose-business"
: notFound : mutation.isSuccess && mutation.data && !mutation.data.tinTaken
? "not-found" ? "verified"
: errorMessage : notFound
? "error" ? "not-found"
: "idle"; : errorMessage
? "error"
: "idle";
const lastReportedStatus = useRef<ETradeStatus | null>(null); const lastReportedStatus = useRef<ETradeStatus | null>(null);
useEffect(() => { useEffect(() => {
@@ -142,7 +201,11 @@ export default function ETradeInfo({
const willAutoFetch = isValidTin(tin) && lastFetchedTin.current !== tin; const willAutoFetch = isValidTin(tin) && lastFetchedTin.current !== tin;
const showLoading = isLoading || willAutoFetch; const showLoading = isLoading || willAutoFetch;
const showRetry = isValidTin(tin) && status !== "verified" && !showLoading; const showRetry =
isValidTin(tin) &&
status !== "verified" &&
status !== "choose-business" &&
!showLoading;
return ( return (
<Stack gap="md"> <Stack gap="md">
@@ -170,7 +233,7 @@ export default function ETradeInfo({
className="max-w-none" className="max-w-none"
variant="filled" variant="filled"
color="edr-green" color="edr-green"
onClick={handleFetch} onClick={() => handleFetch()}
disabled={!isValidTin(tin)} disabled={!isValidTin(tin)}
leftSection={<Download size={16} />} leftSection={<Download size={16} />}
> >
@@ -179,6 +242,91 @@ export default function ETradeInfo({
)} )}
</div> </div>
{businesses.length > 1 && !showPicker && chosenBusiness && (
<Card padding="sm" radius="md" withBorder>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap">
<Building2 size={18} className="shrink-0 text-edr-muted" />
<Stack gap={2}>
<Text fw={600} size="sm">
{chosenBusiness.activity ||
chosenBusiness.tradeName ||
chosenBusiness.licenceNumber}
</Text>
<Group gap="xs">
<Text size="xs" c="edr-muted" ff="monospace">
{chosenBusiness.licenceNumber}
</Text>
{chosenBusiness.renewedTo && (
<Text size="xs" c="edr-muted">
· valid to {chosenBusiness.renewedTo}
</Text>
)}
</Group>
</Stack>
</Group>
<Button
size="xs"
variant="subtle"
onClick={() => setPickerOpen(true)}
disabled={isLoading}
>
Change
</Button>
</Group>
</Card>
)}
{businesses.length > 1 && showPicker && (
<Stack gap="sm">
<Alert
icon={<Building2 size={16} />}
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.
</Alert>
<Radio.Group
value={licence}
onChange={handleChooseBusiness}
aria-label="Business licence"
>
<Stack gap="xs">
{businesses.map((b) => (
<Card key={b.licenceNumber} padding="sm" radius="md" withBorder>
<Radio
value={b.licenceNumber}
disabled={isLoading}
label={
<Stack gap={2}>
<Text fw={600} size="sm">
{b.activity || b.tradeName || b.licenceNumber}
</Text>
<Group gap="xs">
<Text size="xs" c="edr-muted" ff="monospace">
{b.licenceNumber}
</Text>
{b.renewedTo && (
<Text size="xs" c="edr-muted">
· valid to {b.renewedTo}
</Text>
)}
</Group>
</Stack>
}
/>
</Card>
))}
</Stack>
</Radio.Group>
</Stack>
)}
{notFound && ( {notFound && (
<Alert <Alert
icon={<AlertCircle size={16} />} icon={<AlertCircle size={16} />}

View File

@@ -3,10 +3,17 @@ import { companiesService } from "@/services/companies.service";
import { extractApiError } from "@/utils/result"; import { extractApiError } from "@/utils/result";
import type { CompanyRegistrationData } from "@edr/types"; 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() { export function useETradeData() {
return useMutation({ return useMutation({
mutationFn: async (tin: string): Promise<CompanyRegistrationData> => { mutationFn: async (vars: {
return companiesService.fetchETradeInfo({ tin }); tin: string;
licenceNumber?: string;
}): Promise<CompanyRegistrationData> => {
return companiesService.fetchETradeInfo(vars);
}, },
onError: (error) => { onError: (error) => {
const { message } = extractApiError(error); const { message } = extractApiError(error);

View File

@@ -989,7 +989,9 @@ export default function CompanyProfileForm({
} }
if (step === "company" && !tinVerified) { if (step === "company" && !tinVerified) {
setSaveError( 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; return;
} }

View File

@@ -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 { CheckCircle2 } from "lucide-react";
import { Controller } from "react-hook-form"; import type { UseFormWatch } from "react-hook-form";
import type {
Control,
FieldErrors,
UseFormRegister,
UseFormWatch,
} from "react-hook-form";
import { ETHIOPIAN_REGIONS } from "@edr/types";
import type { FormData } from "./schema"; import type { FormData } from "./schema";
import { ReadOnlyField } from "./ReadOnlyField"; import { ReadOnlyField } from "./ReadOnlyField";
/** /**
* One field of the verified-registration card: locked read-only once eTrade * The verified eTrade record, rendered strictly read-only.
* 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).
* *
* A value that fails validation unlocks the same way. eTrade (or a row saved * Nothing here is typeable — not even a field eTrade left blank. These values
* before the current rules) can supply something the schema rejects, and a * are the government's record of the company, so a customer-typed substitute
* rejected value rendered read-only is a step that can never be completed and * would be an unverified claim wearing the badge of a verified one. A gap stays
* never says why. * 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<FormData>;
watch: UseFormWatch<FormData>;
errors: FieldErrors<FormData>;
}) {
const value = watch(name) as string | undefined;
if (value && value.trim() && !errors[name]) {
return <ReadOnlyField label={label} value={value} />;
}
return (
<TextInput
label={label}
description="eTrade didn't provide this — please confirm"
error={errors[name]?.message as string | undefined}
{...register(name)}
/>
);
}
export default function ETradeCompanyCard({ export default function ETradeCompanyCard({
tin, tin,
register,
watch, watch,
errors,
control,
}: { }: {
tin: string; tin: string;
register: UseFormRegister<FormData>;
watch: UseFormWatch<FormData>; watch: UseFormWatch<FormData>;
errors: FieldErrors<FormData>;
control: Control<FormData>;
}) { }) {
const companyName = watch("companyName"); const companyName = watch("companyName");
const region = watch("region");
return ( return (
<Card padding="md" radius="md" withBorder> <Card padding="md" radius="md" withBorder>
@@ -88,73 +45,18 @@ export default function ETradeCompanyCard({
</Group> </Group>
<SimpleGrid cols={2} spacing="sm"> <SimpleGrid cols={2} spacing="sm">
<LockedField <ReadOnlyField label="Company Name" value={watch("companyName")} />
label="Company Name"
name="companyName"
register={register}
watch={watch}
errors={errors}
/>
<ReadOnlyField label="License Number" value={watch("licenceNumber")} /> <ReadOnlyField label="License Number" value={watch("licenceNumber")} />
<ReadOnlyField label="Status" value={watch("statusDescription")} /> <ReadOnlyField label="Status" value={watch("statusDescription")} />
<ReadOnlyField label="Date Registered" value={watch("dateRegistered")} /> <ReadOnlyField label="Date Registered" value={watch("dateRegistered")} />
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} /> <ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} /> <ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} /> <ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
{/* Membership of the catalog, not mere presence: eTrade's normalizer <ReadOnlyField label="Region" value={watch("region")} />
returns null for a region it doesn't recognise, and older rows can <ReadOnlyField label="Zone" value={watch("zone")} />
hold a spelling that isn't in the list. Showing such a value <ReadOnlyField label="Woreda" value={watch("woreda")} />
read-only left the customer with a required field they could not <ReadOnlyField label="Kebele" value={watch("kebele")} />
correct. */} <ReadOnlyField label="House No" value={watch("houseNo")} />
{(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? (
<ReadOnlyField label="Region" value={region} />
) : (
<Controller
name="region"
control={control}
render={({ field }) => (
<Select
label="Region"
description="eTrade didn't provide this — please confirm"
placeholder="Select region"
searchable
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
error={errors.region?.message}
value={field.value || null}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
/>
)}
/>
)}
<LockedField
label="Zone"
name="zone"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Woreda"
name="woreda"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Kebele"
name="kebele"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="House No"
name="houseNo"
register={register}
watch={watch}
errors={errors}
/>
</SimpleGrid> </SimpleGrid>
</Card> </Card>
); );

View File

@@ -91,7 +91,7 @@ export function buildPayload(
_user: AuthUser, _user: AuthUser,
): CreateCompanyPayload { ): CreateCompanyPayload {
return { return {
companyName: data.companyName, companyName: data.companyName ?? "",
companyAddress: data.companyAddress, companyAddress: data.companyAddress,
tin: data.tinNumber, tin: data.tinNumber,
vatNumber: data.vatNumber, vatNumber: data.vatNumber,
@@ -128,7 +128,10 @@ export function stepPayload(
case "company": { case "company": {
const etrade: Partial<UpdateProfilePayload> = {}; const etrade: Partial<UpdateProfilePayload> = {};
for (const key of ETRADE_BUNDLE_FIELDS) { for (const key of ETRADE_BUNDLE_FIELDS) {
if (dirty[key]) (etrade as Record<string, unknown>)[key] = d[key]; // Empty is never sent: a field eTrade left blank has no input to fill
// it, and "" fails the API's own validation (region's @IsIn, say),
// which would 400 a save over data the customer cannot supply.
if (dirty[key] && d[key]) (etrade as Record<string, unknown>)[key] = d[key];
} }
if (dirty.tinNumber) etrade.tin = d.tinNumber; if (dirty.tinNumber) etrade.tin = d.tinNumber;
return { return {

View File

@@ -59,10 +59,22 @@ describe("VAT number", () => {
).toBeUndefined(); ).toBeUndefined();
}); });
it("accepts eleven digits", () => {
expect(
errorFor(values({ vatNumber: "00123456789" }), "vatNumber"),
).toBeUndefined();
});
it("rejects twelve digits", () => {
expect(errorFor(values({ vatNumber: "001234567890" }), "vatNumber")).toBe(
"VAT number must be 10 or 11 digits",
);
});
// `.length(10)` used to pass this, so a ten-letter string reached the API. // `.length(10)` used to pass this, so a ten-letter string reached the API.
it("rejects ten non-digits", () => { it("rejects ten non-digits", () => {
expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe( expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe(
"VAT number must be exactly 10 digits", "VAT number must be 10 or 11 digits",
); );
}); });
@@ -73,11 +85,18 @@ describe("VAT number", () => {
}); });
}); });
describe("region", () => { describe("eTrade-sourced fields", () => {
it("rejects a spelling outside the catalog", () => { // They are rendered read-only — there is no input to correct one in — so the
expect(errorFor(values({ region: "Addis Abeba City" }), "region")).toBe( // schema must never reject what eTrade supplied (or failed to supply).
"Region is required", it.each([
); "companyName",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
] as const)("accepts a blank %s", (field) => {
expect(errorFor(values({ [field]: "" }), field)).toBeUndefined();
}); });
}); });
@@ -88,12 +107,18 @@ describe("stepFields", () => {
it("never gates the company step on a derived or read-only field", () => { it("never gates the company step on a derived or read-only field", () => {
const unreachable = [ const unreachable = [
"etradePhone", "etradePhone",
"companyName",
"licenceNumber", "licenceNumber",
"statusDescription", "statusDescription",
"dateRegistered", "dateRegistered",
"renewedFrom", "renewedFrom",
"renewalDate", "renewalDate",
"renewedTo", "renewedTo",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
]; ];
expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual( expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual(
[], [],

View File

@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { ETHIOPIAN_REGIONS } from "@edr/types";
import { isValidPhone } from "@/components/PhoneField"; import { isValidPhone } from "@/components/PhoneField";
@@ -12,7 +12,8 @@ export type CompanyStep =
| "additional"; | "additional";
export const onboardingSchema = z.object({ export const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"), // eTrade-sourced, and never typeable — see the address block below.
companyName: z.string().optional(),
// Derived from the eTrade address parts (kebele/woreda/zone/region); no // Derived from the eTrade address parts (kebele/woreda/zone/region); no
// standalone input — the granular fields live in the registration section. // standalone input — the granular fields live in the registration section.
companyAddress: z.string().optional(), companyAddress: z.string().optional(),
@@ -25,7 +26,7 @@ export const onboardingSchema = z.object({
vatNumber: z vatNumber: z
.string() .string()
.min(1, "VAT number is required") .min(1, "VAT number is required")
.regex(/^\d{10}$/, "VAT number must be exactly 10 digits"), .regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
// The owner's passport number — the foreign-company identity credential // The owner's passport number — the foreign-company identity credential
// (Fayda is an Ethiopian national ID). Required only for a foreign company; // (Fayda is an Ethiopian national ID). Required only for a foreign company;
// enforced in buildOnboardingSchema since that depends on `nationality`. // enforced in buildOnboardingSchema since that depends on `nationality`.
@@ -36,24 +37,15 @@ export const onboardingSchema = z.object({
renewedFrom: z.string().optional(), renewedFrom: z.string().optional(),
renewalDate: z.string().optional(), renewalDate: z.string().optional(),
renewedTo: z.string().optional(), renewedTo: z.string().optional(),
// Address fields are user-entered and required (the registration/license // The registered address comes from eTrade and nowhere else — the form
// fields above are read-only confirmations pulled from eTrade). // renders these read-only, so requiring them would be a Continue button that
// Region is a closed set; zone/woreda/kebele stay free text until the platform // fails on a field with no input to fix it. A gap in eTrade's own data stays
// has an authoritative dataset of Ethiopian zones/woredas/kebeles. // a gap rather than becoming a customer-typed claim wearing eTrade's badge.
// region: z.string().optional(),
// Deliberately `.refine` over `z.enum`: the form needs "" as its unselected zone: z.string().optional(),
// sentinel (default value, and legacy rows whose region isn't in the list). A woreda: z.string().optional(),
// literal union makes "" untypable, which also splits the resolver's kebele: z.string().optional(),
// input/output types and breaks useForm's inference for every other field. houseNo: z.string().optional(),
region: z
.string()
.refine((v) => (ETHIOPIAN_REGIONS as readonly string[]).includes(v), {
message: "Region is required",
}),
zone: z.string().min(1, "Zone is required"),
woreda: z.string().min(1, "Woreda is required"),
kebele: z.string().min(1, "Kebele is required"),
houseNo: z.string().min(1, "House number is required"),
contactPersonName: z.string().min(1, "Contact person name is required"), contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPosition: z.string().optional(), contactPersonPosition: z.string().optional(),
contactPersonEmail: z contactPersonEmail: z
@@ -192,17 +184,9 @@ export const ETRADE_BUNDLE_FIELDS = [
* (`REQUIRED_COMPANY_INFO`), and reports it with a message. * (`REQUIRED_COMPANY_INFO`), and reports it with a message.
*/ */
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = { export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
company: [ // Only the three fields this step actually renders an input for. The company
"companyName", // name and the registered address are eTrade's, shown read-only.
"tinNumber", company: ["tinNumber", "vatNumber", "ownerPassportNumber"],
"vatNumber",
"ownerPassportNumber",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
],
personnel: [ personnel: [
"generalManagerName", "generalManagerName",
"generalManagerEmail", "generalManagerEmail",

View File

@@ -40,7 +40,6 @@ export default function CompanyInfoStep({
}: CompanyInfoStepProps) { }: CompanyInfoStepProps) {
const { const {
register, register,
control,
watch, watch,
formState: { errors }, formState: { errors },
} = form; } = form;
@@ -51,7 +50,7 @@ export default function CompanyInfoStep({
index={1} index={1}
title="VAT number" title="VAT number"
status={ status={
watch("vatNumber")?.length === 10 && !errors.vatNumber (watch("vatNumber")?.length ?? 0) >= 10 && !errors.vatNumber
? "done" ? "done"
: "todo" : "todo"
} }
@@ -59,7 +58,7 @@ export default function CompanyInfoStep({
<TextInput <TextInput
aria-label="VAT Number" aria-label="VAT Number"
placeholder="0012345678" placeholder="0012345678"
maxLength={10} maxLength={11}
error={errors.vatNumber?.message} error={errors.vatNumber?.message}
{...register("vatNumber")} {...register("vatNumber")}
/> />
@@ -123,15 +122,10 @@ export default function CompanyInfoStep({
onStatusChange={onETradeStatusChange} onStatusChange={onETradeStatusChange}
onReset={onETradeReset} onReset={onETradeReset}
alreadyVerified={hasRegistrationDetails} alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
/> />
{tinVerified && ( {tinVerified && (
<ETradeCompanyCard <ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
tin={watch("tinNumber")}
register={register}
watch={watch}
errors={errors}
control={control}
/>
)} )}
</StepSection> </StepSection>
</Stack> </Stack>

View File

@@ -11,7 +11,6 @@ import {
Button, Button,
Card, Card,
Group, Group,
Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
@@ -21,9 +20,9 @@ import {
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react"; import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types"; import type { CompanyRegistrationData } from "@edr/types";
import OnboardingRoleSelect from "./OnboardingRoleSelect"; import OnboardingRoleSelect from "./OnboardingRoleSelect";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import ETradeInfo, { import ETradeInfo, {
@@ -35,7 +34,8 @@ import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/c
import { normalizeIdentityPhones } from "@/pages/accounts/companyProfileForm/helpers"; import { normalizeIdentityPhones } from "@/pages/accounts/companyProfileForm/helpers";
export const COMPANY_PROFILE_SCHEMA = z.object({ export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"), // eTrade-sourced and read-only, like the registration block below.
companyName: z.string().optional(),
companyLocation: z.string().min(1, "Location is required"), companyLocation: z.string().min(1, "Location is required"),
// Derived from the eTrade address parts (region/zone/woreda/kebele/houseNo); // Derived from the eTrade address parts (region/zone/woreda/kebele/houseNo);
// no standalone input. // no standalone input.
@@ -46,26 +46,22 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
vatNumber: z vatNumber: z
.string() .string()
.min(1, "VAT number is required") .min(1, "VAT number is required")
.regex(/^\d{10}$/, "VAT number must be exactly 10 digits"), .regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
ownerPassportNumber: z.string().optional(), ownerPassportNumber: z.string().optional(),
// Registration/address fields are eTrade-sourced — locked once eTrade // Registration/address fields are eTrade-sourced and never typed by hand —
// supplies a value, editable only as an escape hatch when it doesn't // not even when eTrade leaves one blank, so none of them may be required
// (see LockedField below). Not typed by hand in the normal case. // here (there is no input on screen to fix one with).
licenceNumber: z.string().optional(), licenceNumber: z.string().optional(),
statusDescription: z.string().optional(), statusDescription: z.string().optional(),
dateRegistered: z.string().optional(), dateRegistered: z.string().optional(),
renewedFrom: z.string().optional(), renewedFrom: z.string().optional(),
renewalDate: z.string().optional(), renewalDate: z.string().optional(),
renewedTo: z.string().optional(), renewedTo: z.string().optional(),
region: z region: z.string().optional(),
.string() zone: z.string().optional(),
.refine((v) => (ETHIOPIAN_REGIONS as readonly string[]).includes(v), { woreda: z.string().optional(),
message: "Region is required", kebele: z.string().optional(),
}), houseNo: z.string().optional(),
zone: z.string().min(1, "Zone is required"),
woreda: z.string().min(1, "Woreda is required"),
kebele: z.string().min(1, "Kebele is required"),
houseNo: z.string().min(1, "House number is required"),
}); });
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>; export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
@@ -140,7 +136,6 @@ export default function TabCompanyProfile({
const { const {
register, register,
control,
handleSubmit, handleSubmit,
reset, reset,
watch, watch,
@@ -219,7 +214,9 @@ export default function TabCompanyProfile({
// authenticity re-check for no reason. // authenticity re-check for no reason.
const etradeBundle: Record<string, string | undefined> = {}; const etradeBundle: Record<string, string | undefined> = {};
for (const key of ETRADE_FIELDS) { for (const key of ETRADE_FIELDS) {
if (dirtyFields[key]) etradeBundle[key] = data[key]; // Never "": eTrade left it blank, no input exists to fill it, and the
// API rejects the empty value (region's @IsIn) — see stepPayload.
if (dirtyFields[key] && data[key]) etradeBundle[key] = data[key];
} }
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber; if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
@@ -241,7 +238,7 @@ export default function TabCompanyProfile({
: "customer"; : "customer";
const payload: CreateCompanyPayload = { const payload: CreateCompanyPayload = {
...base, ...base,
companyName: data.companyName, companyName: data.companyName ?? "",
tin: data.tinNumber, tin: data.tinNumber,
companyType, companyType,
companyProfiles: selectedRoles.map((type) => ({ companyProfiles: selectedRoles.map((type) => ({
@@ -332,7 +329,7 @@ export default function TabCompanyProfile({
<TextInput <TextInput
label="VAT Number" label="VAT Number"
placeholder="e.g. 0012345678" placeholder="e.g. 0012345678"
maxLength={10} maxLength={11}
error={errors.vatNumber?.message} error={errors.vatNumber?.message}
{...register("vatNumber")} {...register("vatNumber")}
/> />
@@ -400,15 +397,10 @@ export default function TabCompanyProfile({
onDataLoaded={handleETradeDataLoaded} onDataLoaded={handleETradeDataLoaded}
onStatusChange={setTinStatus} onStatusChange={setTinStatus}
alreadyVerified={hasRegistrationDetails} alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
/> />
{tinVerified && ( {tinVerified && (
<EtradeLockedCard <EtradeLockedCard tin={watch("tinNumber")} watch={watch} />
tin={watch("tinNumber")}
register={register}
watch={watch}
errors={errors}
control={control}
/>
)} )}
</StepSection> </StepSection>
@@ -474,27 +466,20 @@ export default function TabCompanyProfile({
} }
/** /**
* The verified eTrade record, locked read-only — same escape hatch as * The verified eTrade record, rendered strictly read-only — same rule as
* onboarding's ETradeCompanyCard: a field eTrade left blank falls back to an * onboarding's ETradeCompanyCard: nothing here is typeable, not even a field
* editable input rather than trapping the customer. * eTrade left blank. These are the government's record of the company, so a
* customer-typed substitute would be an unverified claim wearing a verified
* badge. A gap stays a visible gap ("—").
*/ */
function EtradeLockedCard({ function EtradeLockedCard({
tin, tin,
register,
watch, watch,
errors,
control,
}: { }: {
tin: string; tin: string;
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"]; watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
errors: ReturnType<
typeof useForm<CompanyProfileFormData>
>["formState"]["errors"];
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
}) { }) {
const companyName = watch("companyName"); const companyName = watch("companyName");
const region = watch("region");
return ( return (
<Card padding="md" radius="md" withBorder> <Card padding="md" radius="md" withBorder>
@@ -507,13 +492,7 @@ function EtradeLockedCard({
</Text> </Text>
</Group> </Group>
<SimpleGrid cols={2} spacing="sm"> <SimpleGrid cols={2} spacing="sm">
<LockedField <ReadOnlyField label="Company Name" value={watch("companyName")} />
label="Company Name"
name="companyName"
register={register}
watch={watch}
errors={errors}
/>
<ReadOnlyField label="License Number" value={watch("licenceNumber")} /> <ReadOnlyField label="License Number" value={watch("licenceNumber")} />
<ReadOnlyField label="Status" value={watch("statusDescription")} /> <ReadOnlyField label="Status" value={watch("statusDescription")} />
<ReadOnlyField <ReadOnlyField
@@ -523,101 +502,12 @@ function EtradeLockedCard({
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} /> <ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} /> <ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} /> <ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
{/* Membership of the catalog, not mere presence — a stored spelling <ReadOnlyField label="Region" value={watch("region")} />
outside the list is otherwise uncorrectable. */} <ReadOnlyField label="Zone" value={watch("zone")} />
{(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? ( <ReadOnlyField label="Woreda" value={watch("woreda")} />
<ReadOnlyField label="Region" value={region} /> <ReadOnlyField label="Kebele" value={watch("kebele")} />
) : ( <ReadOnlyField label="House No" value={watch("houseNo")} />
<RegionSelect control={control} error={errors.region?.message} />
)}
<LockedField
label="Zone"
name="zone"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Woreda"
name="woreda"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Kebele"
name="kebele"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="House No"
name="houseNo"
register={register}
watch={watch}
errors={errors}
/>
</SimpleGrid> </SimpleGrid>
</Card> </Card>
); );
} }
function LockedField({
label,
name,
register,
watch,
errors,
}: {
label: string;
name: keyof CompanyProfileFormData;
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
errors: ReturnType<
typeof useForm<CompanyProfileFormData>
>["formState"]["errors"];
}) {
const value = watch(name) as string | undefined;
// A value that fails validation unlocks too — rendering a rejected value
// read-only is a save that can never succeed and never says why.
if (value?.trim() && !errors[name]) {
return <ReadOnlyField label={label} value={value} />;
}
return (
<TextInput
label={label}
description="eTrade didn't provide this — please confirm"
error={errors[name]?.message as string | undefined}
{...register(name)}
/>
);
}
function RegionSelect({
control,
error,
}: {
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
error?: string;
}) {
return (
<Controller
name="region"
control={control}
render={({ field }) => (
<Select
label="Region"
description="eTrade didn't provide this — please confirm"
placeholder="Select region"
searchable
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
error={error}
value={field.value || null}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
/>
)}
/>
);
}

View File

@@ -480,7 +480,10 @@ export const companiesService = {
}, },
/** Fetch company registration data from eTrade by TIN. */ /** Fetch company registration data from eTrade by TIN. */
fetchETradeInfo: async (payload: { tin: string }): Promise<any> => { fetchETradeInfo: async (payload: {
tin: string;
licenceNumber?: string;
}): Promise<any> => {
const response = await client.post<ApiResponse<any>>( const response = await client.post<ApiResponse<any>>(
URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO, URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO,
payload, payload,

View File

@@ -55,10 +55,24 @@ export interface ETradeCompanyInfo {
RenewedFrom: string; RenewedFrom: string;
RenewedTo: string; RenewedTo: string;
BusinessLicensingGroupMain: string | null; 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 { export interface CompanyRegistrationData {
/** /**
* The registered organization name — `ETradeCompanyInfo.BusinessName`, falling * The registered organization name — `ETradeCompanyInfo.BusinessName`, falling
@@ -84,4 +98,11 @@ export interface CompanyRegistrationData {
managerPhone: string; managerPhone: string;
/** True when this TIN is already registered to an existing company. */ /** True when this TIN is already registered to an existing company. */
tinTaken?: boolean; 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[];
} }