From 848c0d7768dc9dd10c0169d905995b921f266d25 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 29 Jul 2026 14:30:35 +0000 Subject: [PATCH] fix: customer settings fix --- .../modules/companies/companies.controller.ts | 13 +- .../modules/companies/companies.repository.ts | 10 +- .../companies/companies.role-deselect.spec.ts | 113 +++++ .../modules/companies/companies.service.ts | 169 ++++++- .../customers/ChangeRequestReview.tsx | 86 +++- .../src/components/FaydaVerifyPanel.tsx | 20 +- .../onboarding/OnboardingWizardDialog.tsx | 6 +- .../portal/src/pages/SettingsPage.tsx | 2 +- .../src/pages/settings/TabCompanyProfile.tsx | 448 ++++++++++++++---- .../src/pages/settings/TabPowerOfAttorney.tsx | 4 + 10 files changed, 761 insertions(+), 110 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 825c332e5..ee75460c9 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { - 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); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index db8db0d2e..092ebc2c4 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository { .getMany(); } - async existsByTin(tin: string): Promise { - const count = await this.repository.count({ where: { tin } as any }); + async existsByTin(tin: string, excludeCompanyId?: string): Promise { + 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; } diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts new file mode 100644 index 000000000..f22123f61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -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) => ({ + 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 }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index b792ed4ec..50cc648ad 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 = { 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 { 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 { 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 { 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 { + 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> = { + 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.`, + ); + } + } + } } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index ff99be149..253d8aea5 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -59,6 +59,13 @@ const FIELD_LABELS: Record = { 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, +): "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; +}) { + 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 ( + + + + {subject === "owner" ? "Owner re-verification" : "PoA re-verification"} + + {verifiedAt && ( + + Verified {formatDate(verifiedAt)} + + )} + + {fields.length > 0 ? ( + + {fields.map((f) => ( + + ))} + + ) : ( + + Identity re-verified — no name/email/phone/address change. + + )} + + ); +} + 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 + | 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 }) { /> ))} - ) : ( + ) : !faydaIdentitySnapshot ? ( No field changes — document uploads only. + ) : null} + + {faydaIdentitySnapshot && ( + )} {documentChanges.length > 0 && ( diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx index 6897a1a33..fc00cfabe 100644 --- a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -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(null); @@ -151,6 +159,16 @@ export default function FaydaVerifyPanel({ ) )} + {pendingReview && ( + } + > + Re-verification pending review + + )}