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(
dto.tin,
companyId,
dto.licenceNumber,
);
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. */
private async resolveEtradeRegistration(
tin: string,
licenceNumber?: string,
): Promise<CompanyRegistrationData> {
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>
> = {

View File

@@ -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;
}
}

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";
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;
}

View File

@@ -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

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;
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 || "",
})),
};
}
}

View File

@@ -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)",
})

View File

@@ -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<string | null>(
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<ETradeStatus | null>(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 (
<Stack gap="md">
@@ -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={<Download size={16} />}
>
@@ -179,6 +242,91 @@ export default function ETradeInfo({
)}
</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 && (
<Alert
icon={<AlertCircle size={16} />}

View File

@@ -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<CompanyRegistrationData> => {
return companiesService.fetchETradeInfo({ tin });
mutationFn: async (vars: {
tin: string;
licenceNumber?: string;
}): Promise<CompanyRegistrationData> => {
return companiesService.fetchETradeInfo(vars);
},
onError: (error) => {
const { message } = extractApiError(error);

View File

@@ -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;
}

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 { 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<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({
tin,
register,
watch,
errors,
control,
}: {
tin: string;
register: UseFormRegister<FormData>;
watch: UseFormWatch<FormData>;
errors: FieldErrors<FormData>;
control: Control<FormData>;
}) {
const companyName = watch("companyName");
const region = watch("region");
return (
<Card padding="md" radius="md" withBorder>
@@ -88,73 +45,18 @@ export default function ETradeCompanyCard({
</Group>
<SimpleGrid cols={2} spacing="sm">
<LockedField
label="Company Name"
name="companyName"
register={register}
watch={watch}
errors={errors}
/>
<ReadOnlyField label="Company Name" value={watch("companyName")} />
<ReadOnlyField label="License Number" value={watch("licenceNumber")} />
<ReadOnlyField label="Status" value={watch("statusDescription")} />
<ReadOnlyField label="Date Registered" value={watch("dateRegistered")} />
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
{/* 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 ?? "") ? (
<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}
/>
<ReadOnlyField label="Region" value={watch("region")} />
<ReadOnlyField label="Zone" value={watch("zone")} />
<ReadOnlyField label="Woreda" value={watch("woreda")} />
<ReadOnlyField label="Kebele" value={watch("kebele")} />
<ReadOnlyField label="House No" value={watch("houseNo")} />
</SimpleGrid>
</Card>
);

View File

@@ -91,7 +91,7 @@ export function buildPayload(
_user: AuthUser,
): CreateCompanyPayload {
return {
companyName: data.companyName,
companyName: data.companyName ?? "",
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber,
@@ -128,7 +128,10 @@ export function stepPayload(
case "company": {
const etrade: Partial<UpdateProfilePayload> = {};
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;
return {

View File

@@ -59,10 +59,22 @@ describe("VAT number", () => {
).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.
it("rejects ten non-digits", () => {
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", () => {
it("rejects a spelling outside the catalog", () => {
expect(errorFor(values({ region: "Addis Abeba City" }), "region")).toBe(
"Region is required",
);
describe("eTrade-sourced fields", () => {
// They are rendered read-only — there is no input to correct one in — so the
// schema must never reject what eTrade supplied (or failed to supply).
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", () => {
const unreachable = [
"etradePhone",
"companyName",
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewedFrom",
"renewalDate",
"renewedTo",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
];
expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual(
[],

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ import {
Button,
Card,
Group,
Select,
SimpleGrid,
Stack,
Text,
@@ -21,9 +20,9 @@ import {
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-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 { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
import type { CompanyRegistrationData } from "@edr/types";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
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";
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"),
// Derived from the eTrade address parts (region/zone/woreda/kebele/houseNo);
// no standalone input.
@@ -46,26 +46,22 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
vatNumber: z
.string()
.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(),
// Registration/address fields are eTrade-sourced — locked once eTrade
// supplies a value, editable only as an escape hatch when it doesn't
// (see LockedField below). Not typed by hand in the normal case.
// Registration/address fields are eTrade-sourced and never typed by hand —
// not even when eTrade leaves one blank, so none of them may be required
// here (there is no input on screen to fix one with).
licenceNumber: z.string().optional(),
statusDescription: z.string().optional(),
dateRegistered: z.string().optional(),
renewedFrom: z.string().optional(),
renewalDate: z.string().optional(),
renewedTo: 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"),
region: z.string().optional(),
zone: z.string().optional(),
woreda: z.string().optional(),
kebele: z.string().optional(),
houseNo: z.string().optional(),
});
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
@@ -140,7 +136,6 @@ export default function TabCompanyProfile({
const {
register,
control,
handleSubmit,
reset,
watch,
@@ -219,7 +214,9 @@ export default function TabCompanyProfile({
// authenticity re-check for no reason.
const etradeBundle: Record<string, string | undefined> = {};
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;
@@ -241,7 +238,7 @@ export default function TabCompanyProfile({
: "customer";
const payload: CreateCompanyPayload = {
...base,
companyName: data.companyName,
companyName: data.companyName ?? "",
tin: data.tinNumber,
companyType,
companyProfiles: selectedRoles.map((type) => ({
@@ -332,7 +329,7 @@ export default function TabCompanyProfile({
<TextInput
label="VAT Number"
placeholder="e.g. 0012345678"
maxLength={10}
maxLength={11}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
@@ -400,15 +397,10 @@ export default function TabCompanyProfile({
onDataLoaded={handleETradeDataLoaded}
onStatusChange={setTinStatus}
alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
/>
{tinVerified && (
<EtradeLockedCard
tin={watch("tinNumber")}
register={register}
watch={watch}
errors={errors}
control={control}
/>
<EtradeLockedCard tin={watch("tinNumber")} watch={watch} />
)}
</StepSection>
@@ -474,27 +466,20 @@ export default function TabCompanyProfile({
}
/**
* The verified eTrade record, locked read-only — same escape hatch as
* onboarding's ETradeCompanyCard: a field eTrade left blank falls back to an
* editable input rather than trapping the customer.
* The verified eTrade record, rendered strictly read-only — same rule as
* onboarding's ETradeCompanyCard: nothing here is typeable, not even a field
* 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({
tin,
register,
watch,
errors,
control,
}: {
tin: string;
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
errors: ReturnType<
typeof useForm<CompanyProfileFormData>
>["formState"]["errors"];
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
}) {
const companyName = watch("companyName");
const region = watch("region");
return (
<Card padding="md" radius="md" withBorder>
@@ -507,13 +492,7 @@ function EtradeLockedCard({
</Text>
</Group>
<SimpleGrid cols={2} spacing="sm">
<LockedField
label="Company Name"
name="companyName"
register={register}
watch={watch}
errors={errors}
/>
<ReadOnlyField label="Company Name" value={watch("companyName")} />
<ReadOnlyField label="License Number" value={watch("licenceNumber")} />
<ReadOnlyField label="Status" value={watch("statusDescription")} />
<ReadOnlyField
@@ -523,101 +502,12 @@ function EtradeLockedCard({
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
{/* Membership of the catalog, not mere presence — a stored spelling
outside the list is otherwise uncorrectable. */}
{(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? (
<ReadOnlyField label="Region" value={region} />
) : (
<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}
/>
<ReadOnlyField label="Region" value={watch("region")} />
<ReadOnlyField label="Zone" value={watch("zone")} />
<ReadOnlyField label="Woreda" value={watch("woreda")} />
<ReadOnlyField label="Kebele" value={watch("kebele")} />
<ReadOnlyField label="House No" value={watch("houseNo")} />
</SimpleGrid>
</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. */
fetchETradeInfo: async (payload: { tin: string }): Promise<any> => {
fetchETradeInfo: async (payload: {
tin: string;
licenceNumber?: string;
}): Promise<any> => {
const response = await client.post<ApiResponse<any>>(
URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO,
payload,

View File

@@ -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[];
}