fix: customer settings fix

This commit is contained in:
Nathnael
2026-07-29 14:30:35 +00:00
parent aba39b0ec3
commit 848c0d7768
10 changed files with 761 additions and 110 deletions

View File

@@ -192,9 +192,20 @@ export class CompaniesController {
@Post("fetch-etrade-info")
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
async fetchETradeInfo(
@CurrentUser() user: CurrentIamUser,
@Body() dto: FetchETradeDto,
): Promise<ETradeResponseDto> {
const data = await this.companiesService.fetchETradeData(dto.tin);
// Best-effort: a first-run onboarding draft may not exist yet, in which
// case there is no company to exclude and `tinTaken` checks every row —
// the correct behaviour for a brand-new lookup.
const companyId = await this.companiesService
.getCompanyInfoByUserId(user.id)
.then(({ company }) => company.id)
.catch(() => undefined);
const data = await this.companiesService.fetchETradeData(
dto.tin,
companyId,
);
return new ETradeResponseDto(data);
}

View File

@@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
.getMany();
}
async existsByTin(tin: string): Promise<boolean> {
const count = await this.repository.count({ where: { tin } as any });
async existsByTin(tin: string, excludeCompanyId?: string): Promise<boolean> {
const qb = this.repository
.createQueryBuilder('company')
.where('company.tin = :tin', { tin });
if (excludeCompanyId) {
qb.andWhere('company.id != :excludeCompanyId', { excludeCompanyId });
}
const count = await qb.getCount();
return count > 0;
}

View File

@@ -0,0 +1,113 @@
import { CompaniesService } from "./companies.service";
import { CompanyType } from "./entities/company.entity";
import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
/**
* EDRFREIGHT-416: onboarding asked for a deselected role's documents.
*
* Re-running role selection used to only ADD operational profiles, so a role
* the user unticked on the way back left its company_profile row behind — and
* every role-driven requirement (business license, forwarder PoA) is derived
* from those rows. startOnboarding now reconciles both directions.
*/
interface ExistingProfile {
id: string;
type: ProfileType;
status: ProfileStatus;
}
function makeService(existing: ExistingProfile[]) {
const companyProfilesRepo = {
findByCompanyId: jest.fn(async () => existing),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "new",
...row,
})),
softDelete: jest.fn(async () => undefined),
};
const companiesRepo = { update: jest.fn(async () => null) };
const profilesRepo = {
findByUserId: jest.fn(async () => ({
id: "external-1",
companyId: "company-1",
company: { id: "company-1" },
})),
};
const service = new CompaniesService(
companiesRepo as never,
companyProfilesRepo as never,
{} as never,
profilesRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
);
return { service, companyProfilesRepo };
}
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
const start = (service: CompaniesService, roles: ProfileType[]) =>
service.startOnboarding(identity as never, CompanyType.Customer, roles);
describe("re-running role selection reconciles the operational profiles", () => {
it("drops the profile for a role the user deselected", async () => {
const { service, companyProfilesRepo } = makeService([
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
{
id: "p-ff",
type: ProfileType.freightForwarder,
status: ProfileStatus.Pending,
},
]);
await start(service, [ProfileType.importer]);
expect(companyProfilesRepo.softDelete).toHaveBeenCalledWith("p-ff");
expect(companyProfilesRepo.softDelete).toHaveBeenCalledTimes(1);
expect(companyProfilesRepo.create).not.toHaveBeenCalled();
});
it("keeps an already-approved profile even when it is unticked", async () => {
const { service, companyProfilesRepo } = makeService([
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
{
id: "p-exp",
type: ProfileType.exporter,
status: ProfileStatus.Active,
},
]);
await start(service, [ProfileType.importer]);
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
});
it("still adds a newly-picked role", async () => {
const { service, companyProfilesRepo } = makeService([
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
]);
await start(service, [ProfileType.importer, ProfileType.exporter]);
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
expect(companyProfilesRepo.create).toHaveBeenCalledTimes(1);
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ type: ProfileType.exporter }),
);
});
});

View File

@@ -33,6 +33,7 @@ import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import type { CompanyRegistrationData } from "@edr/types";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
@@ -116,6 +117,28 @@ const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
};
/**
* `UpdateProfileDto` fields eTrade is the sole source of truth for. A request
* touching any of these must be re-checked against a fresh eTrade lookup —
* see `assertEtradeFieldsAuthentic`.
*/
const ETRADE_SOURCED_FIELDS = [
"companyName",
"tin",
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewedFrom",
"renewalDate",
"renewedTo",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
"etradePhone",
] as const satisfies readonly (keyof UpdateProfileDto)[];
/** The attributes a verification writes, for one person. */
interface VerifiedIdentityAttributes {
[key: string]: unknown;
@@ -298,8 +321,9 @@ export class CompaniesService {
* chosen operational role(s) up front, so every subsequent wizard step can
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
*
* Idempotent: if the user already has a profile, returns it unchanged (only
* adding any newly-chosen roles). The draft company carries a placeholder TIN
* Idempotent: if the user already has a profile, returns it unchanged, with
* the operational profiles reconciled against the roles just chosen (added
* and — for still-pending ones — removed). The draft company carries a placeholder TIN
* (the real one is filled on the Company Information step) and stays
* status=pending / onboardingCompleted=false until the wizard finishes.
*/
@@ -314,7 +338,7 @@ export class CompaniesService {
const existing = await this.profilesRepo.findByUserId(identity.userId);
if (existing) {
const companyId = existing.company?.id ?? existing.companyId;
await this.ensureCompanyProfiles(companyId, companyType, roles);
await this.syncCompanyProfiles(companyId, companyType, roles);
if (nationality) {
await this.companiesRepo.update(companyId, { nationality });
}
@@ -345,25 +369,44 @@ export class CompaniesService {
onboardingCompleted: false,
});
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
return this.getCompanyInfoByUserId(identity.userId);
}
/** Create any of the requested operational profiles that don't exist yet. */
private async ensureCompanyProfiles(
/**
* Reconcile the company's operational profiles with the roles the user has
* selected: create the missing ones, drop the ones they deselected.
*
* Dropping matters because every role-driven onboarding requirement — the
* per-profile business license, the freight-forwarder PoA rule, the license
* cards in the wizard — is derived from these rows. A row left behind after
* the user went back and unticked a role keeps asking for that role's
* documents (EDRFREIGHT-416). Only still-pending profiles are removed: an
* approved one is live (it can carry bookings and contracts) and re-running
* role selection must never delete it.
*/
private async syncCompanyProfiles(
companyId: string,
companyType: CompanyType,
roles: ProfileType[],
): Promise<void> {
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
for (const type of roles) {
if (!allowedTypes.includes(type)) continue;
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (existing) continue;
const chosen = roles.filter((t) => allowedTypes.includes(t));
const existing = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const profile of existing) {
if (chosen.includes(profile.type)) continue;
if (profile.status !== ProfileStatus.Pending) continue;
// The license files uploaded against this profile go with it: they are
// only ever read per company_profile id, so a soft-deleted profile
// leaves nothing behind to prompt for. Re-picking the role creates a
// fresh profile the user uploads against again.
await this.companyProfilesRepo.softDelete(profile.id);
}
for (const type of chosen) {
if (existing.some((p) => p.type === type)) continue;
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
await this.companyProfilesRepo.create({
companyId,
@@ -720,6 +763,31 @@ export class CompaniesService {
Object.assign(attrUpdates, dto.faydaIdentity);
}
// companyEmail/companyPhone are the Company-column mirrors of the owner's
// verified contact details (the portal derives and submits them, it never
// lets the customer type them once verified) — lock them the same way
// ownerEmail/ownerPhone themselves are locked below, once there is a
// verified owner to lock them to.
if (attrUpdates.ownerFaydaSub) {
if (
dto.companyEmail !== undefined &&
dto.companyEmail !== attrUpdates.ownerEmail
) {
throw new BadRequestException(
"companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
);
}
if (
dto.companyPhone !== undefined &&
normalizeE164(dto.companyPhone) !==
normalizeE164(String(attrUpdates.ownerPhone ?? ""))
) {
throw new BadRequestException(
"companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
);
}
}
// Renaming a Fayda-verified person by hand would launder the guarantee
// away, so the fields the verification owns are refused once it exists.
for (const subject of ["owner", "poa"] as IdentitySubject[]) {
@@ -787,6 +855,8 @@ export class CompaniesService {
): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
await this.assertEtradeFieldsAuthentic(company, dto);
// Naming (or renaming) a Power of Attorney is one of the writes that can
// leave the company with a representative and nothing evidencing them, so
// it is gated here. Edits that don't touch the PoA are left alone — a
@@ -2853,7 +2923,10 @@ export class CompaniesService {
return match?.id ?? null;
}
async fetchETradeData(tin: string) {
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
private async resolveEtradeRegistration(
tin: string,
): Promise<CompanyRegistrationData> {
const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) {
@@ -2861,11 +2934,71 @@ export class CompaniesService {
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
);
}
const registrationData = this.etradeService.extractRegistrationData(
businessInfo,
companyInfo,
return this.etradeService.extractRegistrationData(businessInfo, companyInfo);
}
async fetchETradeData(tin: string, excludeCompanyId?: string) {
const registrationData = await this.resolveEtradeRegistration(tin);
const tinTaken = await this.companiesRepo.existsByTin(
tin,
excludeCompanyId,
);
const tinTaken = await this.companiesRepo.existsByTin(tin);
return { ...registrationData, tinTaken };
}
/**
* An eTrade-sourced field can only ever hold what a fresh eTrade lookup for
* this TIN actually returns — the portal never lets the customer type these
* once eTrade has supplied them, so a mismatch here means either stale
* client state or a hand-crafted request, and either way the write is
* refused rather than silently trusting it.
*/
private async assertEtradeFieldsAuthentic(
company: Company,
dto: UpdateProfileDto,
): Promise<void> {
const touched = ETRADE_SOURCED_FIELDS.some(
(key) => dto[key] !== undefined,
);
if (!touched) return;
const tin = dto.tin ?? company.tin;
const registration = await this.resolveEtradeRegistration(tin);
const expected: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
companyName: registration.companyName,
licenceNumber: registration.licenceNumber,
statusDescription: registration.statusDescription,
dateRegistered: registration.dateRegistered,
renewedFrom: registration.renewedFrom,
renewalDate: registration.renewalDate,
renewedTo: registration.renewedTo,
region: registration.region,
zone: registration.zone,
woreda: registration.woreda,
kebele: registration.kebele,
houseNo: registration.houseNo,
etradePhone:
registration.managerPhone ||
registration.regularPhone ||
registration.mobilePhone,
};
for (const key of ETRADE_SOURCED_FIELDS) {
const submitted = dto[key];
if (submitted === undefined) continue;
const source = expected[key];
// eTrade left this field blank — the onboarding/settings card falls back
// to letting the customer type it directly, so nothing to check against.
if (!source) continue;
const same =
key === "etradePhone"
? normalizeE164(String(submitted)) === normalizeE164(source)
: submitted === source;
if (!same) {
throw new BadRequestException(
`${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`,
);
}
}
}
}

View File

@@ -59,6 +59,13 @@ const FIELD_LABELS: Record<string, string> = {
woreda: "Woreda",
kebele: "Kebele",
houseNo: "House no.",
statusDescription: "eTrade status",
dateRegistered: "Date registered",
renewedFrom: "Renewed from",
renewalDate: "Renewal date",
renewedTo: "Renewed to",
etradePhone: "eTrade phone",
ownerPassportNumber: "Owner passport number",
};
/** Best-effort current value on the live company for a proposed field key. */
@@ -85,6 +92,74 @@ function currentValue(company: Company, key: string): string {
return v === null || v === undefined || v === "" ? "—" : String(v);
}
/** Subject a staged `snapshot.faydaIdentity` blob belongs to, from which of its `*FaydaSub` keys is present. */
function faydaIdentitySubject(
snapshot: Record<string, unknown>,
): "owner" | "poa" | null {
if ("ownerFaydaSub" in snapshot) return "owner";
if ("poaFaydaSub" in snapshot) return "poa";
return null;
}
/**
* `stageIdentityChange` writes a nested `snapshot.faydaIdentity` object
* (attrs-key names like `ownerEmail`, not top-level DTO keys), so the generic
* `DiffRow` loop below can't render it — it would just stringify to
* `[object Object]`. Render it as its own before/after block instead, using
* the company's current `identity.owner`/`identity.poa` as the "before" side.
*/
function FaydaIdentityDiff({
company,
snapshot,
}: {
company: Company;
snapshot: Record<string, unknown>;
}) {
const subject = faydaIdentitySubject(snapshot);
if (!subject) return null;
const current =
subject === "owner" ? company.identity?.owner : company.identity?.poa;
const read = (key: string) => snapshot[`${subject}${key}`] as string | undefined;
const verifiedAt = read("FaydaVerifiedAt");
const fields: { label: string; from?: string | null; to?: string }[] = [
{ label: "Name", from: current?.name, to: read("Name") },
{ label: "Email", from: current?.email, to: read("Email") },
{ label: "Phone", from: current?.phone, to: read("Phone") },
{ label: "Address", from: current?.address, to: read("Address") },
].filter((f) => f.to !== undefined);
return (
<Stack gap={8}>
<Group gap={8}>
<Text size="sm" fw={600} c="edr-text">
{subject === "owner" ? "Owner re-verification" : "PoA re-verification"}
</Text>
{verifiedAt && (
<Text size="xs" c="dimmed">
Verified {formatDate(verifiedAt)}
</Text>
)}
</Group>
{fields.length > 0 ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{fields.map((f) => (
<DiffRow
key={f.label}
label={f.label}
from={f.from?.trim() ? f.from : "—"}
to={f.to?.trim() ? f.to : "—"}
/>
))}
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
Identity re-verified no name/email/phone/address change.
</Text>
)}
</Stack>
);
}
function DiffRow({
label,
from,
@@ -153,8 +228,11 @@ export function ChangeRequestReview({ company }: { company: Company }) {
if (!pending && history.length === 0) return null;
const proposedKeys = pending
? Object.keys(pending.snapshot ?? {})
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
: ([] as string[]);
const faydaIdentitySnapshot = pending?.snapshot?.faydaIdentity as
| Record<string, unknown>
| undefined;
const docCount = pending?.documentFileIds?.length ?? 0;
const licenseChanges = pending?.licenseChanges ?? [];
const documentChanges = pending?.documentChanges ?? [];
@@ -209,10 +287,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
/>
))}
</SimpleGrid>
) : (
) : !faydaIdentitySnapshot ? (
<Text size="sm" c="dimmed">
No field changes document uploads only.
</Text>
) : null}
{faydaIdentitySnapshot && (
<FaydaIdentityDiff company={company} snapshot={faydaIdentitySnapshot} />
)}
{documentChanges.length > 0 && (

View File

@@ -9,7 +9,7 @@ import {
Stack,
Text,
} from "@mantine/core";
import { BadgeCheck, ShieldCheck, XCircle } from "lucide-react";
import { BadgeCheck, Clock, ShieldCheck, XCircle } from "lucide-react";
import {
verifaydaService,
@@ -33,6 +33,13 @@ interface FaydaVerifyPanelProps {
/** Called with the fresh company-wide state once a verification lands. */
onVerified: (next: CompanyIdentityState) => void;
disabled?: boolean;
/**
* True when a fresh verification for this person is already staged in a
* pending change request. On an active company a re-verification never
* touches the live record — it's staged for review — so `state` alone
* would keep showing the OLD verified data with no sign anything happened.
*/
pendingReview?: boolean;
}
function formatDate(iso: string | null): string {
@@ -57,6 +64,7 @@ export default function FaydaVerifyPanel({
required,
onVerified,
disabled,
pendingReview,
}: FaydaVerifyPanelProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -151,6 +159,16 @@ export default function FaydaVerifyPanel({
</Badge>
)
)}
{pendingReview && (
<Badge
size="sm"
variant="light"
color="amber"
leftSection={<Clock size={11} />}
>
Re-verification pending review
</Badge>
)}
</Group>
<Button
type="button"

View File

@@ -225,8 +225,12 @@ export default function OnboardingWizardDialog({
const finishMutation = useMutation({
mutationFn: async () => {
// Per-role business licenses (file model, resource=company_profiles).
// Keys for roles the user deselected on a trip back to role selection are
// dropped — that profile no longer exists, so uploading against it would
// 404 (and the license isn't wanted any more anyway).
const liveProfileIds = new Set(existingProfiles.map((p) => p.id));
for (const [profileId, files] of Object.entries(licenseFiles)) {
if (files.length > 0) {
if (files.length > 0 && liveProfileIds.has(profileId)) {
await companiesService.uploadProfileLicense(profileId, files);
}
}

View File

@@ -363,7 +363,7 @@ export default function SettingsPage() {
enabled so the customer can still review what they submitted. */}
<Tabs.Panel value="company">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabCompanyProfile mode="edit" profile={profile} />
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
</Fieldset>
<OperationalServicesCard profile={profile} />
</Tabs.Panel>

View File

@@ -1,16 +1,19 @@
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
import { api } from "@/services/api";
import type {
CompanyProfileInput,
CreateCompanyPayload,
} from "@/services/companies.service";
import type { AuthUser } from "@/types/auth";
import type { ProfileResponse } from "@/types/profile";
import { extractApiError } from "@/utils/result";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Button,
Card,
Grid,
Group,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
@@ -18,11 +21,15 @@ import {
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo";
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -32,7 +39,9 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
// Derived from the eTrade address parts (region/zone/woreda/kebele/houseNo);
// no standalone input.
companyAddress: z.string().optional(),
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
vatNumber: z
.string()
@@ -41,24 +50,61 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
.optional()
.or(z.literal("")),
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.
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"),
});
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
/** UpdateProfilePayload keys eTrade owns — only resent when the customer re-verified them this session. */
const ETRADE_BUNDLE_FIELDS = [
"companyName",
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewedFrom",
"renewalDate",
"renewedTo",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
] as const satisfies readonly (keyof CompanyProfileFormData)[];
interface TabCompanyProfileProps {
profile?: ProfileResponse;
mode?: "edit" | "create";
onCreateSuccess?: () => void;
user?: AuthUser;
}
export default function TabCompanyProfile({
profile,
mode = "edit",
onCreateSuccess,
user,
}: TabCompanyProfileProps) {
const queryClient = useQueryClient();
const isCreate = mode === "create";
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
const defaultValues = useMemo((): CompanyProfileFormData => {
if (profile) {
@@ -71,6 +117,17 @@ export default function TabCompanyProfile({
tinNumber: profile.tinNumber,
vatNumber: profile.vatNumber ?? "",
ownerPassportNumber: profile.identity?.owner.passportNumber ?? "",
licenceNumber: profile.licenceNumber ?? "",
statusDescription: profile.statusDescription ?? "",
dateRegistered: profile.dateRegistered ?? "",
renewedFrom: profile.renewedFrom ?? "",
renewalDate: profile.renewalDate ?? "",
renewedTo: profile.renewedTo ?? "",
region: profile.region ?? "",
zone: profile.zone ?? "",
woreda: profile.woreda ?? "",
kebele: profile.kebele ?? "",
houseNo: profile.houseNo ?? "",
};
}
return {
@@ -82,6 +139,17 @@ export default function TabCompanyProfile({
tinNumber: "",
vatNumber: "",
ownerPassportNumber: "",
licenceNumber: "",
statusDescription: "",
dateRegistered: "",
renewedFrom: "",
renewalDate: "",
renewedTo: "",
region: "",
zone: "",
woreda: "",
kebele: "",
houseNo: "",
};
}, [profile]);
@@ -90,22 +158,109 @@ export default function TabCompanyProfile({
control,
handleSubmit,
reset,
formState: { errors, isDirty },
watch,
setValue,
formState: { errors, isDirty, dirtyFields },
} = useForm<CompanyProfileFormData>({
resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
values: defaultValues,
});
const identity = profile?.identity;
const verifiedIdentity = identity?.faydaRequired === true;
// companyEmail/companyPhone are the owner's verified contact details, never
// typed — same derivation as the onboarding wizard, just fed from the saved
// profile instead of an in-progress form.
useEffect(() => {
if (!user) return;
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
shouldValidate: true,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity?.owner.email, user?.email]);
useEffect(() => {
if (!user) return;
setValue(
"companyPhone",
identity?.owner.phone ??
profile?.etradePhone ??
toEthiopianE164(user.phoneNumber) ??
"",
{ shouldValidate: true },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity?.owner.phone, profile?.etradePhone, user?.phoneNumber]);
// companyAddress is composed from the (locked) eTrade address parts, not
// typed directly.
const region = watch("region");
const zone = watch("zone");
const woreda = watch("woreda");
const kebele = watch("kebele");
const houseNo = watch("houseNo");
useEffect(() => {
const composed = [houseNo, kebele, woreda, zone, region]
.filter((part) => part && part.trim())
.join(", ");
setValue("companyAddress", composed);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [region, zone, woreda, kebele, houseNo]);
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
if (data.companyName) {
setValue("companyName", data.companyName, {
shouldValidate: true,
shouldDirty: true,
});
}
setValue("licenceNumber", data.licenceNumber, { shouldDirty: true });
setValue("statusDescription", data.statusDescription, { shouldDirty: true });
setValue("dateRegistered", data.dateRegistered, { shouldDirty: true });
setValue("renewedFrom", data.renewedFrom, { shouldDirty: true });
setValue("renewalDate", data.renewalDate, { shouldDirty: true });
setValue("renewedTo", data.renewedTo, { shouldDirty: true });
setValue("region", data.region, { shouldDirty: true });
setValue("zone", data.zone, { shouldDirty: true });
setValue("woreda", data.woreda, { shouldDirty: true });
setValue("kebele", data.kebele, { shouldDirty: true });
setValue("houseNo", data.houseNo, { shouldDirty: true });
};
// A previously-verified TIN (every active company has one) counts as
// verified without a refetch — the registration fields being populated at
// all is proof it passed before.
const registration = watch([
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewalDate",
"renewedFrom",
"renewedTo",
]);
const hasRegistrationDetails = registration.some((v) => v && v.trim());
const tinVerified = tinStatus === "verified" || hasRegistrationDetails;
const mutation = useMutation({
mutationFn: async (data: CompanyProfileFormData) => {
// eTrade-owned fields are only resent when they actually changed this
// session (a real re-verify) — resubmitting the unchanged live values
// on every save would otherwise trigger the server's eTrade
// authenticity re-check for no reason.
const etradeBundle: Record<string, string | undefined> = {};
for (const key of ETRADE_BUNDLE_FIELDS) {
if (dirtyFields[key]) etradeBundle[key] = data[key];
}
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
const base = {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber ?? "",
...etradeBundle,
...(data.ownerPassportNumber !== undefined
? { ownerPassportNumber: data.ownerPassportNumber }
: {}),
@@ -119,6 +274,8 @@ export default function TabCompanyProfile({
: "customer";
const payload: CreateCompanyPayload = {
...base,
companyName: data.companyName,
tin: data.tinNumber,
companyType,
companyProfiles: selectedRoles.map((type) => ({
type: type as CompanyProfileInput["type"],
@@ -143,6 +300,15 @@ export default function TabCompanyProfile({
mutation.mutate(data);
};
const saveErrorMessage = mutation.isError
? extractApiError(mutation.error).message
: null;
const pendingOwnerReview = Boolean(
(profile?.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
?.faydaIdentity?.ownerFaydaSub,
);
// During onboarding the role selection gates the form: nothing else shows
// until the user picks Importer/Exporter or Freight Forwarder.
const showForm = !isCreate || selectedRoles.length > 0;
@@ -164,92 +330,62 @@ export default function TabCompanyProfile({
<Text c="edr-muted" size="sm" mb="lg">
{isCreate
? "Enter your company registration details to get started"
: "Edit your company registration details"}
: "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."}
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Company Name"
placeholder="Global Logistics Ltd"
error={errors.companyName?.message}
{...register("companyName")}
/>
<Stack gap="xl">
<StepSection
index={1}
title="VAT number"
status={watch("vatNumber") ? "done" : "todo"}
>
<TextInput
label="VAT Number (optional)"
placeholder="e.g. 0012345678"
maxLength={20}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</StepSection>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Company Email"
type="email"
placeholder="ops@company.com"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Address"
placeholder="Bole Subcity, Woreda 03"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="TIN Number (10 digits)"
placeholder="1234567890"
maxLength={10}
error={errors.tinNumber?.message}
{...register("tinNumber")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="VAT Number (optional)"
placeholder="e.g. 0012345678"
maxLength={20}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</Grid.Col>
</Grid>
{profile?.identity && (
<>
{identity && (
<StepSection
index={2}
title="Owner identity"
subtitle={
verifiedIdentity
? "Re-verify the company owner with Fayda — their name, phone, email and address are refreshed from the verification."
: "The company owner's passport number."
}
status={
verifiedIdentity
? identity.owner.verified
? "done"
: identity.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity.passportRequired
? "blocked"
: "todo"
}
>
<FaydaVerifyPanel
subject="owner"
title="Company owner"
state={profile.identity.owner}
required={profile.identity.faydaRequired}
state={identity.owner}
required={identity.faydaRequired}
disabled={mutation.isPending}
pendingReview={pendingOwnerReview}
onVerified={() =>
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
})
}
/>
{profile.identity.passportRequired && (
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
@@ -258,9 +394,45 @@ export default function TabCompanyProfile({
{...register("ownerPassportNumber")}
/>
)}
</>
<SimpleGrid cols={2} spacing="md">
<ReadOnlyField label="Company email" value={watch("companyEmail")} />
<ReadOnlyField label="Company phone" value={watch("companyPhone")} />
</SimpleGrid>
</StepSection>
)}
<StepSection
index={3}
title="Company TIN"
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
status={
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
}
>
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
error={errors.tinNumber?.message}
onDataLoaded={handleETradeDataLoaded}
onStatusChange={setTinStatus}
/>
{tinVerified && (
<EtradeLockedCard
tin={watch("tinNumber")}
register={register}
watch={watch}
errors={errors}
control={control}
/>
)}
</StepSection>
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
</Stack>
<Group
@@ -278,11 +450,11 @@ export default function TabCompanyProfile({
</Text>
</Group>
)}
{mutation.isError && (
{saveErrorMessage && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
{isCreate ? "Failed to create profile" : "Save failed"}
{saveErrorMessage}
</Text>
</Group>
)}
@@ -313,3 +485,111 @@ export default function TabCompanyProfile({
</Stack>
);
}
/**
* 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.
*/
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>
<Group justify="space-between" align="center" mb="md">
<Text fw={600} c="edr-text">
{companyName?.trim() ? companyName : "Company record"}
</Text>
<Text size="xs" c="edr-muted">
TIN {tin}
</Text>
</Group>
<SimpleGrid cols={2} spacing="sm">
<LockedField label="Company Name" name="companyName" register={register} watch={watch} errors={errors} />
<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")} />
{region?.trim() ? (
<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} />
</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;
if (value?.trim()) {
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

@@ -283,6 +283,10 @@ export default function TabPowerOfAttorney({
state={identity.poa}
required={identity.faydaRequired}
disabled={mutation.isPending}
pendingReview={Boolean(
(profile.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
?.faydaIdentity?.poaFaydaSub,
)}
onVerified={() => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),