mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add choice to etrade fetcher
This commit is contained in:
@@ -210,6 +210,7 @@ export class CompaniesController {
|
||||
const data = await this.companiesService.fetchETradeData(
|
||||
dto.tin,
|
||||
companyId,
|
||||
dto.licenceNumber,
|
||||
);
|
||||
return new ETradeResponseDto(data);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
> = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 || "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
})
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -85,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,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(
|
||||
[],
|
||||
|
||||
@@ -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(),
|
||||
@@ -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",
|
||||
|
||||
@@ -40,7 +40,6 @@ export default function CompanyInfoStep({
|
||||
}: CompanyInfoStepProps) {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -48,24 +48,20 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
.min(1, "VAT number is required")
|
||||
.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) => ({
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user