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 175533c6d..5d29b32f6 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -288,10 +288,25 @@ export class CompaniesController { dto.roles, dto.nationality, dto.cooperative, + dto.investorLicence, ); return new CompanyInfoResponseDto(profile, company); } + @Post("onboarding/revert-to-etrade") + @PortalCustomer() + @ApiOperation({ + summary: + "Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade", + }) + async revertToRegularCompany( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.revertToRegularCompany(user.id); + return new CompanyInfoResponseDto(profile, company); + } + @Post("company-profile") @PortalCustomer() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts new file mode 100644 index 000000000..9de99186f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts @@ -0,0 +1,292 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { + CompanyNationality, + CompanyStatus, + CompanyType, +} from "./entities/company.entity"; +import { + ProfileStatus, + ProfileType, +} from "./entities/company-profile.entity"; + +/** + * A foreign company on an Investment Commission licence has no eTrade record, + * so it types its registration — and the flag saying so is what makes the + * backoffice treat those fields as unverified. Two things must hold: only a + * foreign company can carry it, and dropping it must not leave the typed + * registration behind looking like eTrade's. + * + * The dropping half is shared with the co-operative route, which has the same + * "eTrade holds nothing" shape, so it is exercised here for both. + */ +function makeService(company: Record | null) { + const companiesRepo = { + findById: jest.fn(async () => company), + update: jest.fn(async () => null), + create: jest.fn(async (row: Record) => ({ + id: "company-1", + ...row, + })), + existsByTin: jest.fn(async () => false), + }; + const companyProfilesRepo = { + findByCompanyId: jest.fn(async (): Promise[]> => []), + updateStatus: jest.fn(async () => null), + create: jest.fn(async (row: Record) => ({ + id: "cp-1", + ...row, + })), + softDelete: jest.fn(async () => undefined), + }; + const profilesRepo = { + findByUserId: jest.fn(async () => + company + ? { id: "external-1", companyId: "company-1", company: { id: "company-1" } } + : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "external-1", + ...row, + })), + update: jest.fn(async () => null), + }; + + const service = new CompaniesService( + companiesRepo as never, + companyProfilesRepo as never, + {} 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, companiesRepo, companyProfilesRepo, profilesRepo }; +} + +const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" }; + +const start = ( + service: CompaniesService, + nationality: CompanyNationality | undefined, + cooperative: boolean, + investorLicence: boolean, +) => + service.startOnboarding( + identity as never, + CompanyType.Customer, + [ProfileType.importer], + nationality, + cooperative, + investorLicence, + ); + +describe("the foreign investment-licence route", () => { + it("refuses the flag for an Ethiopian company", async () => { + const { service } = makeService(null); + await expect( + start(service, CompanyNationality.Ethiopian, false, true), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses the flag alongside the co-operative one", async () => { + const { service } = makeService(null); + await expect( + start(service, CompanyNationality.Foreign, true, true), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("stores the flag on a new foreign draft", async () => { + const { service, companiesRepo } = makeService(null); + await start(service, CompanyNationality.Foreign, false, true); + expect(companiesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + nationality: CompanyNationality.Foreign, + attributes: { investorLicence: true }, + }), + ); + }); + + it("clears the typed registration and reopens onboarding when switching back to eTrade", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + id: "company-1", + attributes: { investorLicence: true, etradeManagerName: "Typed Name" }, + }); + + await service.revertToRegularCompany("user-1"); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(updates.attributes).toEqual({}); + expect(updates.status).toBe(CompanyStatus.Pending); + // The wizard treats a populated registration as a passed lookup, so leaving + // any of it behind would walk the customer straight past the eTrade step. + expect(updates.licenceNumber).toBeNull(); + expect(updates.region).toBeNull(); + expect(profilesRepo.update).toHaveBeenCalledWith("external-1", { + onboardingCompleted: false, + onboardingStep: "company", + }); + }); + + it("clears the typed registration when the box is un-ticked on the way back", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + id: "company-1", + nationality: CompanyNationality.Foreign, + attributes: { investorLicence: true, etradeManagerName: "Typed Name" }, + region: "Addis Ababa", + licenceNumber: "TYPED-1", + }); + + await start(service, CompanyNationality.Foreign, false, false); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + // The wizard sends both flags; the typed manager does not survive. + expect(updates.attributes).toEqual({ + cooperative: false, + investorLicence: false, + }); + expect(updates.licenceNumber).toBeNull(); + expect(updates.region).toBeNull(); + // Resume must land back on the company step, or the customer never reaches + // the eTrade lookup they just opted back into. + expect(profilesRepo.update).toHaveBeenCalledWith("external-1", { + onboardingStep: "company", + }); + }); + + it("does the same for a co-operative that stops being one", async () => { + const { service, companiesRepo } = makeService({ + id: "company-1", + nationality: CompanyNationality.Ethiopian, + attributes: { cooperative: true }, + region: "Oromia", + }); + + await start(service, CompanyNationality.Ethiopian, false, false); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(updates.region).toBeNull(); + }); + + it("leaves the registration alone while the flag stays on", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + id: "company-1", + nationality: CompanyNationality.Foreign, + attributes: { investorLicence: true }, + region: "Addis Ababa", + }); + + await start(service, CompanyNationality.Foreign, false, true); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(updates).not.toHaveProperty("region"); + expect(profilesRepo.update).not.toHaveBeenCalled(); + }); + + it("refuses to switch a company that never took a manual-registration route", async () => { + const { service } = makeService({ id: "company-1", attributes: {} }); + await expect(service.revertToRegularCompany("user-1")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + /** + * The switch belongs to both manual-registration routes, not just this one. A + * co-operative that has since taken out a trade licence had no way back at + * all: the wizard is where the flag is chosen, and an onboarded company can no + * longer reach it. + */ + it("switches a co-operative back to eTrade on the same terms", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + id: "company-1", + nationality: CompanyNationality.Ethiopian, + attributes: { cooperative: true, etradeManagerName: "Typed Name" }, + region: "Oromia", + licenceNumber: "TYPED-1", + }); + + await service.revertToRegularCompany("user-1"); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(updates.attributes).toEqual({}); + expect(updates.status).toBe(CompanyStatus.Pending); + expect(updates.licenceNumber).toBeNull(); + expect(updates.region).toBeNull(); + // A co-op owes no per-role business licence; once it stops being one it + // does, so the application has to be re-opened and re-reviewed. + expect(profilesRepo.update).toHaveBeenCalledWith("external-1", { + onboardingCompleted: false, + onboardingStep: "company", + }); + }); + + /** + * Approval of a co-op's role was granted without a business licence, because + * a co-op owes none. Leaving makes one due, so the approval no longer stands + * for what it said. + */ + it("sends a co-operative's approved roles back for approval", async () => { + const { service, companyProfilesRepo } = makeService({ + id: "company-1", + attributes: { cooperative: true }, + }); + companyProfilesRepo.findByCompanyId.mockResolvedValue([ + { id: "role-active", status: ProfileStatus.Active }, + { id: "role-blocked", status: ProfileStatus.Blacklisted }, + { id: "role-pending", status: ProfileStatus.Pending }, + ]); + + await service.revertToRegularCompany("user-1"); + + expect(companyProfilesRepo.updateStatus).toHaveBeenCalledWith( + "role-active", + ProfileStatus.Pending, + ); + // A staff decision is not the customer's to undo by switching registration: + // promoting a blocked role to "awaiting approval" would launder the block. + expect(companyProfilesRepo.updateStatus).toHaveBeenCalledTimes(1); + }); + + it("leaves an investor's roles alone — their licences were always due", async () => { + const { service, companyProfilesRepo } = makeService({ + id: "company-1", + attributes: { investorLicence: true }, + }); + companyProfilesRepo.findByCompanyId.mockResolvedValue([ + { id: "role-active", status: ProfileStatus.Active }, + ]); + + await service.revertToRegularCompany("user-1"); + + expect(companyProfilesRepo.updateStatus).not.toHaveBeenCalled(); + }); +}); 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 bf8184833..1eb611c24 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -63,7 +63,10 @@ import { CompanyStatus, CompanyType, COOPERATIVE_KEY, + INVESTOR_LICENCE_KEY, + hasInvestorLicence, isCooperative, + usesManualRegistration, } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { @@ -378,6 +381,7 @@ export class CompaniesService { roles: ProfileType[], nationality?: CompanyNationality, cooperative?: boolean, + investorLicence?: boolean, ): Promise<{ profile: ExternalProfile; company: Company }> { // Already started — reuse the existing draft, just ensure roles exist and // keep the nationality up to date if it was (re)selected. @@ -388,13 +392,20 @@ export class CompaniesService { // flag into `attributes`, or to read a stored one the caller didn't send. const needsCompany = cooperative !== undefined || + investorLicence !== undefined || roles.includes(ProfileType.freightForwarder); const current = needsCompany ? await this.companiesRepo.findById(companyId) : null; const isCoop = cooperative ?? isCooperative(current); + const isInvestor = investorLicence ?? hasInvestorLicence(current); this.assertRolesAllowedForCooperative(isCoop, roles); this.assertNationalityAllowedForCooperative(isCoop, nationality); + this.assertInvestorLicenceAllowed( + isInvestor, + isCoop, + nationality ?? current?.nationality ?? undefined, + ); await this.syncCompanyProfiles(companyId, companyType, roles); const updates: Partial = {}; if (nationality) updates.nationality = nationality; @@ -402,20 +413,49 @@ export class CompaniesService { // stored nationality too, or the company keeps resolving to the foreign // document set. if (isCoop) updates.nationality = CompanyNationality.Ethiopian; - if (cooperative !== undefined) { + if (cooperative !== undefined || investorLicence !== undefined) { updates.attributes = { ...(current?.attributes ?? {}), - [COOPERATIVE_KEY]: cooperative, + ...(cooperative !== undefined + ? { [COOPERATIVE_KEY]: cooperative } + : {}), + ...(investorLicence !== undefined + ? { [INVESTOR_LICENCE_KEY]: investorLicence } + : {}), }; } + // Going back and un-ticking the box is the same act as the settings + // switch, so it has to cost the same: the registration the customer typed + // goes, and onboarding drops back to the company step. Without this the + // draft keeps the typed values, `hasRegistrationDetails` reads as a passed + // lookup, resume lands past the company step entirely — and the company + // finishes onboarding on unverified data with no flag left to say so. + const backToEtrade = + usesManualRegistration(current) && !isCoop && !isInvestor; + if (backToEtrade) { + Object.assign(updates, CompaniesService.CLEARED_REGISTRATION); + updates.attributes = this.withoutTypedEtradeManager( + updates.attributes ?? current?.attributes, + ); + } if (Object.keys(updates).length > 0) { await this.companiesRepo.update(companyId, updates); } + if (backToEtrade) { + await this.profilesRepo.update(existing.id, { + onboardingStep: "company", + }); + } return this.getCompanyInfoByUserId(identity.userId); } this.assertRolesAllowedForCooperative(cooperative === true, roles); this.assertNationalityAllowedForCooperative(cooperative === true, nationality); + this.assertInvestorLicenceAllowed( + investorLicence === true, + cooperative === true, + nationality, + ); const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); @@ -428,7 +468,14 @@ export class CompaniesService { country: "Ethiopia", nationality: nationality ?? CompanyNationality.Ethiopian, status: CompanyStatus.Pending, - ...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}), + ...(cooperative || investorLicence + ? { + attributes: { + ...(cooperative ? { [COOPERATIVE_KEY]: true } : {}), + ...(investorLicence ? { [INVESTOR_LICENCE_KEY]: true } : {}), + }, + } + : {}), }); await this.profilesRepo.create({ @@ -485,6 +532,73 @@ export class CompaniesService { } } + /** + * The registration block as it must look when nobody has verified it. + * + * Used wherever a company stops being one eTrade cannot answer for: whatever + * sits in these columns was the customer's own statement, and the wizard + * treats a populated registration as a lookup that already passed + * (`hasRegistrationDetails`). Leaving it behind would hand the company an + * eTrade-verified record eTrade never supplied — and, once the flag is gone, + * a backoffice screen that says so. + */ + private static readonly CLEARED_REGISTRATION: Partial = { + licenceNumber: null, + statusDescription: null, + dateRegistered: null, + renewedFrom: null, + renewalDate: null, + renewedTo: null, + region: null, + zone: null, + woreda: null, + kebele: null, + houseNo: null, + etradePhone: null, + }; + + /** + * The company's own `attributes`, minus the manager captured alongside a + * typed registration. It never came from a licence, so it must not outlive + * the registration it belonged to. + */ + private withoutTypedEtradeManager( + attributes: Record | null | undefined, + ): Record { + const next = { ...(attributes ?? {}) }; + delete next.etradeManagerName; + delete next.etradeManagerPhone; + return next; + } + + /** + * An investment licence belongs to a foreign company and to nothing else. + * + * It is the Ethiopian Investment Commission's licence, issued to a foreign + * investor — an Ethiopian company registers with the trade registry, which is + * exactly the eTrade record this flag says does not exist. A co-operative + * cannot hold one either: it is Ethiopian by construction, and the two flags + * resolve to different document sets, so a company carrying both would owe an + * incoherent list of papers. + */ + private assertInvestorLicenceAllowed( + investorLicence: boolean, + cooperative: boolean, + nationality: CompanyNationality | undefined, + ): void { + if (!investorLicence) return; + if (cooperative) { + throw new BadRequestException( + "A co-operative union or farm is registered in Ethiopia — it cannot also onboard on a foreign investment licence.", + ); + } + if (nationality !== CompanyNationality.Foreign) { + throw new BadRequestException( + "Only a foreign company can onboard on an investment licence.", + ); + } + } + /** * Reconcile the company's operational profiles with the roles the user has * selected: create the missing ones, drop the ones they deselected. @@ -2143,6 +2257,7 @@ export class CompaniesService { // or farm holds no business licence, so it owes its own list rather than the // nationality list plus extras. const cooperative = isCooperative(company); + const investorLicence = hasInvestorLicence(company); const documentSettingCode = this.documentSettingCodeFor(company); const [setting, uploadedFiles] = await Promise.all([ this.fileUploadSettingsService @@ -2292,6 +2407,7 @@ export class CompaniesService { documentSettingCode, nationality: company.nationality ?? CompanyNationality.Ethiopian, cooperative, + investorLicence, companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo, @@ -2369,6 +2485,97 @@ export class CompaniesService { return this.getCompanyInfoByUserId(userId); } + /** + * Drop whichever manual-registration route the company is on and send it back + * through the normal eTrade one. + * + * Both routes exist for the same reason — eTrade holds no record to fetch — + * so leaving one is the same act whichever it is, and it is the only way back + * to eTrade for either. A co-operative union or farm that has since taken out + * a trade licence had no exit at all before this; its only route was the + * wizard, which an onboarded company can no longer reach. + * + * Everything the flag let the customer type is cleared, not kept: the + * registration block on file was their own statement, and leaving it there + * would let the wizard treat the company as already looked-up + * (`hasRegistrationDetails` is what stands in for a verified TIN on a + * resume) and walk straight past the eTrade step this switch exists to + * reach. Onboarding reopens at the company step and the company goes back to + * pending — an approval granted against typed data cannot silently carry over + * to a record that now claims to be eTrade's. + * + * Switching the other way — INTO a co-operative or an investment licence — is + * deliberately not here. It is the wizard's nationality/role step, which this + * reopens, and which is the one place the mutually-exclusive rules live + * (`assertInvestorLicenceAllowed`, `assertRolesAllowedForCooperative`, + * `assertNationalityAllowedForCooperative`). A second entry point would have + * to restate all three. + */ + async revertToRegularCompany( + userId: string, + ): Promise<{ profile: ExternalProfile; company: Company }> { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + + const companyId = profile.company?.id ?? profile.companyId; + const company = await this.companiesRepo.findById(companyId); + if (!company) + throw new NotFoundException(`Company ${companyId} not found`); + if (!usesManualRegistration(company)) { + throw new BadRequestException( + "This company is already registered through eTrade — there is nothing to switch.", + ); + } + + const wasCooperative = isCooperative(company); + + // Both flags go, not just the one that was set: they are mutually exclusive + // and a company can only ever hold one, but the destination is "neither", + // so stripping only the one we happened to check for would leave the other + // behind if the pair ever did coexist. + const attributes = this.withoutTypedEtradeManager(company.attributes); + delete attributes[INVESTOR_LICENCE_KEY]; + delete attributes[COOPERATIVE_KEY]; + + await this.companiesRepo.update(companyId, { + ...CompaniesService.CLEARED_REGISTRATION, + attributes, + status: CompanyStatus.Pending, + }); + await this.profilesRepo.update(profile.id, { + onboardingCompleted: false, + onboardingStep: "company", + }); + + // A co-operative owes no per-role business licence — that is the whole + // reason its own document set stands in for one. The moment it stops being + // one, every role owes a licence that was never uploaded, so an approval + // granted without one no longer means what it said: back to Pending, and + // the reviewer sees the licence with the rest of the re-application. + // + // Only Active roles move. Rejected, Suspended and Blacklisted are the + // backoffice's own decisions, and quietly promoting a blocked role to + // "awaiting approval" would launder the block away. The reference survives + // either way — it is minted once (`setCompanyProfileStatus`) and re-approval + // reuses it, so bookings that cite it keep citing the same number. + // + // An investor is untouched: it always held a licence per role, so nothing + // becomes due that was not already reviewed. + if (wasCooperative) { + const roles = await this.companyProfilesRepo.findByCompanyId(companyId); + for (const role of roles) { + if (role.status !== ProfileStatus.Active) continue; + await this.companyProfilesRepo.updateStatus( + role.id, + ProfileStatus.Pending, + ); + } + } + + return this.getCompanyInfoByUserId(userId); + } + /** * Block a self-service action when the company account isn't active, naming * the actual status — a suspended customer told "awaiting approval" has no @@ -3535,7 +3742,7 @@ export class CompaniesService { // the registered address themselves, and what they send IS the data. The // check is skipped rather than failed: running the lookup would 400 every // save with "no registration found for this TIN". - if (isCooperative(company)) return; + if (usesManualRegistration(company)) return; const touched = ETRADE_SOURCED_FIELDS.some( (key) => key !== "tin" && dto[key] !== undefined, diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 20bd0ab06..49dad4b8d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -78,6 +78,14 @@ export class OnboardingRequirementsResponseDto { */ cooperative: boolean; + /** + * The company is a foreign investor on an investment licence: no eTrade + * record, so the registration was typed. The nationality document set still + * applies (it already asks for the investment licence itself), and so does + * the per-role business licence. + */ + investorLicence: boolean; + /** Required company-information fields and whether each is filled. */ companyInfo: { complete: boolean; @@ -116,6 +124,7 @@ export class OnboardingRequirementsResponseDto { this.documentSettingCode = init.documentSettingCode; this.nationality = init.nationality; this.cooperative = init.cooperative; + this.investorLicence = init.investorLicence; this.companyInfo = init.companyInfo; this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 0d7293ab0..14753f8ef 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -2,7 +2,11 @@ import { buildCompanyIdentityState, CompanyIdentityStateDto, } from "./complete-identity-verification.dto"; -import { Company, isCooperative } from "../entities/company.entity"; +import { + Company, + hasInvestorLicence, + isCooperative, +} from "../entities/company.entity"; import { ExternalProfile } from "../entities/external-profile.entity"; import { ChangeRequestStatus, @@ -21,6 +25,12 @@ export class ProfileResponseDto { * it from eTrade. */ cooperative: boolean; + /** + * The company is a foreign investor on an investment licence: eTrade holds + * no record, so the company step collects the registration by hand. Drives + * the settings card that switches back to the eTrade route. + */ + investorLicence: boolean; companyLocation: string; companyAddress: string | null; tinNumber: string; @@ -93,6 +103,7 @@ export class ProfileResponseDto { this.companyType = company.type; this.nationality = company.nationality ?? null; this.cooperative = isCooperative(company); + this.investorLicence = hasInvestorLicence(company); this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? []; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index e75182889..7c4348e4f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -3,6 +3,7 @@ import { CompanyType, CompanyStatus, CompanyNationality, + hasInvestorLicence, isCooperative, } from '../entities/company.entity'; import { @@ -62,6 +63,13 @@ export class ResponseCompanyDto { * eTrade manager to check the owner against. */ cooperative: boolean; + /** + * The company onboarded as a foreign investor on an investment licence: + * eTrade holds no record for its TIN, so its registration below was typed by + * the customer rather than fetched — nothing here has been checked against a + * licence, and the reviewer is the check. + */ + investorLicence: boolean; tin: string; vatNumber?: string | null; fanNumber?: string | null; @@ -118,6 +126,7 @@ export class ResponseCompanyDto { this.status = company.status; this.nationality = company.nationality ?? null; this.cooperative = isCooperative(company); + this.investorLicence = hasInvestorLicence(company); this.tin = company.tin; this.vatNumber = company.vatNumber; this.fanNumber = company.fanNumber; diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts index 91fcb44a1..0eac35a5f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -31,4 +31,15 @@ export class StartOnboardingDto { @IsOptional() @IsBoolean() cooperative?: boolean; + + /** + * The company is a foreign investor: it operates on an investment licence + * issued by the Ethiopian Investment Commission, so eTrade holds no record + * for its TIN and the registration is typed here instead. Chosen on the same + * step for the same reason as the co-operative flag — it decides what the + * company step asks for. Only a foreign company can hold one. + */ + @IsOptional() + @IsBoolean() + investorLicence?: boolean; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 54c36bf41..68bf73cdf 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -51,6 +51,37 @@ export function isCooperative( return company?.attributes?.[COOPERATIVE_KEY] === true; } +/** + * `attributes` key marking a foreign company onboarding on an investment + * licence. + * + * The Ethiopian Investment Commission registers it, not the trade registry, so + * eTrade holds no record for its TIN: the registration is typed and the eTrade + * authenticity check is skipped rather than failed — exactly as for a + * co-operative. What does NOT change is the licence: the company still holds + * one per operational role, so that requirement stands. + */ +export const INVESTOR_LICENCE_KEY = "investorLicence"; + +/** Is this a foreign company registered on an investment licence? */ +export function hasInvestorLicence( + company: Pick | null | undefined, +): boolean { + return company?.attributes?.[INVESTOR_LICENCE_KEY] === true; +} + +/** + * eTrade holds nothing for this company, so its registration was typed by hand + * rather than fetched — and the backoffice is told so. Two different companies + * reach it (a co-operative has no licence at all; a foreign investor's is not + * the trade registry's), and every consequence they share hangs off this. + */ +export function usesManualRegistration( + company: Pick | null | undefined, +): boolean { + return isCooperative(company) || hasInvestorLicence(company); +} + @Entity({ schema: "freight", name: "companies" }) @Index(["tin"]) @Index(["type"]) diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 90dea5adb..22d3e66cf 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -114,6 +114,36 @@ export function CompanyNationalityBadge({ ); } +/** + * The company's registration was typed, not fetched from eTrade — nothing in it + * has been checked against a licence. Loud on purpose: it is the one thing a + * reviewer must not miss about this customer. Two kinds of company land here + * for different reasons, and the badge names which. + */ +export function ManualRegistrationBadge({ + cooperative, + investorLicence, +}: { + cooperative?: boolean | null; + investorLicence?: boolean | null; +}) { + if (!cooperative && !investorLicence) return null; + return ( + + {cooperative + ? "Manual entry · co-operative" + : "Manual entry · investment licence"} + + ); +} + /** * Profile chips for a company row: one chip per role (Importer / Exporter / …) * carrying its reference code, colored by the profile's status (green active, diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 6f869173c..864a89ae6 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -4,6 +4,7 @@ export { CompanyStatusBadge, CompanyTypeBadge, InvoiceStatusBadge, + ManualRegistrationBadge, PaymentStatusBadge, ProfileApprovalActions, ProfileChips, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 40185f990..a81f07c8b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -50,6 +50,7 @@ import { CompanyTimeline, CompanyTypeBadge, InvoiceStatusBadge, + ManualRegistrationBadge, PaymentStatusBadge, PersonCard, ProfileApprovalActions, @@ -744,6 +745,10 @@ export default function CustomerDetailPage() { ) : ( )} + } @@ -792,6 +797,25 @@ export default function CustomerDetailPage() { )} + {/* Nothing below came from eTrade for these customers. A + co-operative holds no trade licence at all; a foreign investor's + comes from the Investment Commission, not the trade registry. + Either way every registration field was typed, and the reviewer + is the only check there is. */} + {(company.cooperative || company.investorLicence) && ( + } + title="Registration entered by hand — not verified against eTrade" + > + {company.cooperative + ? "This company onboarded as a co-operative union or farm, which holds no trade licence, so eTrade had no record to look its TIN up in. The company name, registration and address below are the customer's own statement. Check them against the Co-operative Registration Certificate on the Documents tab before approving." + : "This company onboarded on a foreign investment licence, so we could not look its TIN up on eTrade. The company name, registration and address below are the customer's own statement. Check them against the Investment Licence on the Documents tab before approving."} + + )} + diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 91bb72fdc..3825f0165 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -28,6 +28,7 @@ import { useNavigate } from "react-router-dom"; import { CompanyNationalityBadge, CompanyStatusBadge, + ManualRegistrationBadge, ProfileChips, formatDate, } from "@/components/customers"; @@ -142,6 +143,10 @@ export default function CustomersPage() { {c.name} + TIN {c.tin} diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 7fa7d792a..8c95e7342 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -234,6 +234,13 @@ export interface Company { * manager to check the owner against, and it holds no freight-forwarder role. */ cooperative?: boolean; + /** + * A foreign investor on an Ethiopian Investment Commission licence: eTrade + * holds no record for its TIN, so every registration field below was typed by + * the customer and verified by nobody. The reviewer is the check — compare + * them against the investment licence on the Documents tab. + */ + investorLicence?: boolean; address?: string | null; phone?: string | null; email?: string | null; diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index d5a87a4ee..b06b1487c 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -162,6 +162,13 @@ export default function OnboardingWizardDialog({ const [cooperative, setCooperative] = useState( company?.company?.attributes?.cooperative === true, ); + // A foreign company operating on an Ethiopian Investment Commission licence. + // eTrade holds nothing for its TIN, so it types the registration exactly as a + // co-operative does — but it still holds a licence per role, so nothing about + // the licence step changes. + const [investorLicence, setInvestorLicence] = useState( + company?.company?.attributes?.investorLicence === true, + ); // Ticking the box drops the selections the company can no longer hold, rather // than letting Continue fail on ones the API refuses: a co-op cannot forward // freight, and is registered in Ethiopia so it is never foreign. @@ -171,8 +178,20 @@ export default function OnboardingWizardDialog({ setRoles((prev) => prev.filter((r) => r !== "freight_forwarder")); // Ethiopian is then the only answer left, so it is made rather than asked. setNationality("ethiopian"); + // Which also rules out the investment licence — that is a foreign + // company's, and the API refuses the pair. + setInvestorLicence(false); } }, []); + // The investment licence is a foreign company's document. Moving the answer + // back to Ethiopian drops it rather than sending a pair the API refuses. + const handleNationalityChange = useCallback( + (value: CompanyNationality | null) => { + setNationality(value); + if (value !== "foreign") setInvestorLicence(false); + }, + [], + ); const [documentFiles, setDocumentFiles] = useState< Record >({}); @@ -224,6 +243,7 @@ export default function OnboardingWizardDialog({ roles: ProfileTypeValue[]; nationality?: CompanyNationality; cooperative?: boolean; + investorLicence?: boolean; }) => api.companies.startOnboarding.call(vars), onSuccess: async () => { // Nationality drives the server-resolved identity requirements (Fayda vs @@ -307,6 +327,7 @@ export default function OnboardingWizardDialog({ setRoles(existingProfiles.map((p) => p.type)); setNationality(savedNationality); setCooperative(company?.company?.attributes?.cooperative === true); + setInvestorLicence(company?.company?.attributes?.investorLicence === true); // Resume into the form only when profiles exist; otherwise send the user to // role selection so the missing operational profiles get created. setPhase(hasOperationalProfiles ? "form" : "nationality-role"); @@ -322,8 +343,9 @@ export default function OnboardingWizardDialog({ roles: roles as ProfileTypeValue[], nationality: nationality ?? undefined, cooperative, + investorLicence, }); - }, [roles, nationality, cooperative, startMutation]); + }, [roles, nationality, cooperative, investorLicence, startMutation]); // Back from the form's first step returns to nationality/role selection. // Safe to re-enter: startOnboarding is idempotent — it reuses the existing @@ -470,6 +492,15 @@ export default function OnboardingWizardDialog({ licenseFiles, onLicenseChange: setLicenseFiles, uploadedDocumentKeys, + // What the server says is already on file, per operational profile. The + // wizard's own `roleProfiles` cannot say: getInfo leaves `licenseFiles` + // empty, so a resumed wizard asked for a licence it had already been given + // and refused to submit until it was uploaded a second time. + uploadedLicenceProfileIds: ( + requirementsQuery.data?.licenseProfiles ?? [] + ) + .filter((p) => p.uploaded) + .map((p) => p.profileId), onUploadDocuments: handleUploadDocuments, // The company's single identity verification, and whose it is. Fayda is // mandatory for an Ethiopian company; a foreign one may instead type a @@ -479,6 +510,10 @@ export default function OnboardingWizardDialog({ // startOnboarding has persisted it, and the form's whole company step // branches on it. cooperative: requirementsQuery.data?.cooperative ?? cooperative, + // Same rule, same reason: only a persisted flag changes what the company + // step asks for. + investorLicence: + requirementsQuery.data?.investorLicence ?? investorLicence, // A freight forwarder cannot answer the power-of-attorney question — the // API forces "yes" — so the step offers no way to change it. declarationLocked: requirementsQuery.data?.poa?.locked ?? false, @@ -546,7 +581,7 @@ export default function OnboardingWizardDialog({ + {/* Only a foreign company is offered this: the licence is the + Investment Commission's, and it is the reason eTrade has + nothing to look up. Same consequence as the co-operative box — + typed registration instead of a lookup — but the per-role + business licence still applies, so the documents step is + unchanged. */} + {nationality === "foreign" && !cooperative && ( + + setInvestorLicence(e.currentTarget.checked) + } + label="We operate on a foreign investment licence" + description="For investors registered with the Ethiopian Investment Commission rather than the trade registry. eTrade holds no record of your TIN, so you'll type your registration details instead — and our team reviews them by hand." + /> + )} What does your company do?(multiple) diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index c9576d192..cefef5ddc 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -103,6 +103,7 @@ export const URL_CONSTANTS = { ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements", + ONBOARDING_REVERT_TO_ETRADE: "/api/companies/onboarding/revert-to-etrade", DASHBOARD: "/api/companies/dashboard", FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 13b0d4025..53e0cf0a3 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -47,6 +47,7 @@ import useAuth from "@/hooks/useAuth"; import { rolesForCompanyType } from "./settings/companyRoles"; import TabAccount from "./settings/TabAccount"; import TabCompanyProfile from "./settings/TabCompanyProfile"; +import RegistrationSourceCard from "./settings/RegistrationSourceCard"; import TabContactPerson from "./settings/TabContactPerson"; import TabDocuments from "./settings/TabDocuments"; import TabOwner from "./settings/TabOwner"; @@ -398,6 +399,7 @@ export default function SettingsPage() { /> + diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index aa445974f..fd0e060e1 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -61,10 +61,12 @@ export default function CompanyProfileForm({ onLicenseChange, submitError, uploadedDocumentKeys, + uploadedLicenceProfileIds, onUploadDocuments, identity: rawIdentity, onIdentityChange, cooperative = false, + investorLicence = false, declarationLocked = false, }: { documentSettingCode: string; @@ -95,6 +97,16 @@ export default function CompanyProfileForm({ submitError?: string | null; /** fileKeys whose company document is already uploaded server-side (resume). */ uploadedDocumentKeys?: string[]; + /** + * Profile ids whose business licence the server already holds. + * + * `roleProfiles.existingFiles` cannot answer this on a resumed wizard: + * `getInfo` does not populate `licenseFiles`, so every profile looks empty + * however many licences are on file. Taken from the onboarding requirements, + * which is the server's own verdict and what `markOnboardingComplete` + * enforces. + */ + uploadedLicenceProfileIds?: string[]; /** * Auto-upload the currently-selected company documents (the Documents step's * "Continue" action). Resolves to an error message string on failure so the @@ -118,6 +130,13 @@ export default function CompanyProfileForm({ * nationality one (resolved by the caller into `documentSettingCode`). */ cooperative?: boolean; + /** + * The company is a foreign investor on an Investment Commission licence. Like + * a co-operative, eTrade holds no record of it, so the registration is typed + * and the lookup gate does not apply — but it does hold a business licence + * per role, so the licence step is untouched. + */ + investorLicence?: boolean; /** * The company operates as a freight forwarder, so the power-of-attorney * answer is forced to "yes" and cannot be changed here. @@ -133,6 +152,12 @@ export default function CompanyProfileForm({ [rawIdentity], ); + // eTrade has nothing to say about this company, whichever of the two reasons + // applies — so the registration is typed here and the lookup cannot gate the + // step. Everything the two cases do NOT share (the per-role business licence) + // keeps reading `cooperative` on its own. + const manualRegistration = cooperative || investorLicence; + const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); /** @@ -434,7 +459,7 @@ export default function CompanyProfileForm({ // holds nothing — and wiping them because the customer went back to fix a // digit of their TIN would throw away an address they had just typed by // hand, over a lookup that never filled anything in the first place. - if (cooperative && !etradeFilledRef.current) { + if (manualRegistration && !etradeFilledRef.current) { setLiveEtradeOwner(null); setEtradeCleared(true); return; @@ -674,7 +699,9 @@ export default function CompanyProfileForm({ if (cooperative) return errs; for (const p of roleProfiles ?? []) { const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0; - const hasExisting = p.existingFiles.length > 0; + const hasExisting = + p.existingFiles.length > 0 || + (uploadedLicenceProfileIds ?? []).includes(p.id); if (!hasNew && !hasExisting) { errs[p.id] = "Business license is required"; } @@ -730,7 +757,7 @@ export default function CompanyProfileForm({ // A co-operative never runs the lookup, so there is nothing to be verified // against; its TIN is validated by the schema like any other typed field. const tinVerified = - cooperative || tinStatus === "verified" || hasRegistrationDetails; + manualRegistration || tinStatus === "verified" || hasRegistrationDetails; // Single source of truth for step sequence — navigation, labels and the // progress bar all derive from this so adding/removing a step is one edit. @@ -770,8 +797,8 @@ export default function CompanyProfileForm({ Boolean(watch(passportField)?.trim())); const requiredKeys: (keyof FormData)[] = []; - if (step === "company" && cooperative) { - // A co-operative has no eTrade record, so the fields every other company + if (step === "company" && manualRegistration) { + // These companies have no eTrade record, so the fields every other company // gets read-only from the licence are typed here — and are therefore // required here. House number stays optional: plenty of addresses have none. requiredKeys.push("companyName", "region", "zone", "woreda", "kebele"); @@ -887,8 +914,9 @@ export default function CompanyProfileForm({ } // The TIN must resolve to a real eTrade record before anything else on // this step is even worth validating — gates here rather than through zod. - // A co-operative is exempt: it has no licence for eTrade to hold, so - // `tinVerified` is true for it and only the duplicate-TIN check applies. + // A co-operative and a foreign investor are exempt: eTrade holds no record + // for either, so `tinVerified` is true and only the duplicate-TIN check + // applies. if (step === "company" && tinStatus === "taken") { failCheck( "This TIN is already registered to another company account.", @@ -987,6 +1015,7 @@ export default function CompanyProfileForm({ tinStatus={tinStatus} tinVerified={tinVerified} hasRegistrationDetails={hasRegistrationDetails} + manualRegistration={manualRegistration} cooperative={cooperative} onETradeDataLoaded={handleETradeDataLoaded} onETradeStatusChange={setTinStatus} @@ -1002,6 +1031,7 @@ export default function CompanyProfileForm({ source={ownerSource} sourced={ownerSourced} cooperative={cooperative} + manualRegistration={manualRegistration} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx index b89e0e920..52893ec59 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx @@ -17,10 +17,13 @@ export interface CompanyInfoStepProps { /** Registration fields are already populated (a lookup passed, now or earlier). */ hasRegistrationDetails: boolean; /** - * The company is a co-operative union or farm: it has a TIN but no business - * licence, so eTrade holds no record to look up and the registration is typed - * here instead. + * eTrade holds no record for this company's TIN, so the registration is typed + * here rather than fetched. True for a co-operative union or farm (no + * business licence) and for a foreign investor (licensed by the Investment + * Commission, not the trade registry). */ + manualRegistration?: boolean; + /** Which of the two it is — wording only; the behaviour is the same. */ cooperative?: boolean; onETradeDataLoaded: (data: CompanyRegistrationData) => void; onETradeStatusChange: (status: ETradeStatus) => void; @@ -32,6 +35,7 @@ export default function CompanyInfoStep({ tinStatus, tinVerified, hasRegistrationDetails, + manualRegistration = false, cooperative = false, onETradeDataLoaded, onETradeStatusChange, @@ -71,14 +75,16 @@ export default function CompanyInfoStep({ index={2} title="Company TIN" subtitle={ - cooperative - ? "We'll check eTrade for your TIN. Co-operatives often aren't listed — if yours isn't, you'll fill the details in below." - : "We'll pull your registration straight from eTrade — nothing to type by hand once it's found." + !manualRegistration + ? "We'll pull your registration straight from eTrade — nothing to type by hand once it's found." + : cooperative + ? "We'll check eTrade for your TIN. Co-operatives often aren't listed — if yours isn't, you'll fill the details in below." + : "We'll check eTrade for your TIN. An investment licence usually isn't on it — if yours isn't, you'll fill the details in below." } status={ tinStatus === "taken" ? "blocked" - : cooperative + : manualRegistration ? watch("tinNumber")?.trim() && !errors.tinNumber ? "done" : "todo" @@ -96,26 +102,26 @@ export default function CompanyInfoStep({ onReset={onETradeReset} alreadyVerified={hasRegistrationDetails} selectedLicenceNumber={watch("licenceNumber")} - registrationOptional={cooperative} + registrationOptional={manualRegistration} /> - {!cooperative && tinVerified && ( + {!manualRegistration && tinVerified && ( )} - {/* A co-operative keeps its typed registration section either way. When - the lookup found something these arrive prefilled — still editable, - because for a co-op they are the customer's own statement rather than - the licence's, and the API takes them as given (`applyEtradeSourcedFields` - skips co-operatives entirely). */} - {cooperative && ( + {/* A company eTrade cannot answer for keeps its typed registration + section either way. When the lookup did find something these arrive + prefilled — still editable, because here they are the customer's own + statement rather than the licence's, and the API takes them as given + (`applyEtradeSourcedFields` skips both cases entirely). */} + {manualRegistration && ( @@ -149,7 +159,15 @@ export default function CompanyInfoStep({ searchable value={region || null} onChange={(v) => - setValue("region", v ?? "", { shouldValidate: true }) + // shouldDirty, or the pick never reaches the API: `region` is + // an eTrade-bundle key, and `stepPayload` sends those only + // when the customer changed them this session. Without it a + // co-operative or foreign investor typed its address and the + // region alone silently vanished on save. + setValue("region", v ?? "", { + shouldValidate: true, + shouldDirty: true, + }) } error={errors.region?.message} /> diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx index 4035b8aaf..805ef8784 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx @@ -32,6 +32,8 @@ export interface OwnerStepProps { sourced: Record; /** A co-operative union or farm: no licence, so no eTrade record to match. */ cooperative?: boolean; + /** No eTrade record at all (co-operative or foreign investment licence). */ + manualRegistration?: boolean; } /** @@ -56,6 +58,7 @@ export default function OwnerStep({ source, sourced, cooperative = false, + manualRegistration = false, }: OwnerStepProps) { const { register, @@ -74,15 +77,17 @@ export default function OwnerStep({ return ( - {cooperative && !etradeOwner - ? "The person who runs the co-operative union or farm. eTrade held no record for your TIN, so we need all of these from you." + {manualRegistration && !etradeOwner + ? cooperative + ? "The person who runs the co-operative union or farm. eTrade held no record for your TIN, so we need all of these from you." + : "The person your investment licence names. eTrade held no record for your TIN, so we need all of these from you." : "These are the details of the person registered on your eTrade licence. What eTrade and Fayda gave us is shown as they gave it; anything they left blank we need from you."} - {/* A co-operative is not told its licence listed no manager — it has no - licence. Its own "nothing came back" case is covered by the line - above. */} - {!cooperative && !etradeOwner && !ownerVerified && ( + {/* A company with no eTrade record is not told its licence listed no + manager — eTrade never held one. That "nothing came back" case is + covered by the line above. */} + {!manualRegistration && !etradeOwner && !ownerVerified && ( }> Your eTrade licence didn't list a manager, so there's nothing for us to prefill. Enter the details of the person registered on it. diff --git a/apps/edr-freight-web/portal/src/pages/settings/RegistrationSourceCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/RegistrationSourceCard.tsx new file mode 100644 index 000000000..a814db2b5 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/RegistrationSourceCard.tsx @@ -0,0 +1,170 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Button, + Card, + Group, + List, + Modal, + Stack, + Text, + Title, +} from "@mantine/core"; +import { AlertCircle, FileSearch } from "lucide-react"; + +import { api } from "@/services/api"; +import type { ProfileResponse } from "@/types/profile"; +import { extractApiError } from "@/utils/result"; + +/** + * Leave whichever manual-registration route the company is on — a co-operative + * union or farm, or a foreign investment licence — and go back to the ordinary + * eTrade one. For a company that has since been registered with the trade + * registry, or that ticked the box by mistake. + * + * It is a re-application, not a settings edit: the API clears the registration + * the customer typed (nothing on file was ever checked against a licence) and + * puts the company back to pending, so the wizard reopens on the company step + * and the TIN goes through eTrade this time. Said plainly here rather than + * discovered afterwards. + * + * Only the way OUT is here. Moving between the three registration sources in + * the other direction is the wizard's own step, which this reopens — that is + * where the rules about which combinations are legal already live, and a second + * picker would have to restate every one of them. + */ +export default function RegistrationSourceCard({ + profile, + disabled = false, +}: { + profile: ProfileResponse; + /** A change request is under review — switching now would strand it. */ + disabled?: boolean; +}) { + const queryClient = useQueryClient(); + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState(null); + + const revert = useMutation({ + mutationFn: () => api.companies.revertToRegularCompany.call(), + onSuccess: async () => { + setConfirming(false); + // getInfo is what the onboarding gate reads, so refreshing it is what + // reopens the wizard. + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: api.companies.onboardingRequirements.queryKey(), + }), + ]); + }, + onError: (err) => setError(extractApiError(err).message), + }); + + // Which of the two routes this is — wording only; leaving costs the same. + const cooperative = profile.cooperative; + if (!profile.investorLicence && !cooperative) return null; + + return ( + <> + + + + Registration source + + + + {cooperative + ? "Your company is registered as a co-operative union or farm, so your registration details were entered by hand instead of being read from eTrade. Our team reviews them against the documents you uploaded." + : "Your company is registered on a foreign investment licence, so your registration details were entered by hand instead of being read from eTrade. Our team reviews them against the documents you uploaded."} + + + If your company now holds an eTrade trade licence, you can switch + over and have your registration verified automatically. + + + + + {disabled && ( + + Not available while your profile changes are under review. + + )} + + + + setConfirming(false)} + title="Switch to eTrade registration?" + centered + radius="lg" + > + + This re-opens your application: + + + The registration details you typed are cleared — eTrade supplies + them once your TIN is found. + + + Your company goes back to pending and is reviewed again. + + + Your documents, owner and contact details stay as they are — but + the papers we ask for change with the registration source, so some + may be listed as outstanding again. + + {cooperative && ( + + A business licence becomes due for each of your operational + services — a co-operative owes none, an eTrade-registered + company does — so any that are already approved go back to + awaiting approval until you upload one. Their reference numbers + stay the same. + + )} + + }> + If eTrade holds no record for your TIN you won't be able to finish — + the wizard reopens on the company step, so go back one step and + re-select {cooperative ? "co-operative" : "the investment licence"}{" "} + there. + + {error && ( + + {error} + + )} + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index a359a3ff0..f8eb58e8f 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -11,6 +11,7 @@ import { Button, Card, Group, + Select, SimpleGrid, Stack, Text, @@ -20,18 +21,22 @@ import { import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Building2, CheckCircle2, Save, XCircle } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import type { FieldErrors, UseFormRegister, UseFormSetValue } from "react-hook-form"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import type { CompanyRegistrationData } from "@edr/types"; +import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types"; import OnboardingRoleSelect from "./OnboardingRoleSelect"; import ETradeInfo, { type ETradeStatus, } from "@/components/onboarding/ETradeInfo"; import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField"; import StepSection from "@/pages/accounts/companyProfileForm/StepSection"; -import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema"; +import { + ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS, + onboardingSchema, +} from "@/pages/accounts/companyProfileForm/schema"; -export const COMPANY_PROFILE_SCHEMA = z.object({ +const BASE_COMPANY_PROFILE_SCHEMA = z.object({ // eTrade-sourced and read-only, like the registration block below. companyName: z.string().optional(), companyLocation: z.string().min(1, "Location is required"), @@ -39,12 +44,13 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ // no standalone input. companyAddress: z.string().optional(), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), - // Same rule as onboarding — the two forms write the same column, so they must - // not disagree about what is acceptable in it. - vatNumber: z - .string() - .min(1, "VAT number is required") - .regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"), + // Onboarding's own rule, reused rather than restated: the two forms write the + // same column, and the copy here had drifted into a 10-or-11-digit check that + // onboarding and the API both refuse to make. A foreign company's VAT is + // whatever its tax authority issues and a co-operative's follows neither, so + // the stricter copy locked those customers out of their own Company tab + // entirely — every save on it, not just the VAT. + vatNumber: onboardingSchema.shape.vatNumber, ownerPassportNumber: z.string().optional(), // 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 @@ -62,7 +68,48 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ houseNo: z.string().optional(), }); -export type CompanyProfileFormData = z.infer; +/** + * The registration fields a manual-registration company types by hand. + * + * eTrade holds no record for a co-operative union or farm, nor for a foreign + * investor on an Investment Commission licence, so the block every other + * company gets read-only off the licence is typed by these two — and is + * therefore required of them, exactly as the onboarding wizard requires it. + * House number stays optional: plenty of addresses have none. + */ +const TYPED_REGISTRATION_LABELS = { + companyName: "Company name", + region: "Region", + zone: "Zone", + woreda: "Woreda", + kebele: "Kebele", +} as const; + +/** + * `manualRegistration` is the only thing that changes here, and it changes the + * same way it does in the wizard: **a field is required iff there is an input + * on screen for it.** For an eTrade company these are read-only rows, so + * requiring one would be a Save button failing on a field with nothing to fix. + */ +export const buildCompanyProfileSchema = (manualRegistration: boolean) => + manualRegistration + ? BASE_COMPANY_PROFILE_SCHEMA.superRefine((d, ctx) => { + for (const key of Object.keys( + TYPED_REGISTRATION_LABELS, + ) as (keyof typeof TYPED_REGISTRATION_LABELS)[]) { + if (d[key]?.trim()) continue; + ctx.addIssue({ + code: "custom", + path: [key], + message: `${TYPED_REGISTRATION_LABELS[key]} is required`, + }); + } + }) + : BASE_COMPANY_PROFILE_SCHEMA; + +export type CompanyProfileFormData = z.infer< + typeof BASE_COMPANY_PROFILE_SCHEMA +>; /** * `etradePhone` is not on this form, so the shared list is filtered down to the @@ -86,6 +133,17 @@ export default function TabCompanyProfile({ }: TabCompanyProfileProps) { const queryClient = useQueryClient(); const isCreate = mode === "create"; + /** + * eTrade has nothing to say about this company — a co-operative holds no + * business licence, a foreign investor's is the Investment Commission's — so + * its registration was typed during onboarding and has to stay editable here. + * Rendering the eTrade card instead left that data invisible and frozen: the + * one company that owns its registration details was the one that could not + * see them. + */ + const manualRegistration = Boolean( + profile?.cooperative || profile?.investorLicence, + ); const [selectedRoles, setSelectedRoles] = useState([]); const [tinStatus, setTinStatus] = useState("idle"); @@ -140,7 +198,7 @@ export default function TabCompanyProfile({ setValue, formState: { errors, isDirty, dirtyFields }, } = useForm({ - resolver: zodResolver(COMPANY_PROFILE_SCHEMA), + resolver: zodResolver(buildCompanyProfileSchema(manualRegistration)), values: defaultValues, }); @@ -315,7 +373,6 @@ export default function TabCompanyProfile({ @@ -325,13 +382,21 @@ export default function TabCompanyProfile({ - {tinVerified && ( + {!manualRegistration && tinVerified && ( )} + {manualRegistration && ( + + + + )} + ; + watch: ReturnType>["watch"]; + setValue: UseFormSetValue; + errors: FieldErrors; +}) { + const region = watch("region") ?? ""; + + return ( + + + + Registered address + + +