feat: add etrade precheck

This commit is contained in:
Nathnael
2026-07-04 09:02:49 +00:00
parent a567218197
commit b866b59478
7 changed files with 86 additions and 37 deletions

View File

@@ -1183,9 +1183,11 @@ export class CompaniesService {
const { businessInfo } = await this.etradeService.resolveCompanyData(tin); const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) { if (!businessInfo) {
throw new BadRequestException( throw new BadRequestException(
"No business license found for this TIN. Please 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.",
); );
} }
return this.etradeService.extractRegistrationData(businessInfo); const registrationData = this.etradeService.extractRegistrationData(businessInfo);
const tinTaken = await this.companiesRepo.existsByTin(tin);
return { ...registrationData, tinTaken };
} }
} }

View File

@@ -1,4 +1,4 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator';
import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { CompanyType, CompanyStatus } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
@@ -17,10 +17,7 @@ export class CreateCompanyDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@Length(10, 10) @Length(10, 10, { message: 'TIN must be exactly 10 digits' })
@Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
tin!: string; tin!: string;
@IsOptional() @IsOptional()

View File

@@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
managerName!: string; managerName!: string;
managerEmail?: string; managerEmail?: string;
managerPhone!: string; managerPhone!: string;
tinTaken?: boolean;
constructor(data: CompanyRegistrationData) { constructor(data: CompanyRegistrationData) {
this.licenceNumber = data.licenceNumber; this.licenceNumber = data.licenceNumber;
@@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData {
this.managerName = data.managerName; this.managerName = data.managerName;
this.managerEmail = data.managerEmail; this.managerEmail = data.managerEmail;
this.managerPhone = data.managerPhone; this.managerPhone = data.managerPhone;
this.tinTaken = data.tinTaken;
} }
} }

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator';
import { CompanyNationality } from '../entities/company.entity'; import { CompanyNationality } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
@@ -34,10 +34,7 @@ export class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@Length(10, 10) @Length(10, 10, { message: 'TIN must be exactly 10 digits' })
@Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
tin?: string; tin?: string;
@IsOptional() @IsOptional()

View File

@@ -7,9 +7,11 @@ import {
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { useEffect, useRef } from "react";
import type { UseFormRegisterReturn } from "react-hook-form"; import type { UseFormRegisterReturn } from "react-hook-form";
import { AlertCircle, CheckCircle2, Download } from "lucide-react"; import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react";
import { useETradeData } from "@/hooks/useETradeData"; import { useETradeData } from "@/hooks/useETradeData";
import { extractApiError } from "@/utils/result";
import type { CompanyRegistrationData } from "@edr/types"; import type { CompanyRegistrationData } from "@edr/types";
interface ETradeInfoProps { interface ETradeInfoProps {
@@ -22,6 +24,8 @@ interface ETradeInfoProps {
onDataLoaded: (data: CompanyRegistrationData) => void; onDataLoaded: (data: CompanyRegistrationData) => void;
} }
const isValidTin = (tin: string) => tin.length === 10;
export default function ETradeInfo({ export default function ETradeInfo({
tin, tin,
register, register,
@@ -30,53 +34,100 @@ export default function ETradeInfo({
}: ETradeInfoProps) { }: ETradeInfoProps) {
const mutation = useETradeData(); const mutation = useETradeData();
const isLoading = mutation.isPending; const isLoading = mutation.isPending;
const hasData = mutation.data; const tinTaken = mutation.data?.tinTaken;
const hasData =
mutation.data && !mutation.data.tinTaken ? mutation.data : null;
const handleFetch = async () => { const handleFetch = async () => {
if (!tin || tin.length !== 10 || !tin.startsWith("00")) return; if (!isValidTin(tin)) return;
const result = await mutation.mutateAsync(tin); const result = await mutation.mutateAsync(tin);
if (result) { if (result && !result.tinTaken) {
onDataLoaded(result); onDataLoaded(result);
} }
}; };
const errorMessage = // Auto-fetch as soon as the TIN reaches its full 10-digit length — only
// once per distinct value, so retyping the same TIN doesn't refetch.
const lastFetchedTin = useRef<string | null>(null);
useEffect(() => {
if (isValidTin(tin) && lastFetchedTin.current !== tin) {
lastFetchedTin.current = tin;
handleFetch();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tin]);
const apiError =
mutation.isError && mutation.error mutation.isError && mutation.error
? (mutation.error as any).message || ? extractApiError(mutation.error)
"Failed to fetch company information. Please try again." : null;
// A 400 here means eTrade simply has no record for this TIN — not a
// failure. Soft-pedal it as an FYI, not a red error, so filling in
// manually doesn't feel like something went wrong.
const notFound = apiError?.statusCode === 400;
const errorMessage =
apiError && !notFound
? apiError.message ||
"We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below."
: null; : null;
return ( return (
<Stack gap="md"> <Stack gap="md">
<Group align="flex-start" grow> <Group align="flex-start" grow>
<TextInput <TextInput
label={<>TIN Number (10 digits) <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>} label={
<>
TIN Number (10 digits){" "}
<span style={{ color: "var(--mantine-color-red-6)" }}>*</span>
</>
}
placeholder="0012345678" placeholder="0012345678"
maxLength={10} maxLength={10}
error={error} error={error}
{...register} {...register}
/> />
<Button {errorMessage && (
variant="filled" <Button
color="edr-green" variant="filled"
onClick={handleFetch} color="edr-green"
disabled={!tin || tin.length !== 10 || !tin.startsWith("00") || isLoading} onClick={handleFetch}
leftSection={ disabled={!isValidTin(tin) || isLoading}
isLoading ? <Loader size={16} /> : <Download size={16} /> leftSection={
} isLoading ? <Loader size={16} /> : <Download size={16} />
mt="24px" }
> mt="24px"
{isLoading ? "Getting..." : "Get Data"} >
</Button> {isLoading ? "Getting..." : "Get Data"}
</Button>
)}
</Group> </Group>
{notFound && (
<Alert icon={<Info size={16} />} color="gray">
We couldn't find a matching business record for this TIN — no
problem, just fill in the details below.
</Alert>
)}
{errorMessage && ( {errorMessage && (
<Alert <Alert
icon={<AlertCircle size={16} />} icon={<AlertCircle size={16} />}
color="red" color="red"
title="Failed to fetch data" title="Couldn't fetch eTrade data"
> >
{errorMessage} You can still fill in the details manually below. {errorMessage}
</Alert>
)}
{tinTaken && (
<Alert
icon={<AlertCircle size={16} />}
color="red"
title="TIN already registered"
>
This TIN is already registered to another company account. Please
double-check the number, or contact support if you believe this is a
mistake.
</Alert> </Alert>
)} )}

View File

@@ -450,8 +450,6 @@ export default function CompanyProfileForm({
onDataLoaded={handleETradeDataLoaded} onDataLoaded={handleETradeDataLoaded}
/> />
<Divider my="sm" />
<TextInput <TextInput
label="Company Name" label="Company Name"
placeholder="Global Logistics Ltd" placeholder="Global Logistics Ltd"
@@ -507,7 +505,7 @@ export default function CompanyProfileForm({
from eTrade · read-only from eTrade · read-only
</Text> </Text>
</Group> </Group>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="sm">
<ReadOnlyField <ReadOnlyField
label="License Number" label="License Number"
value={watch("licenceNumber")} value={watch("licenceNumber")}

View File

@@ -76,4 +76,6 @@ export interface CompanyRegistrationData {
managerName: string; managerName: string;
managerEmail?: string; managerEmail?: string;
managerPhone: string; managerPhone: string;
/** True when this TIN is already registered to an existing company. */
tinTaken?: boolean;
} }