mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
Merge pull request #1012 from Tria-plc/freight/nati-1
fayda verification and validation
This commit is contained in:
@@ -93,6 +93,9 @@ FAYDA_PRIVATE_KEY_BASE64=
|
|||||||
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
|
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
|
||||||
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
||||||
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
|
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
|
||||||
|
# OAuth redirect_uri for the customer portal (its own origin — must also be
|
||||||
|
# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset.
|
||||||
|
FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback
|
||||||
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
|
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
|
||||||
FAYDA_SCOPE=openid profile email phone address
|
FAYDA_SCOPE=openid profile email phone address
|
||||||
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface FaydaJwk {
|
|||||||
qi?: string;
|
qi?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FaydaPlatform = 'WEB' | 'MOBILE';
|
export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL';
|
||||||
|
|
||||||
export interface FaydaConfig {
|
export interface FaydaConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -25,8 +25,10 @@ export interface FaydaConfig {
|
|||||||
userInfoEndpoint: string;
|
userInfoEndpoint: string;
|
||||||
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
|
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
|
/** OAuth redirect_uri sent to eSignet for WEB (backoffice) clients. Falls back to `redirectUri`. */
|
||||||
webRedirectUri: string;
|
webRedirectUri: string;
|
||||||
|
/** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */
|
||||||
|
portalRedirectUri: string;
|
||||||
privateJwk: FaydaJwk;
|
privateJwk: FaydaJwk;
|
||||||
scope: string;
|
scope: string;
|
||||||
acrValues: string;
|
acrValues: string;
|
||||||
@@ -77,6 +79,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
|||||||
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
||||||
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
||||||
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
|
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
|
||||||
|
const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri;
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
|||||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
||||||
redirectUri,
|
redirectUri,
|
||||||
webRedirectUri,
|
webRedirectUri,
|
||||||
|
portalRedirectUri,
|
||||||
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||||
scope,
|
scope,
|
||||||
acrValues,
|
acrValues,
|
||||||
@@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
|||||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
webRedirectUri,
|
webRedirectUri,
|
||||||
|
portalRedirectUri,
|
||||||
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
||||||
scope,
|
scope,
|
||||||
acrValues,
|
acrValues,
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
|||||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||||
|
import {
|
||||||
|
CompanyIdentityStateDto,
|
||||||
|
CompleteIdentityVerificationDto,
|
||||||
|
} from "./dto/complete-identity-verification.dto";
|
||||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||||
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||||
@@ -188,9 +192,20 @@ export class CompaniesController {
|
|||||||
@Post("fetch-etrade-info")
|
@Post("fetch-etrade-info")
|
||||||
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
|
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
|
||||||
async fetchETradeInfo(
|
async fetchETradeInfo(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Body() dto: FetchETradeDto,
|
@Body() dto: FetchETradeDto,
|
||||||
): Promise<ETradeResponseDto> {
|
): 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);
|
return new ETradeResponseDto(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +393,32 @@ export class CompaniesController {
|
|||||||
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("identity/fayda/complete")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
|
||||||
|
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
|
||||||
|
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
|
||||||
|
})
|
||||||
|
async completeIdentityVerification(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Body() dto: CompleteIdentityVerificationDto,
|
||||||
|
): Promise<CompanyIdentityStateDto> {
|
||||||
|
return this.companiesService.completeIdentityVerification(user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("identity/fayda/poa")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
|
||||||
|
"Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
|
||||||
|
})
|
||||||
|
async removePoaIdentity(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<CompanyIdentityStateDto> {
|
||||||
|
return this.companiesService.removePoaIdentity(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch("onboarding-step")
|
@Patch("onboarding-step")
|
||||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CompaniesService } from "./companies.service";
|
||||||
|
import { CompanyNationality, CompanyStatus } from "./entities/company.entity";
|
||||||
|
import { ProfileType } from "./entities/company-profile.entity";
|
||||||
|
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A person's identity is proved through Fayda: name, email, phone and address
|
||||||
|
* come from the verified payload, not typed. Fayda's userinfo carries no
|
||||||
|
* national ID number, so none is collected or derived here.
|
||||||
|
*
|
||||||
|
* - Ethiopian company: the owner (and its PoA, once named) is verified through
|
||||||
|
* Fayda, and their details can't be edited afterwards.
|
||||||
|
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
|
||||||
|
* supplies a typed passport number — required on its own, whether or not the
|
||||||
|
* owner also completes a (purely optional) Fayda verification.
|
||||||
|
*
|
||||||
|
* The owner is NOT the general manager — GM is a separate, plain typed role
|
||||||
|
* the portal offers a "same as owner" copy for, but it is never itself
|
||||||
|
* Fayda-verified or gated on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Ctx {
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
files: { id: string; code: string; reviewStatus?: string | null }[];
|
||||||
|
profileTypes: ProfileType[];
|
||||||
|
status: CompanyStatus;
|
||||||
|
nationality: CompanyNationality;
|
||||||
|
verification: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWNER_VERIFIED = {
|
||||||
|
ownerFaydaSub: "owner-sub",
|
||||||
|
ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z",
|
||||||
|
ownerName: "Abebe Bikila",
|
||||||
|
};
|
||||||
|
|
||||||
|
const POA_VERIFIED = {
|
||||||
|
poaFaydaSub: "poa-sub",
|
||||||
|
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
poaName: "Tirunesh Dibaba",
|
||||||
|
poaEmail: "tirunesh@example.com",
|
||||||
|
poaPhone: "+251911000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
const paper = () => ({
|
||||||
|
id: "file-1",
|
||||||
|
code: POA_DELEGATION_FILE_KEY,
|
||||||
|
reviewStatus: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeService(overrides: Partial<Ctx> = {}) {
|
||||||
|
const ctx: Ctx = {
|
||||||
|
attributes: {},
|
||||||
|
files: [],
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
status: CompanyStatus.Pending,
|
||||||
|
nationality: CompanyNationality.Ethiopian,
|
||||||
|
verification: {
|
||||||
|
purpose: "VERIFY",
|
||||||
|
verified: true,
|
||||||
|
sub: "new-sub",
|
||||||
|
fullName: "Haile Gebrselassie",
|
||||||
|
email: "haile@example.com",
|
||||||
|
phoneNumber: "+251922000000",
|
||||||
|
address: "Addis Ababa",
|
||||||
|
birthdate: "1973-04-18",
|
||||||
|
gender: "Male",
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
const company = () => ({
|
||||||
|
id: "company-1",
|
||||||
|
status: ctx.status,
|
||||||
|
nationality: ctx.nationality,
|
||||||
|
attributes: ctx.attributes,
|
||||||
|
companyProfiles: ctx.profileTypes.map((type, i) => ({
|
||||||
|
id: `profile-${i}`,
|
||||||
|
type,
|
||||||
|
})),
|
||||||
|
type: "customer",
|
||||||
|
});
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
companiesRepo: {
|
||||||
|
findById: jest.fn(async () => company()),
|
||||||
|
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||||
|
if (patch.attributes)
|
||||||
|
ctx.attributes = patch.attributes as Record<string, unknown>;
|
||||||
|
return company();
|
||||||
|
}),
|
||||||
|
findByTin: jest.fn(async () => null),
|
||||||
|
},
|
||||||
|
companyProfilesRepo: {
|
||||||
|
findByCompanyId: jest.fn(async () =>
|
||||||
|
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
|
||||||
|
),
|
||||||
|
findByType: jest.fn(async (_id: string, type: ProfileType) =>
|
||||||
|
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
|
||||||
|
),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
|
id: "new",
|
||||||
|
...row,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
changeRequestRepo: {
|
||||||
|
findPendingByCompanyId: jest.fn(async () => null),
|
||||||
|
findByCompanyId: jest.fn(async () => []),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
|
id: "cr-1",
|
||||||
|
...row,
|
||||||
|
})),
|
||||||
|
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||||
|
},
|
||||||
|
profilesRepo: {
|
||||||
|
findByCompanyId: jest.fn(async () => []),
|
||||||
|
findByUserId: jest.fn(async () => ({
|
||||||
|
id: "external-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
company: company(),
|
||||||
|
onboardingCompleted: false,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
filesService: {
|
||||||
|
findByResource: jest.fn(async () => ctx.files),
|
||||||
|
findById: jest.fn(async () => null),
|
||||||
|
remove: jest.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
companyNotifier: { changeRequestSubmitted: jest.fn() },
|
||||||
|
verifayda: {
|
||||||
|
completeVerification: jest.fn(async () => ctx.verification),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new CompaniesService(
|
||||||
|
deps.companiesRepo as never,
|
||||||
|
deps.companyProfilesRepo as never,
|
||||||
|
deps.changeRequestRepo as never,
|
||||||
|
deps.profilesRepo as never,
|
||||||
|
{} as never,
|
||||||
|
deps.filesService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
deps.companyNotifier as never,
|
||||||
|
{} as never,
|
||||||
|
deps.verifayda as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(service, "getCompanyInfoByUserId")
|
||||||
|
.mockImplementation(
|
||||||
|
async () =>
|
||||||
|
({ profile: { id: "external-1" }, company: company() }) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, ctx, deps, company };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Fayda identity verification binds a person to the company", () => {
|
||||||
|
it("writes the verified identity", async () => {
|
||||||
|
const { service, ctx } = makeService();
|
||||||
|
|
||||||
|
const state = await service.completeIdentityVerification("user-1", {
|
||||||
|
subject: "owner",
|
||||||
|
code: "c",
|
||||||
|
state: "s",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.attributes.ownerFaydaSub).toBe("new-sub");
|
||||||
|
expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie");
|
||||||
|
expect(state.owner.verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills every PoA detail from the payload, address included", async () => {
|
||||||
|
const { service, ctx } = makeService();
|
||||||
|
|
||||||
|
await service.completeIdentityVerification("user-1", {
|
||||||
|
subject: "poa",
|
||||||
|
code: "c",
|
||||||
|
state: "s",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.attributes.poaName).toBe("Haile Gebrselassie");
|
||||||
|
expect(ctx.attributes.poaEmail).toBe("haile@example.com");
|
||||||
|
expect(ctx.attributes.poaPhone).toBe("+251922000000");
|
||||||
|
expect(ctx.attributes.poaAddress).toBe("Addis Ababa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies successfully even though Fayda returns no national ID number", async () => {
|
||||||
|
// Fayda's userinfo carries no FAN/FIN claim at all — this must be the
|
||||||
|
// normal, successful path, not an error.
|
||||||
|
const { service } = makeService({
|
||||||
|
verification: {
|
||||||
|
purpose: "VERIFY",
|
||||||
|
verified: true,
|
||||||
|
sub: "x",
|
||||||
|
fullName: "No Fan Here",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await service.completeIdentityVerification("user-1", {
|
||||||
|
subject: "owner",
|
||||||
|
code: "c",
|
||||||
|
state: "s",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.owner.verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to make one identity both owner and PoA", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: { ownerFaydaSub: "same-person" },
|
||||||
|
verification: {
|
||||||
|
purpose: "VERIFY",
|
||||||
|
verified: true,
|
||||||
|
sub: "same-person",
|
||||||
|
fullName: "Abebe Bikila",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeIdentityVerification("user-1", {
|
||||||
|
subject: "poa",
|
||||||
|
code: "c",
|
||||||
|
state: "s",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stages the change for review on an approved company", async () => {
|
||||||
|
// Swapping the person who can act for a live company is exactly what the
|
||||||
|
// backoffice review exists for, so it must not rewrite the row directly.
|
||||||
|
const { service, ctx, deps } = makeService({
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.completeIdentityVerification("user-1", {
|
||||||
|
subject: "poa",
|
||||||
|
code: "c",
|
||||||
|
state: "s",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
||||||
|
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to rename a verified person by hand", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never locks or gates the general manager — it is not the verified subject", async () => {
|
||||||
|
// GM is a plain typed role; the portal offers a "same as owner" copy, but
|
||||||
|
// the backend must not treat it as identity-owned or require it verified.
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", {
|
||||||
|
generalManagerName: "Someone Else",
|
||||||
|
generalManagerEmail: "someone@example.com",
|
||||||
|
generalManagerPhone: "+251911223344",
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => {
|
||||||
|
// The company is applying for the forwarder role, so it must not already
|
||||||
|
// hold it — createCompanyProfileForUser short-circuits on an existing profile
|
||||||
|
// and would never reach the gate.
|
||||||
|
const applyingForFf = {
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
attributes: { ...POA_VERIFIED },
|
||||||
|
files: [paper()],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("blocks the forwarder role while the owner is unverified", async () => {
|
||||||
|
const { service } = makeService(applyingForFf);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks the forwarder role while the PoA is unverified", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
poaName: "Tirunesh Dibaba",
|
||||||
|
poaEmail: "t@example.com",
|
||||||
|
poaPhone: "+251911000000",
|
||||||
|
},
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants the forwarder role once owner and PoA are both verified", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never asks a foreign company for Fayda, verified or not", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
nationality: CompanyNationality.Foreign,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await service.completeIdentityVerification("user-1", {
|
||||||
|
subject: "owner",
|
||||||
|
code: "c",
|
||||||
|
state: "s",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Still lets the owner verify — a foreign owner verifying is allowed, just
|
||||||
|
// never required — but the passport is the thing that actually gates it.
|
||||||
|
expect(state.owner.verified).toBe(true);
|
||||||
|
expect(state.faydaRequired).toBe(false);
|
||||||
|
expect(state.passportRequired).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks the forwarder role for a foreign company with no owner passport", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
nationality: CompanyNationality.Foreign,
|
||||||
|
attributes: {
|
||||||
|
poaName: "Jean Dupont",
|
||||||
|
poaEmail: "jean@example.com",
|
||||||
|
poaPhone: "+33100000000",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
nationality: CompanyNationality.Foreign,
|
||||||
|
attributes: {
|
||||||
|
ownerPassportNumber: "P1234567",
|
||||||
|
poaName: "Jean Dupont",
|
||||||
|
poaEmail: "jean@example.com",
|
||||||
|
poaPhone: "+33100000000",
|
||||||
|
},
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
||||||
|
// Verifying is optional for a foreign owner, but it does not waive the
|
||||||
|
// passport requirement — the two are independent credentials.
|
||||||
|
const { service } = makeService({
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
nationality: CompanyNationality.Foreign,
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
poaName: "Jean Dupont",
|
||||||
|
poaEmail: "jean@example.com",
|
||||||
|
poaPhone: "+33100000000",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ import { CompanyProfileRepository } from "./company-profile.repository";
|
|||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
import { CompanyNotifierService } from "./company-notifier.service";
|
import { CompanyNotifierService } from "./company-notifier.service";
|
||||||
|
import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -38,6 +39,8 @@ import { CompanyNotifierService } from "./company-notifier.service";
|
|||||||
// imports this module back for portal recipient targeting, hence forwardRef.
|
// imports this module back for portal recipient targeting, hence forwardRef.
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
forwardRef(() => NotificationInboxModule),
|
forwardRef(() => NotificationInboxModule),
|
||||||
|
// Fayda identity verification for the company's owner and PoA.
|
||||||
|
VerifaydaModule,
|
||||||
],
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CompaniesService } from "./companies.service";
|
||||||
|
import { CompanyStatus } from "./entities/company.entity";
|
||||||
|
import { ProfileType } from "./entities/company-profile.entity";
|
||||||
|
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS
|
||||||
|
* delegation paper on file. The rule used to live only in the onboarding
|
||||||
|
* wizard's completion check, so every other write that could break the pairing
|
||||||
|
* — saving PoA details, deleting the paper, picking up the forwarder role —
|
||||||
|
* went unguarded. These cover those writes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Ctx {
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
files: { id: string; code: string; reviewStatus?: string | null }[];
|
||||||
|
profileTypes: ProfileType[];
|
||||||
|
status: CompanyStatus;
|
||||||
|
pendingSnapshot: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The forwarder role is gated on Fayda-verified identities as well as on the
|
||||||
|
* delegation paper. These tests are about the paper, so they run against a
|
||||||
|
* company whose identities are already verified — the identity rule itself is
|
||||||
|
* covered in companies.fayda-identity.spec.ts.
|
||||||
|
*/
|
||||||
|
const VERIFIED_IDENTITIES = {
|
||||||
|
ownerFaydaSub: "owner-sub",
|
||||||
|
poaFaydaSub: "poa-sub",
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeService(overrides: Partial<Ctx> = {}) {
|
||||||
|
const ctx: Ctx = {
|
||||||
|
attributes: {},
|
||||||
|
files: [],
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
status: CompanyStatus.Pending,
|
||||||
|
pendingSnapshot: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
const company = () => ({
|
||||||
|
id: "company-1",
|
||||||
|
status: ctx.status,
|
||||||
|
attributes: ctx.attributes,
|
||||||
|
companyProfiles: ctx.profileTypes.map((type, i) => ({
|
||||||
|
id: `profile-${i}`,
|
||||||
|
type,
|
||||||
|
})),
|
||||||
|
type: "customer",
|
||||||
|
});
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
companiesRepo: {
|
||||||
|
findById: jest.fn(async () => company()),
|
||||||
|
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||||
|
ctx.attributes = (patch.attributes ??
|
||||||
|
ctx.attributes) as Record<string, unknown>;
|
||||||
|
return company();
|
||||||
|
}),
|
||||||
|
findByTin: jest.fn(async () => null),
|
||||||
|
},
|
||||||
|
companyProfilesRepo: {
|
||||||
|
findByCompanyId: jest.fn(async () =>
|
||||||
|
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
|
||||||
|
),
|
||||||
|
findByType: jest.fn(async (_id: string, type: ProfileType) =>
|
||||||
|
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
|
||||||
|
),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
|
id: "new",
|
||||||
|
...row,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
changeRequestRepo: {
|
||||||
|
findPendingByCompanyId: jest.fn(async () =>
|
||||||
|
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||||
|
),
|
||||||
|
findByCompanyId: jest.fn(async () => []),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
|
id: "cr-1",
|
||||||
|
...row,
|
||||||
|
})),
|
||||||
|
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||||
|
},
|
||||||
|
profilesRepo: {
|
||||||
|
findByCompanyId: jest.fn(async () => []),
|
||||||
|
findByUserId: jest.fn(async () => ({
|
||||||
|
id: "external-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
company: company(),
|
||||||
|
onboardingCompleted: false,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
filesService: {
|
||||||
|
findByResource: jest.fn(async () => ctx.files),
|
||||||
|
findById: jest.fn(async (id: string) =>
|
||||||
|
ctx.files.find((f) => f.id === id)
|
||||||
|
? {
|
||||||
|
...ctx.files.find((f) => f.id === id),
|
||||||
|
resource: "companies",
|
||||||
|
resourceId: "company-1",
|
||||||
|
name: "dars.pdf",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
remove: jest.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
companyNotifier: { changeRequestSubmitted: jest.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new CompaniesService(
|
||||||
|
deps.companiesRepo as never,
|
||||||
|
deps.companyProfilesRepo as never,
|
||||||
|
deps.changeRequestRepo as never,
|
||||||
|
deps.profilesRepo as never,
|
||||||
|
{} as never,
|
||||||
|
deps.filesService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
deps.companyNotifier as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for
|
||||||
|
// the PoA paths, so short-circuit it rather than mock the whole graph.
|
||||||
|
jest
|
||||||
|
.spyOn(service, "getCompanyInfoByUserId")
|
||||||
|
.mockImplementation(
|
||||||
|
async () =>
|
||||||
|
({ profile: { id: "external-1" }, company: company() }) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, ctx, deps };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paper = (reviewStatus: string | null = null) => ({
|
||||||
|
id: "file-1",
|
||||||
|
code: POA_DELEGATION_FILE_KEY,
|
||||||
|
reviewStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||||
|
it("rejects PoA details saved with no paper on file", async () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", POA as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts PoA details once the paper is on file", async () => {
|
||||||
|
const { service } = makeService({ files: [paper()] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", POA as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a paper the reviewer sent back for correction", async () => {
|
||||||
|
const { service } = makeService({ files: [paper("change_requested")] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", POA as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves edits that don't touch the PoA alone", async () => {
|
||||||
|
// A company carrying legacy details must not be locked out of every other
|
||||||
|
// field until it produces a paper.
|
||||||
|
const { service } = makeService({ attributes: { ...POA }, files: [] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", { companyEmail: "x@y.com" } as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to remove the paper while the PoA is still named", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: { ...POA },
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows removing the paper once the PoA has been cleared", async () => {
|
||||||
|
const { service } = makeService({ attributes: {}, files: [paper()] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("judges the removal against a staged clear, not the live row", async () => {
|
||||||
|
// An Active company's edits are staged for review rather than written, so
|
||||||
|
// the live attributes still carry the PoA the customer just cleared.
|
||||||
|
const { service } = makeService({
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
attributes: { ...POA },
|
||||||
|
pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" },
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the forwarder role to a company with no PoA", async () => {
|
||||||
|
const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants the forwarder role once PoA details and paper are both in place", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: { ...POA, ...VERIFIED_IDENTITIES },
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
async existsByTin(tin: string): Promise<boolean> {
|
async existsByTin(tin: string, excludeCompanyId?: string): Promise<boolean> {
|
||||||
const count = await this.repository.count({ where: { tin } as any });
|
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;
|
return count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,10 +17,23 @@ import {
|
|||||||
import { FilesService } from "../files/files.service";
|
import { FilesService } from "../files/files.service";
|
||||||
import { FileRecord } from "../files/entities/file.entity";
|
import { FileRecord } from "../files/entities/file.entity";
|
||||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||||
|
import {
|
||||||
|
POA_DELEGATION_FILE_KEY,
|
||||||
|
POA_DELEGATION_LABEL,
|
||||||
|
POA_DELEGATION_PENDING_CODE,
|
||||||
|
} from "../file-upload-settings/poa-delegation.constants";
|
||||||
|
import { VerifaydaService } from "../verifayda/verifayda.service";
|
||||||
|
import {
|
||||||
|
buildCompanyIdentityState,
|
||||||
|
CompanyIdentityStateDto,
|
||||||
|
CompleteIdentityVerificationDto,
|
||||||
|
IdentitySubject,
|
||||||
|
} from "./dto/complete-identity-verification.dto";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
import { CompanyNotifierService } from "./company-notifier.service";
|
import { CompanyNotifierService } from "./company-notifier.service";
|
||||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||||
|
import type { CompanyRegistrationData } from "@edr/types";
|
||||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||||
@@ -58,10 +71,6 @@ const LICENSE_CODE = "business_license";
|
|||||||
/** Code for a license file staged in an open change request (not yet live). */
|
/** Code for a license file staged in an open change request (not yet live). */
|
||||||
const LICENSE_PENDING_CODE = "business_license_pending";
|
const LICENSE_PENDING_CODE = "business_license_pending";
|
||||||
|
|
||||||
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
|
|
||||||
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
|
||||||
/** Code for a PoA letter staged in an open change request (not yet live). */
|
|
||||||
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
|
||||||
/** FileRecord resource that company-level documents are stored under. */
|
/** FileRecord resource that company-level documents are stored under. */
|
||||||
const COMPANY_RESOURCE = "companies";
|
const COMPANY_RESOURCE = "companies";
|
||||||
/** company.attributes keys that together mean "a PoA was entered". */
|
/** company.attributes keys that together mean "a PoA was entered". */
|
||||||
@@ -79,6 +88,62 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
|||||||
{ key: "poaPhone", label: "PoA phone" },
|
{ key: "poaPhone", label: "PoA phone" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `attributes` key prefix per verifiable person. The owner is NOT the general
|
||||||
|
* manager — GM is a plain typed role (the portal offers a "same as owner" copy
|
||||||
|
* once the owner is verified), while the owner is who this verification
|
||||||
|
* actually proves. They're very often the same human; that's what the copy is
|
||||||
|
* for.
|
||||||
|
*/
|
||||||
|
const IDENTITY_PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
||||||
|
owner: "owner",
|
||||||
|
poa: "poa",
|
||||||
|
};
|
||||||
|
|
||||||
|
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
|
||||||
|
owner: "owner",
|
||||||
|
poa: "Power of Attorney",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity fields a Fayda verification owns outright, per person. Once verified
|
||||||
|
* these can no longer be typed — the government IdP is the source, so an edit
|
||||||
|
* that disagrees with it is either a mistake or an attempt to launder the
|
||||||
|
* guarantee away. The GM fields are deliberately absent: GM is never itself
|
||||||
|
* Fayda-verified, so it stays freely editable regardless of the owner's state.
|
||||||
|
*/
|
||||||
|
const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
|
||||||
|
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserIdentity {
|
export interface UserIdentity {
|
||||||
userId: string;
|
userId: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -100,6 +165,7 @@ export class CompaniesService {
|
|||||||
private readonly etradeService: ETradeService,
|
private readonly etradeService: ETradeService,
|
||||||
private readonly companyNotifier: CompanyNotifierService,
|
private readonly companyNotifier: CompanyNotifierService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly verifaydaService: VerifaydaService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -255,8 +321,9 @@ export class CompaniesService {
|
|||||||
* chosen operational role(s) up front, so every subsequent wizard step can
|
* chosen operational role(s) up front, so every subsequent wizard step can
|
||||||
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
|
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
|
||||||
*
|
*
|
||||||
* Idempotent: if the user already has a profile, returns it unchanged (only
|
* Idempotent: if the user already has a profile, returns it unchanged, with
|
||||||
* adding any newly-chosen roles). The draft company carries a placeholder TIN
|
* 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
|
* (the real one is filled on the Company Information step) and stays
|
||||||
* status=pending / onboardingCompleted=false until the wizard finishes.
|
* status=pending / onboardingCompleted=false until the wizard finishes.
|
||||||
*/
|
*/
|
||||||
@@ -271,7 +338,7 @@ export class CompaniesService {
|
|||||||
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const companyId = existing.company?.id ?? existing.companyId;
|
const companyId = existing.company?.id ?? existing.companyId;
|
||||||
await this.ensureCompanyProfiles(companyId, companyType, roles);
|
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||||
if (nationality) {
|
if (nationality) {
|
||||||
await this.companiesRepo.update(companyId, { nationality });
|
await this.companiesRepo.update(companyId, { nationality });
|
||||||
}
|
}
|
||||||
@@ -302,25 +369,44 @@ export class CompaniesService {
|
|||||||
onboardingCompleted: false,
|
onboardingCompleted: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
|
await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
|
||||||
|
|
||||||
return this.getCompanyInfoByUserId(identity.userId);
|
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,
|
companyId: string,
|
||||||
companyType: CompanyType,
|
companyType: CompanyType,
|
||||||
roles: ProfileType[],
|
roles: ProfileType[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
for (const type of roles) {
|
const chosen = roles.filter((t) => allowedTypes.includes(t));
|
||||||
if (!allowedTypes.includes(type)) continue;
|
const existing = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
const existing = await this.companyProfilesRepo.findByType(
|
|
||||||
companyId,
|
for (const profile of existing) {
|
||||||
type,
|
if (chosen.includes(profile.type)) continue;
|
||||||
);
|
if (profile.status !== ProfileStatus.Pending) continue;
|
||||||
if (existing) 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).
|
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
|
||||||
await this.companyProfilesRepo.create({
|
await this.companyProfilesRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
@@ -599,7 +685,9 @@ export class CompaniesService {
|
|||||||
*/
|
*/
|
||||||
private mapProfileDtoToCompanyUpdates(
|
private mapProfileDtoToCompanyUpdates(
|
||||||
company: Company,
|
company: Company,
|
||||||
dto: Partial<UpdateProfileDto>,
|
dto: Partial<UpdateProfileDto> & {
|
||||||
|
faydaIdentity?: VerifiedIdentityAttributes;
|
||||||
|
},
|
||||||
): Record<string, any> {
|
): Record<string, any> {
|
||||||
const companyUpdates: Record<string, any> = {};
|
const companyUpdates: Record<string, any> = {};
|
||||||
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
||||||
@@ -617,7 +705,6 @@ export class CompaniesService {
|
|||||||
if (dto.tin !== undefined && dto.tin !== company.tin)
|
if (dto.tin !== undefined && dto.tin !== company.tin)
|
||||||
companyUpdates.tin = dto.tin;
|
companyUpdates.tin = dto.tin;
|
||||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||||
if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
|
|
||||||
|
|
||||||
if (dto.contactPersonName !== undefined)
|
if (dto.contactPersonName !== undefined)
|
||||||
attrUpdates.contactPersonName = dto.contactPersonName;
|
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||||
@@ -661,6 +748,72 @@ export class CompaniesService {
|
|||||||
if (dto.etradePhone !== undefined)
|
if (dto.etradePhone !== undefined)
|
||||||
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
|
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
|
||||||
|
|
||||||
|
// A plain typed field — never Fayda-verified, so no lock ever applies to
|
||||||
|
// it. Independent of the owner's verification: still required for a
|
||||||
|
// foreign company even if the owner also verifies with Fayda.
|
||||||
|
if (dto.ownerPassportNumber !== undefined)
|
||||||
|
attrUpdates.ownerPassportNumber = dto.ownerPassportNumber;
|
||||||
|
|
||||||
|
// A verified identity overwrites the person's details. `faydaIdentity`
|
||||||
|
// never comes off the wire — the global validation pipe runs with
|
||||||
|
// forbidNonWhitelisted, so a client that sends it is rejected outright; it
|
||||||
|
// only reaches here from completeIdentityVerification, directly or through
|
||||||
|
// a staged snapshot.
|
||||||
|
if (dto.faydaIdentity) {
|
||||||
|
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[]) {
|
||||||
|
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||||
|
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
||||||
|
const incoming = (dto as Record<string, unknown>)[field];
|
||||||
|
if (incoming === undefined) continue;
|
||||||
|
// The verification itself is allowed to write them; anything else is
|
||||||
|
// compared against what is already stored, not against the value this
|
||||||
|
// same call just copied into the patch. Phones are compared normalized:
|
||||||
|
// a form that re-renders +251911000000 as 0911000000 is echoing the
|
||||||
|
// stored value back, not trying to change it.
|
||||||
|
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
|
||||||
|
const stored = company.attributes?.[field];
|
||||||
|
const same = field.endsWith("Phone")
|
||||||
|
? normalizeE164(String(incoming)) ===
|
||||||
|
normalizeE164(String(stored ?? ""))
|
||||||
|
: incoming === stored;
|
||||||
|
if (!same) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companyUpdates.attributes = attrUpdates;
|
companyUpdates.attributes = attrUpdates;
|
||||||
return companyUpdates;
|
return companyUpdates;
|
||||||
}
|
}
|
||||||
@@ -702,6 +855,21 @@ export class CompaniesService {
|
|||||||
): Promise<ProfileResponseDto> {
|
): Promise<ProfileResponseDto> {
|
||||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
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
|
||||||
|
// company carrying legacy details must not be locked out of every other
|
||||||
|
// field until it produces a paper.
|
||||||
|
if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) {
|
||||||
|
const attributes = this.mapProfileDtoToCompanyUpdates(company, dto)
|
||||||
|
.attributes as Record<string, unknown>;
|
||||||
|
await this.assertPoaDelegationSatisfied(company.id, attributes, {
|
||||||
|
requirePoa: await this.isFreightForwarder(company.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (company.status !== CompanyStatus.Active) {
|
if (company.status !== CompanyStatus.Active) {
|
||||||
await this.assertTinAvailable(company, dto.tin);
|
await this.assertTinAvailable(company, dto.tin);
|
||||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
|
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
|
||||||
@@ -1127,11 +1295,24 @@ export class CompaniesService {
|
|||||||
// blacklist skip all this — staff must always be able to act against a bad
|
// blacklist skip all this — staff must always be able to act against a bad
|
||||||
// account.
|
// account.
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
await manager.findOne(Company, {
|
const company = await manager.findOne(Company, {
|
||||||
where: { id: existing.companyId },
|
where: { id: existing.companyId },
|
||||||
lock: { mode: "pessimistic_write" },
|
lock: { mode: "pessimistic_write" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Putting a forwarder into service without a Power of Attorney backed by
|
||||||
|
// a DARS paper is the thing EDRFREIGHT-358 forbids, so the approval is
|
||||||
|
// the last place it has to be checked — the role may have been applied
|
||||||
|
// for before the paper was withdrawn.
|
||||||
|
if (company && existing.type === ProfileType.freightForwarder) {
|
||||||
|
this.assertIdentityVerified(company, { requirePoa: true });
|
||||||
|
await this.assertPoaDelegationSatisfied(
|
||||||
|
company.id,
|
||||||
|
company.attributes,
|
||||||
|
{ requirePoa: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [companyDocs, profileDocs] = await Promise.all([
|
const [companyDocs, profileDocs] = await Promise.all([
|
||||||
this.filesService.findWithOpenChangeRequest(
|
this.filesService.findWithOpenChangeRequest(
|
||||||
[existing.companyId],
|
[existing.companyId],
|
||||||
@@ -1382,6 +1563,18 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
if (existing) continue;
|
if (existing) continue;
|
||||||
|
|
||||||
|
// A forwarder signs on other companies' behalf, so it cannot be taken on
|
||||||
|
// without a Power of Attorney and its DARS paper — checked here so the
|
||||||
|
// customer is told at the point of asking, not at review.
|
||||||
|
if (type === ProfileType.freightForwarder) {
|
||||||
|
this.assertIdentityVerified(company, { requirePoa: true });
|
||||||
|
await this.assertPoaDelegationSatisfied(
|
||||||
|
companyId,
|
||||||
|
await this.effectivePoaAttributes(company),
|
||||||
|
{ requirePoa: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Self-service role adds start Pending and carry no reference — a reference
|
// Self-service role adds start Pending and carry no reference — a reference
|
||||||
// is minted only when a backoffice reviewer approves the role.
|
// is minted only when a backoffice reviewer approves the role.
|
||||||
await this.companyProfilesRepo.create({
|
await this.companyProfilesRepo.create({
|
||||||
@@ -1419,6 +1612,14 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||||
|
if (!created && type === ProfileType.freightForwarder) {
|
||||||
|
this.assertIdentityVerified(company, { requirePoa: true });
|
||||||
|
await this.assertPoaDelegationSatisfied(
|
||||||
|
companyId,
|
||||||
|
await this.effectivePoaAttributes(company),
|
||||||
|
{ requirePoa: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!created) {
|
if (!created) {
|
||||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||||
// carry no reference until approved.
|
// carry no reference until approved.
|
||||||
@@ -1453,11 +1654,17 @@ export class CompaniesService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<OnboardingRequirementsResponseDto> {
|
): Promise<OnboardingRequirementsResponseDto> {
|
||||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
const identity = this.getCompanyIdentityState(company);
|
||||||
|
|
||||||
// 1. Required company-information fields.
|
// 1. Required company-information fields. The FAN is never one of them —
|
||||||
const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
|
// Fayda verification doesn't produce a FAN, so it's never collected as
|
||||||
(f) => !f.get(company),
|
// part of onboarding at all (see the identity block below).
|
||||||
).map((f) => ({ key: f.key, label: f.label }));
|
const requiredInfo = this.REQUIRED_COMPANY_INFO.filter(
|
||||||
|
(f) => f.key !== "fanNumber",
|
||||||
|
);
|
||||||
|
const missingInfo = requiredInfo
|
||||||
|
.filter((f) => !f.get(company))
|
||||||
|
.map((f) => ({ key: f.key, label: f.label }));
|
||||||
|
|
||||||
// 2. Nationality-based company documents + which are already uploaded.
|
// 2. Nationality-based company documents + which are already uploaded.
|
||||||
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
|
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
|
||||||
@@ -1504,26 +1711,31 @@ export class CompaniesService {
|
|||||||
|
|
||||||
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
|
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
|
||||||
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
|
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
|
||||||
// has been entered must be evidenced by the delegation letter.
|
// has been entered must be evidenced by the DARS delegation paper — a legal
|
||||||
|
// requirement, so unlike the documents above it does not depend on the
|
||||||
|
// upload set carrying a field for it (see poa-delegation.constants.ts).
|
||||||
const poaRequired = (company.companyProfiles ?? []).some(
|
const poaRequired = (company.companyProfiles ?? []).some(
|
||||||
(p) => p.type === ProfileType.freightForwarder,
|
(p) => p.type === ProfileType.freightForwarder,
|
||||||
);
|
);
|
||||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
);
|
);
|
||||||
const missingPoaFields = poaRequired
|
// An Ethiopian company does not type its PoA details at all — they arrive
|
||||||
? REQUIRED_POA_FIELDS.filter(
|
// from the Fayda verification — so reporting them as missing fields would
|
||||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
// ask for something the form no longer offers. The identity block below
|
||||||
)
|
// reports "verify your PoA" instead.
|
||||||
: [];
|
const missingPoaFields =
|
||||||
// Only gate on the letter once the document set actually carries the field.
|
poaRequired && !identity.faydaRequired
|
||||||
const delegationField = (setting?.fields ?? []).find(
|
? REQUIRED_POA_FIELDS.filter(
|
||||||
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
|
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||||
);
|
)
|
||||||
const missingDelegation =
|
: [];
|
||||||
Boolean(delegationField) &&
|
const delegation = await this.getPoaDelegationState(company.id);
|
||||||
(poaRequired || poaProvided) &&
|
const delegationDue = poaRequired || poaProvided;
|
||||||
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
|
const missingDelegation = delegationDue && !delegation.onFile;
|
||||||
|
// A paper the reviewer sent back is not evidence — the customer has to
|
||||||
|
// replace it before the application counts as complete.
|
||||||
|
const flaggedDelegation = delegationDue && delegation.flagged;
|
||||||
|
|
||||||
const outstanding = [
|
const outstanding = [
|
||||||
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||||
@@ -1534,29 +1746,62 @@ export class CompaniesService {
|
|||||||
),
|
),
|
||||||
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||||
...(missingDelegation
|
...(missingDelegation
|
||||||
? ["Upload the delegation letter for your Power of Attorney"]
|
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
||||||
|
: []),
|
||||||
|
...(flaggedDelegation
|
||||||
|
? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`]
|
||||||
|
: []),
|
||||||
|
...(identity.faydaRequired && !identity.owner.verified
|
||||||
|
? ["Verify the company owner's identity with Fayda"]
|
||||||
|
: []),
|
||||||
|
...(identity.faydaRequired &&
|
||||||
|
(poaRequired || poaProvided) &&
|
||||||
|
!identity.poa.verified
|
||||||
|
? ["Verify your Power of Attorney's identity with Fayda"]
|
||||||
|
: []),
|
||||||
|
...(identity.passportRequired && !identity.owner.passportNumber
|
||||||
|
? ["Add the company owner's passport number"]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Progress spans every required item the user has to satisfy: company-info
|
// Progress spans every required item the user has to satisfy: company-info
|
||||||
// fields, required documents, one license per operational profile, and the
|
// fields, required documents, one license per operational profile, and the
|
||||||
// PoA details/letter whenever those are mandatory.
|
// PoA details/paper whenever those are mandatory.
|
||||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||||
const poaItemCount =
|
const poaItemCount =
|
||||||
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
|
(poaRequired && !identity.faydaRequired
|
||||||
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
|
? REQUIRED_POA_FIELDS.length
|
||||||
|
: 0) + (delegationDue ? 1 : 0);
|
||||||
|
// One item per identity credential the company has to prove: the owner
|
||||||
|
// always (Fayda for Ethiopian, passport for foreign), the PoA once there
|
||||||
|
// is one and Fayda is what's mandatory here.
|
||||||
|
const identityItemCount = identity.faydaRequired
|
||||||
|
? delegationDue
|
||||||
|
? 2
|
||||||
|
: 1
|
||||||
|
: identity.passportRequired
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
const missingIdentityCount = identity.faydaRequired
|
||||||
|
? (identity.owner.verified ? 0 : 1) +
|
||||||
|
(delegationDue && !identity.poa.verified ? 1 : 0)
|
||||||
|
: identity.passportRequired && !identity.owner.passportNumber
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
const total =
|
const total =
|
||||||
this.REQUIRED_COMPANY_INFO.length +
|
requiredInfo.length +
|
||||||
requiredDocCount +
|
requiredDocCount +
|
||||||
licenseProfiles.length +
|
licenseProfiles.length +
|
||||||
poaItemCount;
|
poaItemCount +
|
||||||
|
identityItemCount;
|
||||||
const completed =
|
const completed =
|
||||||
total -
|
total -
|
||||||
(missingInfo.length +
|
(missingInfo.length +
|
||||||
missingDocs.length +
|
missingDocs.length +
|
||||||
missingLicenses.length +
|
missingLicenses.length +
|
||||||
missingPoaFields.length +
|
missingPoaFields.length +
|
||||||
(missingDelegation ? 1 : 0));
|
(missingDelegation || flaggedDelegation ? 1 : 0) +
|
||||||
|
missingIdentityCount);
|
||||||
|
|
||||||
return new OnboardingRequirementsResponseDto({
|
return new OnboardingRequirementsResponseDto({
|
||||||
documentSettingCode,
|
documentSettingCode,
|
||||||
@@ -1567,10 +1812,15 @@ export class CompaniesService {
|
|||||||
poa: {
|
poa: {
|
||||||
required: poaRequired,
|
required: poaRequired,
|
||||||
provided: poaProvided,
|
provided: poaProvided,
|
||||||
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
|
delegationLetterUploaded: delegation.onFile,
|
||||||
|
delegationLetterFlagged: delegation.flagged,
|
||||||
missingFields: missingPoaFields,
|
missingFields: missingPoaFields,
|
||||||
complete: missingPoaFields.length === 0 && !missingDelegation,
|
complete:
|
||||||
|
missingPoaFields.length === 0 &&
|
||||||
|
!missingDelegation &&
|
||||||
|
!flaggedDelegation,
|
||||||
},
|
},
|
||||||
|
identity,
|
||||||
progress: { completed, total },
|
progress: { completed, total },
|
||||||
isComplete: outstanding.length === 0,
|
isComplete: outstanding.length === 0,
|
||||||
onboardingCompleted: profile.onboardingCompleted,
|
onboardingCompleted: profile.onboardingCompleted,
|
||||||
@@ -2044,15 +2294,348 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Power of Attorney delegation letter
|
// Power of Attorney delegation paper (DARS)
|
||||||
//
|
//
|
||||||
// A company-level document that follows the same staged-review model as the
|
// A company-level document that follows the same staged-review model as the
|
||||||
// business license: on an approved (Active) company an upload lands under the
|
// business license: on an approved (Active) company an upload lands under the
|
||||||
// pending code and the live letter is flagged for removal, so the reviewer
|
// pending code and the live paper is flagged for removal, so the reviewer
|
||||||
// sees both and approval swaps them atomically. During onboarding it goes live.
|
// sees both and approval swaps them atomically. During onboarding it goes live.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** The company's PoA letter(s), with each file's review status resolved. */
|
/**
|
||||||
|
* What the company has on file towards its DARS delegation paper. A paper
|
||||||
|
* staged for review counts as "on file" — it is the customer's whole
|
||||||
|
* obligation discharged; whether it is good enough is the reviewer's call,
|
||||||
|
* recorded as `flagged`.
|
||||||
|
*/
|
||||||
|
private async getPoaDelegationState(
|
||||||
|
companyId: string,
|
||||||
|
ignoreFileIds: string[] = [],
|
||||||
|
): Promise<{ onFile: boolean; flagged: boolean }> {
|
||||||
|
const records = (
|
||||||
|
await this.filesService.findByResource(companyId, COMPANY_RESOURCE)
|
||||||
|
).filter(
|
||||||
|
(r) =>
|
||||||
|
(r.code === POA_DELEGATION_FILE_KEY ||
|
||||||
|
r.code === POA_DELEGATION_PENDING_CODE) &&
|
||||||
|
!ignoreFileIds.includes(r.id),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
onFile: records.length > 0,
|
||||||
|
flagged: records.some((r) => r.reviewStatus === "change_requested"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule behind EDRFREIGHT-358: a company that names a Power of Attorney
|
||||||
|
* must evidence it with a DARS delegation paper, and a freight forwarder —
|
||||||
|
* which signs on other companies' behalf — must have both, verified.
|
||||||
|
*
|
||||||
|
* This is enforced at every write that can break the pairing (PoA details
|
||||||
|
* saved, paper removed, forwarder role applied for or approved) rather than
|
||||||
|
* only at onboarding submission, which is what let a company that finished
|
||||||
|
* onboarding as an importer pick up the forwarder role with neither.
|
||||||
|
*
|
||||||
|
* `attributes` is the state being written, which is not always the state on
|
||||||
|
* the row yet — a staged change request carries it, and a removal has to be
|
||||||
|
* judged against the files that would survive it (`ignoreFileIds`).
|
||||||
|
*/
|
||||||
|
private async assertPoaDelegationSatisfied(
|
||||||
|
companyId: string,
|
||||||
|
attributes: Record<string, unknown> | null | undefined,
|
||||||
|
opts: { requirePoa: boolean; ignoreFileIds?: string[] },
|
||||||
|
): Promise<void> {
|
||||||
|
const read = (key: string) =>
|
||||||
|
(attributes?.[key] as string | undefined)?.trim();
|
||||||
|
const poaProvided = POA_ATTRIBUTES.some((k) => read(k));
|
||||||
|
if (!opts.requirePoa && !poaProvided) return;
|
||||||
|
|
||||||
|
if (opts.requirePoa) {
|
||||||
|
const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
|
||||||
|
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onFile, flagged } = await this.getPoaDelegationState(
|
||||||
|
companyId,
|
||||||
|
opts.ignoreFileIds,
|
||||||
|
);
|
||||||
|
if (!onFile) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
|
||||||
|
(opts.requirePoa ? " — it is required for freight forwarders." : "."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (flagged) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
|
||||||
|
`Re-upload it before continuing.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does this company operate as a freight forwarder? */
|
||||||
|
private async isFreightForwarder(companyId: string): Promise<boolean> {
|
||||||
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
|
return profiles.some((p) => p.type === ProfileType.freightForwarder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fayda identity verification (owner / PoA)
|
||||||
|
//
|
||||||
|
// A completed VeriFayda verification proves a person's name, phone, email
|
||||||
|
// and address — Fayda's userinfo carries no national ID number, so none of
|
||||||
|
// that is collected here. For an Ethiopian company both the owner and its
|
||||||
|
// PoA (once named) must be verified before the company can trade. Fayda is
|
||||||
|
// an Ethiopian national ID system, so a foreign company's owner proves
|
||||||
|
// identity with a typed passport number instead — required on its own
|
||||||
|
// terms, not waived by an owner who happens to verify with Fayda too.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verification state for both people, plus whether it is mandatory here.
|
||||||
|
* `complete` answers the gate question directly so the portal, the onboarding
|
||||||
|
* requirements and the assertions below all read the same verdict — the
|
||||||
|
* derivation itself is shared with ProfileResponseDto.
|
||||||
|
*/
|
||||||
|
getCompanyIdentityState(company: Company): CompanyIdentityStateDto {
|
||||||
|
return buildCompanyIdentityState(company);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a Fayda verification and bind the identity to one of the company's
|
||||||
|
* people. The portal starts the flow through the shared
|
||||||
|
* `POST /fayda/verification/start` and only tells us which person it was for
|
||||||
|
* here, at completion — so the verifayda module stays generic and its session
|
||||||
|
* table needs no company-specific column.
|
||||||
|
*/
|
||||||
|
async completeIdentityVerification(
|
||||||
|
userId: string,
|
||||||
|
dto: CompleteIdentityVerificationDto,
|
||||||
|
): Promise<CompanyIdentityStateDto> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
const prefix = IDENTITY_PREFIX[dto.subject];
|
||||||
|
|
||||||
|
const result = await this.verifaydaService.completeVerification({
|
||||||
|
code: dto.code,
|
||||||
|
state: dto.state,
|
||||||
|
});
|
||||||
|
if (!result.verified || !result.sub) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Fayda could not verify this identity. Start the verification again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner delegating power of attorney to themselves is not a
|
||||||
|
// delegation — it would let one identity satisfy both halves of the check.
|
||||||
|
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
||||||
|
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
||||||
|
if (otherSub && otherSub === result.sub) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const identity: VerifiedIdentityAttributes = {
|
||||||
|
[`${prefix}FaydaSub`]: result.sub,
|
||||||
|
[`${prefix}FaydaVerifiedAt`]: now,
|
||||||
|
[`${prefix}Birthdate`]: result.birthdate ?? null,
|
||||||
|
[`${prefix}Gender`]: result.gender ?? null,
|
||||||
|
// The verified payload owns the person's details from here on.
|
||||||
|
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
|
||||||
|
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
|
||||||
|
...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}),
|
||||||
|
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// An approved company's profile edits are staged for backoffice review, and
|
||||||
|
// swapping the person who can act for the company is exactly the kind of
|
||||||
|
// edit that review exists for — so a verification lands the same way an
|
||||||
|
// ordinary edit does, rather than quietly rewriting a live record.
|
||||||
|
if (company.status === CompanyStatus.Active) {
|
||||||
|
await this.stageIdentityChange(company, userId, identity);
|
||||||
|
return this.getCompanyIdentityState(company);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.companiesRepo.update(company.id, {
|
||||||
|
attributes: { ...(company.attributes ?? {}), ...identity },
|
||||||
|
});
|
||||||
|
if (!updated)
|
||||||
|
throw new NotFoundException(`Company ${company.id} not found`);
|
||||||
|
updated.companyProfiles = company.companyProfiles;
|
||||||
|
return this.getCompanyIdentityState(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the Power of Attorney entirely — the verified identity, the details it
|
||||||
|
* wrote and the delegation paper together.
|
||||||
|
*
|
||||||
|
* Only the PoA can go: a company always has an owner, and a freight forwarder
|
||||||
|
* always has a representative. Once a PoA is Fayda-verified its
|
||||||
|
* fields are locked, so blanking the form is no longer a way out — without
|
||||||
|
* this the customer would be stuck with a representative they cannot remove.
|
||||||
|
*/
|
||||||
|
async removePoaIdentity(userId: string): Promise<CompanyIdentityStateDto> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
if (
|
||||||
|
(company.companyProfiles ?? []).some(
|
||||||
|
(p) => p.type === ProfileType.freightForwarder,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleared: Record<string, unknown> = {};
|
||||||
|
for (const key of [
|
||||||
|
...POA_ATTRIBUTES,
|
||||||
|
"poaFaydaSub",
|
||||||
|
"poaFaydaVerifiedAt",
|
||||||
|
"poaBirthdate",
|
||||||
|
"poaGender",
|
||||||
|
]) {
|
||||||
|
cleared[key] = null;
|
||||||
|
}
|
||||||
|
const attributes = { ...(company.attributes ?? {}), ...cleared };
|
||||||
|
|
||||||
|
// The paper evidences a representative who no longer exists.
|
||||||
|
const records = await this.filesService.findByResource(
|
||||||
|
company.id,
|
||||||
|
COMPANY_RESOURCE,
|
||||||
|
);
|
||||||
|
for (const r of records) {
|
||||||
|
if (
|
||||||
|
r.code === POA_DELEGATION_FILE_KEY ||
|
||||||
|
r.code === POA_DELEGATION_PENDING_CODE
|
||||||
|
) {
|
||||||
|
await this.filesService.remove(r.id);
|
||||||
|
await this.withdrawDocumentIntent(company.id, r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.companiesRepo.update(company.id, { attributes });
|
||||||
|
if (!updated)
|
||||||
|
throw new NotFoundException(`Company ${company.id} not found`);
|
||||||
|
updated.companyProfiles = company.companyProfiles;
|
||||||
|
return this.getCompanyIdentityState(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stage a verified identity onto the company's pending change request. */
|
||||||
|
private async stageIdentityChange(
|
||||||
|
company: Company,
|
||||||
|
userId: string,
|
||||||
|
identity: VerifiedIdentityAttributes,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
||||||
|
company.id,
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
const snapshot = {
|
||||||
|
...(existing?.snapshot ?? {}),
|
||||||
|
faydaIdentity: {
|
||||||
|
...(((existing?.snapshot ?? {}) as Record<string, any>)
|
||||||
|
.faydaIdentity ?? {}),
|
||||||
|
...identity,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (existing) {
|
||||||
|
await this.changeRequestRepo.update(existing.id, {
|
||||||
|
snapshot,
|
||||||
|
submittedBy: userId,
|
||||||
|
submittedAt: now,
|
||||||
|
note: null,
|
||||||
|
});
|
||||||
|
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const history = await this.changeRequestRepo.findByCompanyId(company.id);
|
||||||
|
const resubmitted = history.some(
|
||||||
|
(r) => r.status === ChangeRequestStatus.Rejected,
|
||||||
|
);
|
||||||
|
const request = await this.changeRequestRepo.create({
|
||||||
|
companyId: company.id,
|
||||||
|
snapshot,
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
|
submittedBy: userId,
|
||||||
|
submittedAt: now,
|
||||||
|
});
|
||||||
|
this.companyNotifier.changeRequestSubmitted(
|
||||||
|
company,
|
||||||
|
request.id,
|
||||||
|
resubmitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gate: an Ethiopian company's owner must be Fayda-verified, and so must
|
||||||
|
* its Power of Attorney once it has one; a foreign company's owner must carry
|
||||||
|
* a passport number instead. Called from the same places as
|
||||||
|
* `assertPoaDelegationSatisfied` — the two rules describe the same moment
|
||||||
|
* (who may act for this company, and on what evidence) and drifting them
|
||||||
|
* apart is how one of them ends up unenforced.
|
||||||
|
*/
|
||||||
|
private assertIdentityVerified(
|
||||||
|
company: Company,
|
||||||
|
opts: { requirePoa: boolean },
|
||||||
|
): void {
|
||||||
|
const state = buildCompanyIdentityState(company);
|
||||||
|
|
||||||
|
if (state.passportRequired) {
|
||||||
|
if (!state.owner.passportNumber) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Add the company owner's passport number before continuing.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.owner.verified) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Verify the company owner's identity with Fayda before continuing.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||||
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
|
);
|
||||||
|
if (!opts.requirePoa && !poaNamed) return;
|
||||||
|
|
||||||
|
if (!state.poa.verified) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
opts.requirePoa
|
||||||
|
? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one."
|
||||||
|
: "Verify the Power of Attorney you named with Fayda, or remove the representative.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PoA details the company is heading for: its live attributes with any
|
||||||
|
* pending change-request snapshot laid over them. An Active company's edits
|
||||||
|
* are staged rather than written, so the live row on its own would judge the
|
||||||
|
* customer against details they have already asked to change.
|
||||||
|
*/
|
||||||
|
private async effectivePoaAttributes(
|
||||||
|
company: Company,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const pending = await this.changeRequestRepo.findPendingByCompanyId(
|
||||||
|
company.id,
|
||||||
|
);
|
||||||
|
const snapshot = (pending?.snapshot ?? {}) as Record<string, unknown>;
|
||||||
|
const staged: Record<string, unknown> = {};
|
||||||
|
for (const key of POA_ATTRIBUTES) {
|
||||||
|
if (key in snapshot) staged[key] = snapshot[key];
|
||||||
|
}
|
||||||
|
return { ...(company.attributes ?? {}), ...staged };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The company's PoA paper(s), with each file's review status resolved. */
|
||||||
async listPoaDelegationFiles(
|
async listPoaDelegationFiles(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<CompanyDocumentFileView[]> {
|
): Promise<CompanyDocumentFileView[]> {
|
||||||
@@ -2149,6 +2732,18 @@ export class CompaniesService {
|
|||||||
throw new NotFoundException(`Delegation letter ${fileId} not found`);
|
throw new NotFoundException(`Delegation letter ${fileId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Taking the paper away is the other half of the pairing: allowed only once
|
||||||
|
// the representative it evidences is gone too (which, for an Active
|
||||||
|
// company, means the clearing edit is already staged).
|
||||||
|
await this.assertPoaDelegationSatisfied(
|
||||||
|
company.id,
|
||||||
|
await this.effectivePoaAttributes(company),
|
||||||
|
{
|
||||||
|
requirePoa: await this.isFreightForwarder(company.id),
|
||||||
|
ignoreFileIds: [fileId],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (record.code === POA_DELEGATION_PENDING_CODE) {
|
if (record.code === POA_DELEGATION_PENDING_CODE) {
|
||||||
await this.filesService.remove(fileId);
|
await this.filesService.remove(fileId);
|
||||||
await this.withdrawDocumentIntent(company.id, fileId);
|
await this.withdrawDocumentIntent(company.id, fileId);
|
||||||
@@ -2328,7 +2923,10 @@ export class CompaniesService {
|
|||||||
return match?.id ?? null;
|
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 } =
|
const { businessInfo, companyInfo } =
|
||||||
await this.etradeService.resolveCompanyData(tin);
|
await this.etradeService.resolveCompanyData(tin);
|
||||||
if (!businessInfo) {
|
if (!businessInfo) {
|
||||||
@@ -2336,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.",
|
"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(
|
return this.etradeService.extractRegistrationData(businessInfo, companyInfo);
|
||||||
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 };
|
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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsString, IsNotEmpty } from "class-validator";
|
||||||
|
|
||||||
|
import { Company, CompanyNationality } from "../entities/company.entity";
|
||||||
|
import { ProfileType } from "../entities/company-profile.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two people a company is verified through — its owner and its Power of
|
||||||
|
* Attorney. "Owner" is not the same as the General Manager: a company's GM is
|
||||||
|
* a plain typed role (with a "same as owner" copy the portal offers), while
|
||||||
|
* the owner is the person this verification proves. They're very often the
|
||||||
|
* same human, which is exactly what the copy is for.
|
||||||
|
*/
|
||||||
|
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
|
||||||
|
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
||||||
|
|
||||||
|
export class CompleteIdentityVerificationDto {
|
||||||
|
@ApiProperty({
|
||||||
|
enum: IDENTITY_SUBJECTS,
|
||||||
|
description: "Which of the company's people this verification is for.",
|
||||||
|
})
|
||||||
|
@IsIn(IDENTITY_SUBJECTS)
|
||||||
|
subject!: IdentitySubject;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Authorization code from the Fayda redirect." })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "CSRF state from the Fayda redirect." })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
state!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One person's verification state, as reported back to the portal. */
|
||||||
|
export class IdentityVerificationStateDto {
|
||||||
|
@ApiProperty() verified!: boolean;
|
||||||
|
@ApiProperty({ nullable: true }) name!: string | null;
|
||||||
|
@ApiProperty({ nullable: true }) phone!: string | null;
|
||||||
|
@ApiProperty({ nullable: true }) email!: string | null;
|
||||||
|
@ApiProperty({ nullable: true }) address!: string | null;
|
||||||
|
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
|
||||||
|
@ApiProperty({ nullable: true }) birthdate!: string | null;
|
||||||
|
@ApiProperty({ nullable: true }) gender!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
|
||||||
|
@ApiProperty({
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
"Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
|
||||||
|
})
|
||||||
|
passportNumber!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompanyIdentityStateDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
|
||||||
|
})
|
||||||
|
faydaRequired!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
|
||||||
|
})
|
||||||
|
passportRequired!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: OwnerIdentityStateDto })
|
||||||
|
owner!: OwnerIdentityStateDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||||
|
poa!: IdentityVerificationStateDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
|
||||||
|
})
|
||||||
|
complete!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `attributes` key prefix per person. */
|
||||||
|
const PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
||||||
|
owner: "owner",
|
||||||
|
poa: "poa",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** company.attributes keys that together mean "a PoA was entered". */
|
||||||
|
const POA_KEYS = [
|
||||||
|
"poaName",
|
||||||
|
"poaPhone",
|
||||||
|
"poaEmail",
|
||||||
|
"poaLocation",
|
||||||
|
"poaAddress",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function stateFor(
|
||||||
|
attrs: Record<string, unknown>,
|
||||||
|
subject: IdentitySubject,
|
||||||
|
): IdentityVerificationStateDto {
|
||||||
|
const p = PREFIX[subject];
|
||||||
|
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||||
|
return {
|
||||||
|
verified: Boolean(read(`${p}FaydaSub`)),
|
||||||
|
name: read(`${p}Name`),
|
||||||
|
phone: read(`${p}Phone`),
|
||||||
|
email: read(`${p}Email`),
|
||||||
|
address: read(`${p}Address`),
|
||||||
|
verifiedAt: read(`${p}FaydaVerifiedAt`),
|
||||||
|
birthdate: read(`${p}Birthdate`),
|
||||||
|
gender: read(`${p}Gender`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive both people's verification state from the company row.
|
||||||
|
*
|
||||||
|
* Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
|
||||||
|
* renders from it, so the settings page and the onboarding wizard can never
|
||||||
|
* disagree with the rule the API actually enforces.
|
||||||
|
*/
|
||||||
|
export function buildCompanyIdentityState(
|
||||||
|
company: Company,
|
||||||
|
): CompanyIdentityStateDto {
|
||||||
|
const attrs = company.attributes ?? {};
|
||||||
|
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||||
|
|
||||||
|
// Fayda is an Ethiopian national ID — a foreign company's owner may not hold
|
||||||
|
// one, so a typed passport number is the mandatory credential there instead.
|
||||||
|
// The two are mutually exclusive by nationality but independently tracked,
|
||||||
|
// since a foreign owner verifying with Fayda doesn't waive the passport.
|
||||||
|
const foreign = company.nationality === CompanyNationality.Foreign;
|
||||||
|
const faydaRequired = !foreign;
|
||||||
|
const passportRequired = foreign;
|
||||||
|
|
||||||
|
const owner: OwnerIdentityStateDto = {
|
||||||
|
...stateFor(attrs, "owner"),
|
||||||
|
passportNumber: read("ownerPassportNumber"),
|
||||||
|
};
|
||||||
|
const poa = stateFor(attrs, "poa");
|
||||||
|
const poaDue =
|
||||||
|
(company.companyProfiles ?? []).some(
|
||||||
|
(p) => p.type === ProfileType.freightForwarder,
|
||||||
|
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||||
|
|
||||||
|
const complete = faydaRequired
|
||||||
|
? owner.verified && (!poaDue || poa.verified)
|
||||||
|
: !passportRequired || Boolean(owner.passportNumber);
|
||||||
|
|
||||||
|
return { faydaRequired, passportRequired, owner, poa, complete };
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
* truth the wizard uses to auto-finish.
|
* truth the wizard uses to auto-finish.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
|
||||||
|
|
||||||
export interface OnboardingInfoField {
|
export interface OnboardingInfoField {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -40,11 +42,13 @@ export interface OnboardingPoaState {
|
|||||||
required: boolean;
|
required: boolean;
|
||||||
/** True once any PoA detail has been entered. */
|
/** True once any PoA detail has been entered. */
|
||||||
provided: boolean;
|
provided: boolean;
|
||||||
/** True when the delegation letter is stored for the company. */
|
/** True when the DARS delegation paper is stored for the company. */
|
||||||
delegationLetterUploaded: boolean;
|
delegationLetterUploaded: boolean;
|
||||||
|
/** True when a reviewer sent the paper back for correction. */
|
||||||
|
delegationLetterFlagged: boolean;
|
||||||
/** PoA details still missing (only populated when `required`). */
|
/** PoA details still missing (only populated when `required`). */
|
||||||
missingFields: OnboardingInfoField[];
|
missingFields: OnboardingInfoField[];
|
||||||
/** False while the PoA step still owes details or a delegation letter. */
|
/** False while the PoA step still owes details or an uncorrected paper. */
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto {
|
|||||||
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
|
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
|
||||||
poa: OnboardingPoaState;
|
poa: OnboardingPoaState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fayda verification state for the company's people. `required` is false for
|
||||||
|
* a foreign company, which is never gated on it — the portal renders the
|
||||||
|
* typed personnel forms in that case and the verify panels otherwise.
|
||||||
|
*/
|
||||||
|
identity: CompanyIdentityStateDto;
|
||||||
|
|
||||||
/** Overall setup progress across fields + documents + licenses. */
|
/** Overall setup progress across fields + documents + licenses. */
|
||||||
progress: { completed: number; total: number };
|
progress: { completed: number; total: number };
|
||||||
|
|
||||||
@@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto {
|
|||||||
this.documents = init.documents;
|
this.documents = init.documents;
|
||||||
this.licenseProfiles = init.licenseProfiles;
|
this.licenseProfiles = init.licenseProfiles;
|
||||||
this.poa = init.poa;
|
this.poa = init.poa;
|
||||||
|
this.identity = init.identity;
|
||||||
this.progress = init.progress;
|
this.progress = init.progress;
|
||||||
this.isComplete = init.isComplete;
|
this.isComplete = init.isComplete;
|
||||||
this.onboardingCompleted = init.onboardingCompleted;
|
this.onboardingCompleted = init.onboardingCompleted;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
buildCompanyIdentityState,
|
||||||
|
CompanyIdentityStateDto,
|
||||||
|
} from "./complete-identity-verification.dto";
|
||||||
import { Company } from '../entities/company.entity';
|
import { Company } from '../entities/company.entity';
|
||||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +56,16 @@ export class ProfileResponseDto {
|
|||||||
|
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fayda verification state for the company's owner and PoA — not the general
|
||||||
|
* manager, which is a separate typed role. The settings tabs and the
|
||||||
|
* onboarding wizard render from `identity.faydaRequired` /
|
||||||
|
* `identity.passportRequired`: an Ethiopian company verifies the owner (and
|
||||||
|
* PoA) instead of typing their details; a foreign one requires a typed
|
||||||
|
* passport number instead.
|
||||||
|
*/
|
||||||
|
identity: CompanyIdentityStateDto;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
||||||
@@ -124,5 +138,6 @@ export class ProfileResponseDto {
|
|||||||
: null;
|
: null;
|
||||||
this.reviewNote = openReview?.note ?? null;
|
this.reviewNote = openReview?.note ?? null;
|
||||||
this.pendingChanges = openReview?.snapshot ?? null;
|
this.pendingChanges = openReview?.snapshot ?? null;
|
||||||
|
this.identity = buildCompanyIdentityState(company);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
ProfileLicenseFileView,
|
ProfileLicenseFileView,
|
||||||
} from '../entities/company-profile.entity';
|
} from '../entities/company-profile.entity';
|
||||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||||
|
import {
|
||||||
|
buildCompanyIdentityState,
|
||||||
|
CompanyIdentityStateDto,
|
||||||
|
} from './complete-identity-verification.dto';
|
||||||
|
|
||||||
export class ResponseCompanyProfileDto {
|
export class ResponseCompanyProfileDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,6 +73,28 @@ export class ResponseCompanyDto {
|
|||||||
* external profiles weren't loaded.
|
* external profiles weren't loaded.
|
||||||
*/
|
*/
|
||||||
onboardingCompleted?: boolean;
|
onboardingCompleted?: boolean;
|
||||||
|
|
||||||
|
// eTrade-sourced registration record — populated by the onboarding TIN
|
||||||
|
// lookup, locked/read-only on the portal from the moment it's fetched.
|
||||||
|
licenceNumber?: string | null;
|
||||||
|
statusDescription?: string | null;
|
||||||
|
dateRegistered?: string | null;
|
||||||
|
renewedFrom?: string | null;
|
||||||
|
renewalDate?: string | null;
|
||||||
|
renewedTo?: string | null;
|
||||||
|
region?: string | null;
|
||||||
|
zone?: string | null;
|
||||||
|
woreda?: string | null;
|
||||||
|
kebele?: string | null;
|
||||||
|
houseNo?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner/PoA Fayda verification state, shared with the portal
|
||||||
|
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
|
||||||
|
* disagrees with — the rule the API actually enforces.
|
||||||
|
*/
|
||||||
|
identity: CompanyIdentityStateDto;
|
||||||
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
@@ -95,6 +121,18 @@ export class ResponseCompanyDto {
|
|||||||
? company.profiles.length === 0 ||
|
? company.profiles.length === 0 ||
|
||||||
company.profiles.some((p) => p.onboardingCompleted)
|
company.profiles.some((p) => p.onboardingCompleted)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
this.licenceNumber = company.licenceNumber;
|
||||||
|
this.statusDescription = company.statusDescription;
|
||||||
|
this.dateRegistered = company.dateRegistered;
|
||||||
|
this.renewedFrom = company.renewedFrom;
|
||||||
|
this.renewalDate = company.renewalDate;
|
||||||
|
this.renewedTo = company.renewedTo;
|
||||||
|
this.region = company.region;
|
||||||
|
this.zone = company.zone;
|
||||||
|
this.woreda = company.woreda;
|
||||||
|
this.kebele = company.kebele;
|
||||||
|
this.houseNo = company.houseNo;
|
||||||
|
this.identity = buildCompanyIdentityState(company);
|
||||||
this.createdAt = company.createdAt;
|
this.createdAt = company.createdAt;
|
||||||
this.updatedAt = company.updatedAt;
|
this.updatedAt = company.updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ export class UpdateProfileDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
vatNumber?: string;
|
vatNumber?: string;
|
||||||
|
|
||||||
@IsOptional()
|
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
||||||
@IsString()
|
// company's PoA (or its general manager), so it is derived from a completed
|
||||||
@MaxLength(16)
|
// Fayda verification rather than typed. The global validation pipe runs with
|
||||||
fanNumber?: string;
|
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
|
||||||
|
// so — see CompaniesService.completeIdentityVerification.
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -110,6 +111,16 @@ export class UpdateProfileDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
poaAddress?: string;
|
poaAddress?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner's passport number — the identity credential for a foreign
|
||||||
|
* company, since Fayda is an Ethiopian national ID. Plain typed field, never
|
||||||
|
* written or locked by a Fayda verification: still required even if the
|
||||||
|
* owner also verifies.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ownerPassportNumber?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
FILE_UPLOAD_SETTINGS_REPOSITORY,
|
FILE_UPLOAD_SETTINGS_REPOSITORY,
|
||||||
IFileUploadSettingsRepository,
|
IFileUploadSettingsRepository,
|
||||||
} from "./interfaces/file-upload-settings.repository.interface";
|
} from "./interfaces/file-upload-settings.repository.interface";
|
||||||
|
import {
|
||||||
|
COMPANY_ONBOARDING_CODE_PREFIX,
|
||||||
|
POA_DELEGATION_FILE_KEY,
|
||||||
|
poaDelegationField,
|
||||||
|
} from "./poa-delegation.constants";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileUploadSettingsService {
|
export class FileUploadSettingsService {
|
||||||
@@ -40,6 +45,22 @@ export class FileUploadSettingsService {
|
|||||||
async getByCode(code: string): Promise<FileUploadSetting> {
|
async getByCode(code: string): Promise<FileUploadSetting> {
|
||||||
const setting = await this.repository.findByCode(code);
|
const setting = await this.repository.findByCode(code);
|
||||||
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
|
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
|
||||||
|
return this.withPoaDelegationField(setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company onboarding sets always carry the DARS delegation paper, whether or
|
||||||
|
* not anyone configured a row for it — see poa-delegation.constants.ts. Every
|
||||||
|
* consumer (the portal's PoA step, the onboarding gate) reads the set through
|
||||||
|
* here, so this is the single place the field can be guaranteed.
|
||||||
|
*/
|
||||||
|
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
|
||||||
|
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
|
||||||
|
const fields = setting.fields ?? [];
|
||||||
|
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;
|
||||||
|
|
||||||
|
const lastOrder = fields.reduce((max, f) => Math.max(max, f.displayOrder), 0);
|
||||||
|
setting.fields = [...fields, poaDelegationField(lastOrder + 1)];
|
||||||
return setting;
|
return setting;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { FileUploadField } from "./entities/file-upload-field.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DARS delegation paper — the document that evidences a company's Power of
|
||||||
|
* Attorney (EDRFREIGHT-358).
|
||||||
|
*
|
||||||
|
* Every other onboarding document is admin-managed: the rows in
|
||||||
|
* `file_upload_fields` are edited from the backoffice file-settings editor and
|
||||||
|
* the seeder deliberately inserts none. This one is different — a company that
|
||||||
|
* names a PoA must produce a delegation paper authenticated by the Documents
|
||||||
|
* Authentication and Registration Service, and that is a legal requirement
|
||||||
|
* rather than a configuration choice. So the field is defined here in code and
|
||||||
|
* injected into the company onboarding sets on read: no row to forget to seed,
|
||||||
|
* and deleting one in the editor cannot silently switch the requirement off.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** FileRecord `code` (and upload field key) of the live delegation paper. */
|
||||||
|
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||||
|
|
||||||
|
/** Code for a delegation paper staged in an open change request (not yet live). */
|
||||||
|
export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
||||||
|
|
||||||
|
/** Customer-facing name of the document, used by the API and both web apps. */
|
||||||
|
export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
|
||||||
|
|
||||||
|
/** Prefix of the setting codes the field is injected into. */
|
||||||
|
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
|
||||||
|
|
||||||
|
const POA_DELEGATION_HELP =
|
||||||
|
"Delegation paper issued by the Documents Authentication and Registration " +
|
||||||
|
"Service (DARS) delegating the representative named above. Upload the " +
|
||||||
|
"authenticated copy — a plain letter is not accepted.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The field descriptor. `isRequired` stays false because the paper is only due
|
||||||
|
* once a PoA has actually been named (or the company operates as a freight
|
||||||
|
* forwarder) — a rule that spans form fields as well as files, so it is
|
||||||
|
* enforced in CompaniesService rather than by this flag.
|
||||||
|
*/
|
||||||
|
export function poaDelegationField(displayOrder: number): FileUploadField {
|
||||||
|
return {
|
||||||
|
fileKey: POA_DELEGATION_FILE_KEY,
|
||||||
|
fileLabel: POA_DELEGATION_LABEL,
|
||||||
|
helpText: POA_DELEGATION_HELP,
|
||||||
|
isRequired: false,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder,
|
||||||
|
} as FileUploadField;
|
||||||
|
}
|
||||||
@@ -13,14 +13,14 @@ export class StartVerificationDto {
|
|||||||
purpose?: 'LOGIN' | 'VERIFY';
|
purpose?: 'LOGIN' | 'VERIFY';
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: ['WEB', 'MOBILE'],
|
enum: ['WEB', 'MOBILE', 'PORTAL'],
|
||||||
default: 'WEB',
|
default: 'WEB',
|
||||||
description:
|
description:
|
||||||
'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.',
|
'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB (backoffice) uses FAYDA_WEB_REDIRECT_URI, PORTAL uses FAYDA_PORTAL_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. All land on the same /complete handling.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['WEB', 'MOBILE'])
|
@IsIn(['WEB', 'MOBILE', 'PORTAL'])
|
||||||
platform?: 'WEB' | 'MOBILE';
|
platform?: 'WEB' | 'MOBILE' | 'PORTAL';
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -57,6 +57,12 @@ export class CompleteVerificationResultDto {
|
|||||||
agentId?: string;
|
agentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Fayda OIDC subject — the stable key a verified identity is stored under (VERIFY flow). Pairwise pseudonymous.',
|
||||||
|
})
|
||||||
|
sub?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' })
|
@ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' })
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
|
|
||||||
@@ -74,6 +80,11 @@ export class CompleteVerificationResultDto {
|
|||||||
@ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
|
@ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
|
||||||
gender?: string;
|
gender?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Verified address from Fayda, English rendering (VERIFY flow).',
|
||||||
|
})
|
||||||
|
address?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
|
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
|
||||||
userDataSaved?: boolean;
|
userDataSaved?: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,15 @@ export interface CompleteVerificationResult {
|
|||||||
promptPasswordSetup?: boolean;
|
promptPasswordSetup?: boolean;
|
||||||
iamUserId?: string;
|
iamUserId?: string;
|
||||||
user?: FaydaUserSummary;
|
user?: FaydaUserSummary;
|
||||||
|
/** Fayda OIDC subject — the stable key a verified identity is stored under. */
|
||||||
|
sub?: string;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
birthdate?: string;
|
birthdate?: string;
|
||||||
gender?: string;
|
gender?: string;
|
||||||
|
/** Verified address, English rendering (falls back to Amharic). */
|
||||||
|
address?: string;
|
||||||
userDataSaved?: boolean;
|
userDataSaved?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,11 +129,15 @@ export class VerifaydaService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */
|
/**
|
||||||
|
* Each client lands on its own registered redirect_uri: MOBILE on the base
|
||||||
|
* one, the customer portal on its own origin, everything else (backoffice) on
|
||||||
|
* the web one. All three must be registered with eSignet.
|
||||||
|
*/
|
||||||
private redirectUriForPlatform(platform?: FaydaPlatform): string {
|
private redirectUriForPlatform(platform?: FaydaPlatform): string {
|
||||||
return platform === 'MOBILE'
|
if (platform === 'MOBILE') return this.faydaConfig.redirectUri;
|
||||||
? this.faydaConfig.redirectUri
|
if (platform === 'PORTAL') return this.faydaConfig.portalRedirectUri;
|
||||||
: this.faydaConfig.webRedirectUri;
|
return this.faydaConfig.webRedirectUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeVerification(
|
async completeVerification(
|
||||||
@@ -210,11 +218,13 @@ export class VerifaydaService {
|
|||||||
result = {
|
result = {
|
||||||
purpose: 'VERIFY',
|
purpose: 'VERIFY',
|
||||||
verified: true,
|
verified: true,
|
||||||
|
sub: normalized.sub,
|
||||||
fullName: normalized.fullName,
|
fullName: normalized.fullName,
|
||||||
email: normalized.email,
|
email: normalized.email,
|
||||||
phoneNumber: normalized.phoneNumber,
|
phoneNumber: normalized.phoneNumber,
|
||||||
birthdate: normalized.birthdate,
|
birthdate: normalized.birthdate,
|
||||||
gender: normalized.gender,
|
gender: normalized.gender,
|
||||||
|
address: normalized.addressEn ?? normalized.addressAm,
|
||||||
userDataSaved,
|
userDataSaved,
|
||||||
iamUserId: iamUserId ?? undefined,
|
iamUserId: iamUserId ?? undefined,
|
||||||
token: sessionToken?.token,
|
token: sessionToken?.token,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common";
|
|||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
||||||
|
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants";
|
||||||
|
|
||||||
interface OnboardingField {
|
interface OnboardingField {
|
||||||
fileKey: string;
|
fileKey: string;
|
||||||
@@ -17,27 +18,14 @@ interface OnboardingField {
|
|||||||
|
|
||||||
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
|
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
|
||||||
|
|
||||||
/** fileKey of the delegation letter attached to the Power of Attorney step. */
|
|
||||||
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seeded as optional: the delegation letter is only mandatory once a PoA has
|
* Listed in the sets below only so the reference defaults stay a complete
|
||||||
* been entered, or when the company operates as a freight forwarder. That rule
|
* picture of a company onboarding form. Unlike every other field here, the DARS
|
||||||
* spans form fields as well as files, so it lives in the onboarding gate
|
* delegation paper is not admin-managed: `FileUploadSettingsService.getByCode`
|
||||||
* (companies.service.getOnboardingRequirements) rather than in `isRequired`.
|
* injects it from poa-delegation.constants.ts whether or not a row exists.
|
||||||
*/
|
*/
|
||||||
const poaDelegationField = (displayOrder: number): OnboardingField => ({
|
const poaDelegationDefault = (displayOrder: number): OnboardingField =>
|
||||||
fileKey: POA_DELEGATION_FILE_KEY,
|
poaDelegationField(displayOrder) as unknown as OnboardingField;
|
||||||
fileLabel: "PoA Delegation Letter",
|
|
||||||
helpText:
|
|
||||||
"Signed letter in which the General Manager delegates the representative named above.",
|
|
||||||
isRequired: false,
|
|
||||||
isMultiple: false,
|
|
||||||
maxFiles: 1,
|
|
||||||
allowedExtensions: DOC_EXTENSIONS,
|
|
||||||
maxSizeMb: 10,
|
|
||||||
displayOrder,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Documents required from an Ethiopian company at onboarding. */
|
/** Documents required from an Ethiopian company at onboarding. */
|
||||||
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
|
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||||
@@ -75,7 +63,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
|
|||||||
maxSizeMb: 10,
|
maxSizeMb: 10,
|
||||||
displayOrder: 3,
|
displayOrder: 3,
|
||||||
},
|
},
|
||||||
poaDelegationField(4),
|
poaDelegationDefault(4),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Documents required from a Foreign company at onboarding. */
|
/** Documents required from a Foreign company at onboarding. */
|
||||||
@@ -124,7 +112,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
|||||||
maxSizeMb: 10,
|
maxSizeMb: 10,
|
||||||
displayOrder: 4,
|
displayOrder: 4,
|
||||||
},
|
},
|
||||||
poaDelegationField(5),
|
poaDelegationDefault(5),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Legacy combined set, kept for the older per-company-type codes. */
|
/** Legacy combined set, kept for the older per-company-type codes. */
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
woreda: "Woreda",
|
woreda: "Woreda",
|
||||||
kebele: "Kebele",
|
kebele: "Kebele",
|
||||||
houseNo: "House no.",
|
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. */
|
/** 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);
|
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({
|
function DiffRow({
|
||||||
label,
|
label,
|
||||||
from,
|
from,
|
||||||
@@ -153,8 +228,11 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
if (!pending && history.length === 0) return null;
|
if (!pending && history.length === 0) return null;
|
||||||
|
|
||||||
const proposedKeys = pending
|
const proposedKeys = pending
|
||||||
? Object.keys(pending.snapshot ?? {})
|
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
||||||
: ([] as string[]);
|
: ([] as string[]);
|
||||||
|
const faydaIdentitySnapshot = pending?.snapshot?.faydaIdentity as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
const docCount = pending?.documentFileIds?.length ?? 0;
|
const docCount = pending?.documentFileIds?.length ?? 0;
|
||||||
const licenseChanges = pending?.licenseChanges ?? [];
|
const licenseChanges = pending?.licenseChanges ?? [];
|
||||||
const documentChanges = pending?.documentChanges ?? [];
|
const documentChanges = pending?.documentChanges ?? [];
|
||||||
@@ -209,10 +287,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
) : (
|
) : !faydaIdentitySnapshot ? (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No field changes — document uploads only.
|
No field changes — document uploads only.
|
||||||
</Text>
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{faydaIdentitySnapshot && (
|
||||||
|
<FaydaIdentityDiff company={company} snapshot={faydaIdentitySnapshot} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{documentChanges.length > 0 && (
|
{documentChanges.length > 0 && (
|
||||||
|
|||||||
@@ -607,8 +607,13 @@ export default function CustomerDetailPage() {
|
|||||||
{ label: "PoA address", value: company?.poaAddress },
|
{ label: "PoA address", value: company?.poaAddress },
|
||||||
];
|
];
|
||||||
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
|
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
|
||||||
|
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
|
||||||
|
// this page can never disagree with the rule the API actually enforces.
|
||||||
|
const ownerIdentity = company?.identity?.owner;
|
||||||
|
const poaIdentity = company?.identity?.poa;
|
||||||
|
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
|
||||||
// A freight forwarder acts on other companies' behalf, so its PoA — details
|
// A freight forwarder acts on other companies' behalf, so its PoA — details
|
||||||
// and delegation letter both — is mandatory rather than optional.
|
// and DARS delegation paper both — is mandatory rather than optional.
|
||||||
const poaMandatory = (company?.companyProfiles ?? []).some(
|
const poaMandatory = (company?.companyProfiles ?? []).some(
|
||||||
(p) => p.type === "freight_forwarder",
|
(p) => p.type === "freight_forwarder",
|
||||||
);
|
);
|
||||||
@@ -752,6 +757,16 @@ export default function CustomerDetailPage() {
|
|||||||
<InfoField label="TIN" value={company.tin} />
|
<InfoField label="TIN" value={company.tin} />
|
||||||
<InfoField label="VAT number" value={company.vatNumber} />
|
<InfoField label="VAT number" value={company.vatNumber} />
|
||||||
<InfoField label="FAN number" value={company.fanNumber} />
|
<InfoField label="FAN number" value={company.fanNumber} />
|
||||||
|
<InfoField
|
||||||
|
label="Owner identity"
|
||||||
|
value={
|
||||||
|
ownerIdentity?.verified
|
||||||
|
? "Fayda verified"
|
||||||
|
: ownerIdentity?.passportNumber
|
||||||
|
? `Passport ${ownerIdentity.passportNumber}`
|
||||||
|
: "Not verified"
|
||||||
|
}
|
||||||
|
/>
|
||||||
<InfoField label="Country" value={company.country} />
|
<InfoField label="Country" value={company.country} />
|
||||||
<InfoField label="Address" value={company.address} />
|
<InfoField label="Address" value={company.address} />
|
||||||
<InfoField label="Website" value={company.website} />
|
<InfoField label="Website" value={company.website} />
|
||||||
@@ -783,6 +798,97 @@ export default function CustomerDetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
eTrade registration
|
||||||
|
</Text>
|
||||||
|
{hasEtradeRecord ? (
|
||||||
|
<Badge size="sm" color="edr-green" variant="light">
|
||||||
|
Verified with eTrade
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" color="gray" variant="light">
|
||||||
|
No eTrade record
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{hasEtradeRecord ? (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
|
<InfoField
|
||||||
|
label="License number"
|
||||||
|
value={company.licenceNumber}
|
||||||
|
/>
|
||||||
|
<InfoField label="Status" value={company.statusDescription} />
|
||||||
|
<InfoField
|
||||||
|
label="Date registered"
|
||||||
|
value={company.dateRegistered}
|
||||||
|
/>
|
||||||
|
<InfoField label="Renewed from" value={company.renewedFrom} />
|
||||||
|
<InfoField label="Renewal date" value={company.renewalDate} />
|
||||||
|
<InfoField label="Renewed to" value={company.renewedTo} />
|
||||||
|
<InfoField label="Region" value={company.region} />
|
||||||
|
<InfoField label="Zone" value={company.zone} />
|
||||||
|
<InfoField label="Woreda" value={company.woreda} />
|
||||||
|
<InfoField label="Kebele" value={company.kebele} />
|
||||||
|
<InfoField label="House No" value={company.houseNo} />
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No eTrade registration record on file for this customer's
|
||||||
|
TIN.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Owner identity
|
||||||
|
</Text>
|
||||||
|
{ownerIdentity?.verified ? (
|
||||||
|
<Badge size="sm" color="edr-green" variant="light">
|
||||||
|
Fayda verified
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" color="gray" variant="light">
|
||||||
|
Not verified
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{ownerIdentity?.verified ? (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
|
<InfoField label="Name" value={ownerIdentity.name} />
|
||||||
|
<InfoField label="Phone" value={ownerIdentity.phone} />
|
||||||
|
<InfoField label="Email" value={ownerIdentity.email} />
|
||||||
|
<InfoField label="Address" value={ownerIdentity.address} />
|
||||||
|
<InfoField
|
||||||
|
label="Verified at"
|
||||||
|
value={formatDate(ownerIdentity.verifiedAt)}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Birthdate"
|
||||||
|
value={ownerIdentity.birthdate}
|
||||||
|
/>
|
||||||
|
<InfoField label="Gender" value={ownerIdentity.gender} />
|
||||||
|
<InfoField
|
||||||
|
label="Passport number"
|
||||||
|
value={ownerIdentity.passportNumber}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{ownerIdentity?.passportNumber
|
||||||
|
? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.`
|
||||||
|
: "The company owner has not verified their identity with Fayda."}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
@@ -798,11 +904,11 @@ export default function CustomerDetailPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
{delegationMissing ? (
|
{delegationMissing ? (
|
||||||
<Badge size="sm" color="red" variant="light">
|
<Badge size="sm" color="red" variant="light">
|
||||||
Delegation letter missing
|
DARS delegation paper missing
|
||||||
</Badge>
|
</Badge>
|
||||||
) : poaLive.length > 0 ? (
|
) : poaLive.length > 0 ? (
|
||||||
<Badge size="sm" color="edr-green" variant="light">
|
<Badge size="sm" color="edr-green" variant="light">
|
||||||
Delegation letter on file
|
DARS delegation paper on file
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge size="sm" color="gray" variant="light">
|
<Badge size="sm" color="gray" variant="light">
|
||||||
@@ -820,6 +926,25 @@ export default function CustomerDetailPage() {
|
|||||||
value={f.value}
|
value={f.value}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<InfoField
|
||||||
|
label="PoA Fayda"
|
||||||
|
value={
|
||||||
|
poaIdentity?.verified ? "Verified" : "Not verified"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{poaIdentity?.verified && (
|
||||||
|
<>
|
||||||
|
<InfoField
|
||||||
|
label="PoA verified at"
|
||||||
|
value={formatDate(poaIdentity.verifiedAt)}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="PoA birthdate"
|
||||||
|
value={poaIdentity.birthdate}
|
||||||
|
/>
|
||||||
|
<InfoField label="PoA gender" value={poaIdentity.gender} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
@@ -838,7 +963,7 @@ export default function CustomerDetailPage() {
|
|||||||
tt="uppercase"
|
tt="uppercase"
|
||||||
style={{ letterSpacing: "0.04em" }}
|
style={{ letterSpacing: "0.04em" }}
|
||||||
>
|
>
|
||||||
Delegation letter
|
DARS delegation paper
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{documentsQuery.isLoading ? (
|
{documentsQuery.isLoading ? (
|
||||||
@@ -864,7 +989,7 @@ export default function CustomerDetailPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
) : poaDocuments.length === 0 ? (
|
) : poaDocuments.length === 0 ? (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No delegation letter uploaded.
|
No DARS delegation paper uploaded.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
poaDocuments.map((doc) => (
|
poaDocuments.map((doc) => (
|
||||||
|
|||||||
@@ -135,6 +135,36 @@ export interface CustomerResetTarget {
|
|||||||
phoneIsDomestic: boolean | null;
|
phoneIsDomestic: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One person's Fayda verification state — mirrors `IdentityVerificationStateDto`. */
|
||||||
|
export interface IdentityVerificationState {
|
||||||
|
verified: boolean;
|
||||||
|
name: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
address: string | null;
|
||||||
|
verifiedAt: string | null;
|
||||||
|
birthdate: string | null;
|
||||||
|
gender: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors `OwnerIdentityStateDto`. */
|
||||||
|
export interface OwnerIdentityState extends IdentityVerificationState {
|
||||||
|
passportNumber: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner/PoA Fayda verification, shared with the portal's derivation
|
||||||
|
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
|
||||||
|
* disagrees with — the rule the API actually enforces.
|
||||||
|
*/
|
||||||
|
export interface CompanyIdentityState {
|
||||||
|
faydaRequired: boolean;
|
||||||
|
passportRequired: boolean;
|
||||||
|
owner: OwnerIdentityState;
|
||||||
|
poa: IdentityVerificationState;
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||||
export interface Company {
|
export interface Company {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -161,6 +191,20 @@ export interface Company {
|
|||||||
poaAddress?: string | null;
|
poaAddress?: string | null;
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
attributes?: Record<string, unknown> | null;
|
attributes?: Record<string, unknown> | null;
|
||||||
|
// eTrade-sourced registration record — populated by the onboarding TIN
|
||||||
|
// lookup, locked/read-only on the portal from the moment it's fetched.
|
||||||
|
licenceNumber?: string | null;
|
||||||
|
statusDescription?: string | null;
|
||||||
|
dateRegistered?: string | null;
|
||||||
|
renewedFrom?: string | null;
|
||||||
|
renewalDate?: string | null;
|
||||||
|
renewedTo?: string | null;
|
||||||
|
region?: string | null;
|
||||||
|
zone?: string | null;
|
||||||
|
woreda?: string | null;
|
||||||
|
kebele?: string | null;
|
||||||
|
houseNo?: string | null;
|
||||||
|
identity?: CompanyIdentityState;
|
||||||
companyProfiles: CompanyProfile[];
|
companyProfiles: CompanyProfile[];
|
||||||
/**
|
/**
|
||||||
* Whether the customer submitted their onboarding application. A company row
|
* Whether the customer submitted their onboarding application. A company row
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import NewShipmentPage from "./pages/contracts/NewShipmentPage";
|
|||||||
import NewShipmentRequestPage from "./pages/contracts/NewShipmentRequestPage";
|
import NewShipmentRequestPage from "./pages/contracts/NewShipmentRequestPage";
|
||||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||||
|
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||||
|
|
||||||
@@ -261,6 +262,9 @@ const App = () => {
|
|||||||
element={<CheckPaymentPage />}
|
element={<CheckPaymentPage />}
|
||||||
/>
|
/>
|
||||||
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
|
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
|
||||||
|
{/* Fayda (eSignet) redirect_uri — runs in the verification popup and
|
||||||
|
relays the code/state back to the form that opened it. */}
|
||||||
|
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||||
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
||||||
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
||||||
|
|
||||||
|
|||||||
230
apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx
Normal file
230
apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { BadgeCheck, Clock, ShieldCheck, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
verifaydaService,
|
||||||
|
type CompanyIdentityState,
|
||||||
|
type FaydaCallbackMessage,
|
||||||
|
type IdentitySubject,
|
||||||
|
type IdentityVerificationState,
|
||||||
|
} from "@/services/verifayda.service";
|
||||||
|
|
||||||
|
interface FaydaVerifyPanelProps {
|
||||||
|
subject: IdentitySubject;
|
||||||
|
/** Heading — "General Manager" / "Power of Attorney". */
|
||||||
|
title: string;
|
||||||
|
/** What this person's verification is currently known to be. */
|
||||||
|
state?: IdentityVerificationState;
|
||||||
|
/**
|
||||||
|
* False for a foreign company: verification is offered but nothing is gated
|
||||||
|
* on it, so the panel says so rather than nagging.
|
||||||
|
*/
|
||||||
|
required: boolean;
|
||||||
|
/** 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 {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify one of the company's people through Fayda and show what came back.
|
||||||
|
*
|
||||||
|
* The identity is proved in an eSignet popup; that popup lands on /callback,
|
||||||
|
* which relays the code+state here by postMessage. This window then completes
|
||||||
|
* the exchange — once, in one place — and the API writes the person's name,
|
||||||
|
* phone, email and address from the verified payload. Nothing on this panel
|
||||||
|
* is typed.
|
||||||
|
*/
|
||||||
|
export default function FaydaVerifyPanel({
|
||||||
|
subject,
|
||||||
|
title,
|
||||||
|
state,
|
||||||
|
required,
|
||||||
|
onVerified,
|
||||||
|
disabled,
|
||||||
|
pendingReview,
|
||||||
|
}: FaydaVerifyPanelProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// The listener closes over `subject`; keep it in a ref so remounting the
|
||||||
|
// panel between steps can't complete a verification against the wrong person.
|
||||||
|
const subjectRef = useRef(subject);
|
||||||
|
subjectRef.current = subject;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
|
||||||
|
if (event.origin !== window.location.origin) return;
|
||||||
|
if (event.data?.type !== "fayda-callback") return;
|
||||||
|
|
||||||
|
if (event.data.error) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(event.data.errorDescription ?? event.data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!event.data.code || !event.data.state) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const next = await verifaydaService.completeIdentity(
|
||||||
|
subjectRef.current,
|
||||||
|
event.data.code,
|
||||||
|
event.data.state,
|
||||||
|
);
|
||||||
|
setError(null);
|
||||||
|
onVerified(next);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ??
|
||||||
|
(err instanceof Error ? err.message : "Verification failed"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("message", onMessage);
|
||||||
|
return () => window.removeEventListener("message", onMessage);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startVerification = async () => {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const authorizationUrl = await verifaydaService.start();
|
||||||
|
const popup = window.open(
|
||||||
|
authorizationUrl,
|
||||||
|
"fayda-verify",
|
||||||
|
"width=480,height=760,noopener=no",
|
||||||
|
);
|
||||||
|
if (!popup) {
|
||||||
|
setLoading(false);
|
||||||
|
setError("Pop-up blocked — allow pop-ups for this site and try again.");
|
||||||
|
}
|
||||||
|
// Loading stays on until the popup posts back.
|
||||||
|
} catch (err) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ??
|
||||||
|
(err instanceof Error ? err.message : "Could not start verification"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verified = state?.verified ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding="md" radius="md" withBorder>
|
||||||
|
<Group justify="space-between" align="center" mb={verified ? "md" : "xs"}>
|
||||||
|
<Group gap="sm">
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
{title} identity
|
||||||
|
</Text>
|
||||||
|
{verified ? (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
leftSection={<BadgeCheck size={11} />}
|
||||||
|
>
|
||||||
|
Fayda verified
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
required && (
|
||||||
|
<Badge size="sm" variant="light" color="amber">
|
||||||
|
Verification required
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{pendingReview && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="amber"
|
||||||
|
leftSection={<Clock size={11} />}
|
||||||
|
>
|
||||||
|
Re-verification pending review
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
loading={loading}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={startVerification}
|
||||||
|
>
|
||||||
|
{verified ? "Re-verify with Fayda" : "Verify with Fayda"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!verified && (
|
||||||
|
<Text c="edr-muted" size="xs">
|
||||||
|
{required
|
||||||
|
? "Verify this person with Fayda. Their name, phone and address come from the verification — there is nothing to fill in by hand."
|
||||||
|
: "Optional for a foreign company. If this person holds a Fayda ID, verifying it fills in their details."}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verified && state && (
|
||||||
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
|
<VerifiedField label="Name" value={state.name} />
|
||||||
|
<VerifiedField label="Phone" value={state.phone} />
|
||||||
|
<VerifiedField label="Email" value={state.email} />
|
||||||
|
<VerifiedField label="Address" value={state.address} />
|
||||||
|
<VerifiedField label="Verified" value={formatDate(state.verifiedAt)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert mt="sm" color="red" variant="light" icon={<XCircle size={18} />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerifiedField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
}) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500} c="edr-text">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import {
|
import { Alert, Button, Group, Loader, Stack, TextInput } from "@mantine/core";
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||||
import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react";
|
import { AlertCircle, Download } from "lucide-react";
|
||||||
import { useETradeData } from "@/hooks/useETradeData";
|
import { useETradeData } from "@/hooks/useETradeData";
|
||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
import type { CompanyRegistrationData } from "@edr/types";
|
import type { CompanyRegistrationData } from "@edr/types";
|
||||||
|
|
||||||
|
export type ETradeStatus =
|
||||||
|
| "idle"
|
||||||
|
| "loading"
|
||||||
|
| "verified"
|
||||||
|
| "not-found"
|
||||||
|
| "taken"
|
||||||
|
| "error";
|
||||||
|
|
||||||
interface ETradeInfoProps {
|
interface ETradeInfoProps {
|
||||||
/** Current TIN value (drives button enablement). */
|
/** Current TIN value (drives button enablement). */
|
||||||
tin: string;
|
tin: string;
|
||||||
@@ -22,6 +22,8 @@ interface ETradeInfoProps {
|
|||||||
/** Validation error for the TIN field, if any. */
|
/** Validation error for the TIN field, if any. */
|
||||||
error?: string;
|
error?: string;
|
||||||
onDataLoaded: (data: CompanyRegistrationData) => void;
|
onDataLoaded: (data: CompanyRegistrationData) => void;
|
||||||
|
/** Reports the live lookup status so the parent step can gate on it. */
|
||||||
|
onStatusChange?: (status: ETradeStatus) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidTin = (tin: string) => tin.length === 10;
|
const isValidTin = (tin: string) => tin.length === 10;
|
||||||
@@ -31,12 +33,11 @@ export default function ETradeInfo({
|
|||||||
register,
|
register,
|
||||||
error,
|
error,
|
||||||
onDataLoaded,
|
onDataLoaded,
|
||||||
|
onStatusChange,
|
||||||
}: ETradeInfoProps) {
|
}: ETradeInfoProps) {
|
||||||
const mutation = useETradeData();
|
const mutation = useETradeData();
|
||||||
const isLoading = mutation.isPending;
|
const isLoading = mutation.isPending;
|
||||||
const tinTaken = mutation.data?.tinTaken;
|
const tinTaken = mutation.data?.tinTaken;
|
||||||
const hasData =
|
|
||||||
mutation.data && !mutation.data.tinTaken ? mutation.data : null;
|
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const handleFetch = async () => {
|
||||||
if (!isValidTin(tin)) return;
|
if (!isValidTin(tin)) return;
|
||||||
@@ -47,8 +48,10 @@ export default function ETradeInfo({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Auto-fetch as soon as the TIN reaches its full 10-digit length — only
|
// Auto-fetch as soon as the TIN reaches its full 10-digit length — only
|
||||||
// once per distinct value, so retyping the same TIN doesn't refetch.
|
// once per distinct value, so retyping the same TIN doesn't refetch. Seeded
|
||||||
const lastFetchedTin = useRef<string | null>(null);
|
// from the initial value so a resumed draft with an already-verified TIN
|
||||||
|
// doesn't refire the lookup the moment this mounts.
|
||||||
|
const lastFetchedTin = useRef<string | null>(tin || null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isValidTin(tin) && lastFetchedTin.current !== tin) {
|
if (isValidTin(tin) && lastFetchedTin.current !== tin) {
|
||||||
lastFetchedTin.current = tin;
|
lastFetchedTin.current = tin;
|
||||||
@@ -61,16 +64,36 @@ export default function ETradeInfo({
|
|||||||
mutation.isError && mutation.error
|
mutation.isError && mutation.error
|
||||||
? extractApiError(mutation.error)
|
? extractApiError(mutation.error)
|
||||||
: null;
|
: null;
|
||||||
// A 400 here means eTrade simply has no record for this TIN — not a
|
// A 400 here means eTrade simply has no record for this TIN.
|
||||||
// failure. Soft-pedal it as an FYI, not a red error, so filling in
|
|
||||||
// manually doesn't feel like something went wrong.
|
|
||||||
const notFound = apiError?.statusCode === 400;
|
const notFound = apiError?.statusCode === 400;
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
apiError && !notFound
|
apiError && !notFound
|
||||||
? apiError.message ||
|
? apiError.message ||
|
||||||
"We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below."
|
"We couldn't reach eTrade to fetch your company information. Please try again."
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const status: ETradeStatus = isLoading
|
||||||
|
? "loading"
|
||||||
|
: tinTaken
|
||||||
|
? "taken"
|
||||||
|
: mutation.isSuccess && mutation.data && !mutation.data.tinTaken
|
||||||
|
? "verified"
|
||||||
|
: notFound
|
||||||
|
? "not-found"
|
||||||
|
: errorMessage
|
||||||
|
? "error"
|
||||||
|
: "idle";
|
||||||
|
|
||||||
|
const lastReportedStatus = useRef<ETradeStatus | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastReportedStatus.current === status) return;
|
||||||
|
lastReportedStatus.current = status;
|
||||||
|
onStatusChange?.(status);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const showRetry = isValidTin(tin) && status !== "verified" && status !== "loading";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group align="flex-start" grow>
|
<Group align="flex-start" grow>
|
||||||
@@ -86,7 +109,7 @@ export default function ETradeInfo({
|
|||||||
error={error}
|
error={error}
|
||||||
{...register}
|
{...register}
|
||||||
/>
|
/>
|
||||||
{errorMessage && (
|
{showRetry && (
|
||||||
<Button
|
<Button
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -103,9 +126,13 @@ export default function ETradeInfo({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{notFound && (
|
{notFound && (
|
||||||
<Alert icon={<Info size={16} />} color="gray">
|
<Alert
|
||||||
We couldn't find a matching business record for this TIN — no
|
icon={<AlertCircle size={16} />}
|
||||||
problem, just fill in the details below.
|
color="red"
|
||||||
|
title="No matching business record"
|
||||||
|
>
|
||||||
|
This TIN isn't registered with eTrade. Check the number — we can't
|
||||||
|
continue without a matching business record.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -130,29 +157,6 @@ export default function ETradeInfo({
|
|||||||
mistake.
|
mistake.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasData && (
|
|
||||||
<Alert
|
|
||||||
icon={<CheckCircle2 size={16} />}
|
|
||||||
color="green"
|
|
||||||
title="Company information loaded"
|
|
||||||
>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="sm">
|
|
||||||
<strong>License:</strong> {hasData.licenceNumber}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">
|
|
||||||
<strong>Status:</strong> {hasData.statusDescription}
|
|
||||||
</Text>
|
|
||||||
{hasData.region && (
|
|
||||||
<Text size="sm">
|
|
||||||
<strong>Location:</strong> {hasData.kebele}, {hasData.woreda},{" "}
|
|
||||||
{hasData.zone}, {hasData.region}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ const STEP_META: Record<
|
|||||||
company: {
|
company: {
|
||||||
icon: <Building2 size={20} />,
|
icon: <Building2 size={20} />,
|
||||||
title: "Company Information",
|
title: "Company Information",
|
||||||
description: "Tell us about your company and its registration details.",
|
description:
|
||||||
|
"Confirm your VAT number, verify the owner's identity, and we'll pull your registration from eTrade.",
|
||||||
},
|
},
|
||||||
personnel: {
|
personnel: {
|
||||||
icon: <User size={20} />,
|
icon: <User size={20} />,
|
||||||
@@ -224,8 +225,12 @@ export default function OnboardingWizardDialog({
|
|||||||
const finishMutation = useMutation({
|
const finishMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
// Per-role business licenses (file model, resource=company_profiles).
|
// 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)) {
|
for (const [profileId, files] of Object.entries(licenseFiles)) {
|
||||||
if (files.length > 0) {
|
if (files.length > 0 && liveProfileIds.has(profileId)) {
|
||||||
await companiesService.uploadProfileLicense(profileId, files);
|
await companiesService.uploadProfileLicense(profileId, files);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,6 +429,14 @@ export default function OnboardingWizardDialog({
|
|||||||
onLicenseChange: setLicenseFiles,
|
onLicenseChange: setLicenseFiles,
|
||||||
uploadedDocumentKeys,
|
uploadedDocumentKeys,
|
||||||
onUploadDocuments: handleUploadDocuments,
|
onUploadDocuments: handleUploadDocuments,
|
||||||
|
// Fayda verification state for the owner and the PoA — the general manager
|
||||||
|
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
|
||||||
|
// a foreign one requires a typed passport number for the owner instead.
|
||||||
|
identity: requirementsQuery.data?.identity,
|
||||||
|
onIdentityChange: () => {
|
||||||
|
void profileQuery.refetch();
|
||||||
|
void requirementsQuery.refetch();
|
||||||
|
},
|
||||||
// Surface a failed final submit (license/document upload or complete) inside
|
// Surface a failed final submit (license/document upload or complete) inside
|
||||||
// the form — otherwise the server message (e.g. a 500) would be invisible on
|
// the form — otherwise the server message (e.g. a 500) would be invisible on
|
||||||
// the submit step.
|
// the submit step.
|
||||||
|
|||||||
56
apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx
Normal file
56
apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Center, Loader, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { FaydaCallbackMessage } from "@/services/verifayda.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing page for the portal's eSignet redirect_uri
|
||||||
|
* (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback). Runs inside the
|
||||||
|
* verification popup: relays ?code&state (or ?error) to the window that opened
|
||||||
|
* it via postMessage, then closes itself. The opener performs the completion
|
||||||
|
* call so the single-use session is only consumed once, in one place.
|
||||||
|
*/
|
||||||
|
export default function FaydaCallbackPage() {
|
||||||
|
const [standalone, setStandalone] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const message: FaydaCallbackMessage = {
|
||||||
|
type: "fayda-callback",
|
||||||
|
code: params.get("code") ?? undefined,
|
||||||
|
state: params.get("state") ?? undefined,
|
||||||
|
error: params.get("error") ?? undefined,
|
||||||
|
errorDescription: params.get("error_description") ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.opener && window.opener !== window) {
|
||||||
|
(window.opener as Window).postMessage(message, window.location.origin);
|
||||||
|
window.close();
|
||||||
|
} else {
|
||||||
|
// Opened as a full-page redirect instead of a popup — nothing to relay to.
|
||||||
|
setStandalone(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center h="100vh">
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
{standalone ? (
|
||||||
|
<>
|
||||||
|
<Text fw={600}>Verification window lost its parent page</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Close this tab and start the verification again from the form.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Completing Fayda verification…
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,13 +63,22 @@ type SettingsTab =
|
|||||||
/** A section is "incomplete" when its required fields aren't filled in yet. */
|
/** A section is "incomplete" when its required fields aren't filled in yet. */
|
||||||
function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
|
function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
|
||||||
switch (tabId) {
|
switch (tabId) {
|
||||||
case "company":
|
case "company": {
|
||||||
|
// Identity proof lives here: the owner's Fayda verification for an
|
||||||
|
// Ethiopian company, or the owner's typed passport number for a foreign
|
||||||
|
// one.
|
||||||
|
const identity = profile.identity;
|
||||||
|
const identityIncomplete = identity
|
||||||
|
? (identity.faydaRequired && !identity.owner.verified) ||
|
||||||
|
(identity.passportRequired && !identity.owner.passportNumber)
|
||||||
|
: false;
|
||||||
return (
|
return (
|
||||||
!profile.companyEmail ||
|
!profile.companyEmail ||
|
||||||
!profile.companyPhone ||
|
!profile.companyPhone ||
|
||||||
!profile.companyAddress ||
|
!profile.companyAddress ||
|
||||||
!profile.fanNumber
|
identityIncomplete
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case "contact":
|
case "contact":
|
||||||
return !profile.contactPersonName || !profile.contactPersonPhone;
|
return !profile.contactPersonName || !profile.contactPersonPhone;
|
||||||
case "gm":
|
case "gm":
|
||||||
@@ -354,7 +363,7 @@ export default function SettingsPage() {
|
|||||||
enabled so the customer can still review what they submitted. */}
|
enabled so the customer can still review what they submitted. */}
|
||||||
<Tabs.Panel value="company">
|
<Tabs.Panel value="company">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
<TabCompanyProfile mode="edit" profile={profile} />
|
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
<OperationalServicesCard profile={profile} />
|
<OperationalServicesCard profile={profile} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|||||||
@@ -4,24 +4,21 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Select,
|
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { AlertCircle, ArrowLeft, ArrowRight, Info } from "lucide-react";
|
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
import type { CompanyRegistrationData } from "@edr/types";
|
import type { CompanyRegistrationData } from "@edr/types";
|
||||||
import { ETHIOPIAN_REGIONS } from "@edr/types";
|
|
||||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||||
import { SmartFileInput } from "@edr/ui-common";
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||||
@@ -29,7 +26,9 @@ import { api } from "@/services/api";
|
|||||||
import RoleLicenseStep, {
|
import RoleLicenseStep, {
|
||||||
type RoleLicenseProfile,
|
type RoleLicenseProfile,
|
||||||
} from "@/components/onboarding/RoleLicenseStep";
|
} from "@/components/onboarding/RoleLicenseStep";
|
||||||
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
import ETradeInfo, {
|
||||||
|
type ETradeStatus,
|
||||||
|
} from "@/components/onboarding/ETradeInfo";
|
||||||
import {
|
import {
|
||||||
buildOnboardingSchema,
|
buildOnboardingSchema,
|
||||||
type CompanyStep,
|
type CompanyStep,
|
||||||
@@ -43,8 +42,11 @@ import {
|
|||||||
stepPayload,
|
stepPayload,
|
||||||
toFormValues,
|
toFormValues,
|
||||||
} from "./companyProfileForm/helpers";
|
} from "./companyProfileForm/helpers";
|
||||||
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||||
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
|
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
||||||
|
import StepSection from "./companyProfileForm/StepSection";
|
||||||
|
|
||||||
export default function CompanyProfileForm({
|
export default function CompanyProfileForm({
|
||||||
documentSettingCode,
|
documentSettingCode,
|
||||||
@@ -65,6 +67,8 @@ export default function CompanyProfileForm({
|
|||||||
submitError,
|
submitError,
|
||||||
uploadedDocumentKeys,
|
uploadedDocumentKeys,
|
||||||
onUploadDocuments,
|
onUploadDocuments,
|
||||||
|
identity,
|
||||||
|
onIdentityChange,
|
||||||
}: {
|
}: {
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
documentFiles?: Record<string, File | File[] | null>;
|
documentFiles?: Record<string, File | File[] | null>;
|
||||||
@@ -102,10 +106,17 @@ export default function CompanyProfileForm({
|
|||||||
onUploadDocuments?: () => Promise<
|
onUploadDocuments?: () => Promise<
|
||||||
{ ok: true } | { ok: false; error: string }
|
{ ok: true } | { ok: false; error: string }
|
||||||
>;
|
>;
|
||||||
|
/** Fayda verification state for the owner and the PoA (undefined until loaded). */
|
||||||
|
identity?: CompanyIdentityState;
|
||||||
|
/** Refetch the profile + requirements once a verification lands. */
|
||||||
|
onIdentityChange?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
// Live eTrade lookup status, reported up by ETradeInfo — drives the Continue
|
||||||
|
// gate on the company step.
|
||||||
|
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
|
||||||
|
|
||||||
// Report each step change up so the wizard can persist it for resume.
|
// Report each step change up so the wizard can persist it for resume.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -161,10 +172,14 @@ export default function CompanyProfileForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// A freight forwarder signs on other companies' behalf, so its Power of
|
// A freight forwarder signs on other companies' behalf, so its Power of
|
||||||
// Attorney (details + delegation letter) is mandatory rather than optional.
|
// Attorney (details + DARS delegation paper) is mandatory rather than optional.
|
||||||
const requirePoa = (roleProfiles ?? []).some(
|
const requirePoa = (roleProfiles ?? []).some(
|
||||||
(p) => p.type === "freight_forwarder",
|
(p) => p.type === "freight_forwarder",
|
||||||
);
|
);
|
||||||
|
// Fayda is an Ethiopian national ID: an Ethiopian company verifies its owner
|
||||||
|
// and PoA instead of typing their details, a foreign one keeps the typed
|
||||||
|
// forms (plus a mandatory owner passport number).
|
||||||
|
const verifiedIdentity = identity?.faydaRequired === true;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -175,16 +190,21 @@ export default function CompanyProfileForm({
|
|||||||
setValue,
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(buildOnboardingSchema(requirePoa)),
|
resolver: zodResolver(
|
||||||
|
buildOnboardingSchema(
|
||||||
|
requirePoa,
|
||||||
|
verifiedIdentity,
|
||||||
|
identity?.passportRequired === true,
|
||||||
|
),
|
||||||
|
),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
companyEmail: "",
|
companyEmail: "",
|
||||||
companyPhone: "",
|
companyPhone: "",
|
||||||
companyLocation: "",
|
|
||||||
companyAddress: "",
|
companyAddress: "",
|
||||||
tinNumber: "",
|
tinNumber: "",
|
||||||
vatNumber: "",
|
vatNumber: "",
|
||||||
fanNumber: "",
|
ownerPassportNumber: "",
|
||||||
licenceNumber: "",
|
licenceNumber: "",
|
||||||
statusDescription: "",
|
statusDescription: "",
|
||||||
dateRegistered: "",
|
dateRegistered: "",
|
||||||
@@ -213,14 +233,9 @@ export default function CompanyProfileForm({
|
|||||||
values: rehydrate ? toFormValues(rehydrate) : undefined,
|
values: rehydrate ? toFormValues(rehydrate) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eTrade carries no email, so the company/contact email fields start blank.
|
// The contact person's email still just seeds from the account and stays editable.
|
||||||
// Seed them from the registering user's account email — but only while empty,
|
|
||||||
// so a typed or rehydrated value is never overwritten.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.email) return;
|
if (!user?.email) return;
|
||||||
if (!watch("companyEmail")) {
|
|
||||||
setValue("companyEmail", user.email, { shouldValidate: true });
|
|
||||||
}
|
|
||||||
if (!watch("contactPersonEmail")) {
|
if (!watch("contactPersonEmail")) {
|
||||||
setValue("contactPersonEmail", user.email);
|
setValue("contactPersonEmail", user.email);
|
||||||
}
|
}
|
||||||
@@ -265,19 +280,10 @@ export default function CompanyProfileForm({
|
|||||||
setValue("woreda", data.woreda);
|
setValue("woreda", data.woreda);
|
||||||
setValue("kebele", data.kebele);
|
setValue("kebele", data.kebele);
|
||||||
setValue("houseNo", data.houseNo);
|
setValue("houseNo", data.houseNo);
|
||||||
setValue(
|
|
||||||
"companyPhone",
|
|
||||||
toEthiopianE164(data.regularPhone || data.mobilePhone),
|
|
||||||
);
|
|
||||||
// companyAddress is composed reactively from the address fields below, so
|
// companyAddress is composed reactively from the address fields below, so
|
||||||
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
|
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
|
||||||
// compose it here.
|
// compose it here. companyPhone is derived below (identity → eTrade →
|
||||||
|
// account), not set directly here.
|
||||||
// Pre-fill the company contact phone from eTrade's mobile number.
|
|
||||||
const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone);
|
|
||||||
if (mobile) {
|
|
||||||
setValue("companyPhone", mobile, { shouldValidate: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
setEtradeOwner({
|
setEtradeOwner({
|
||||||
name: data.managerName,
|
name: data.managerName,
|
||||||
@@ -287,6 +293,29 @@ export default function CompanyProfileForm({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// companyEmail/companyPhone are no longer typed — the Fayda-verified owner
|
||||||
|
// is the highest-trust source (that's the whole point of verifying), eTrade's
|
||||||
|
// registered number and the account email/phone are the fallbacks used
|
||||||
|
// before verification happens.
|
||||||
|
useEffect(() => {
|
||||||
|
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [identity?.owner.email, user.email]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(
|
||||||
|
"companyPhone",
|
||||||
|
identity?.owner.phone ??
|
||||||
|
etradeOwner?.phone ??
|
||||||
|
toEthiopianE164(user.phoneNumber) ??
|
||||||
|
"",
|
||||||
|
{ shouldValidate: true },
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber]);
|
||||||
|
|
||||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||||
// them and re-enables editing.
|
// them and re-enables editing.
|
||||||
@@ -302,10 +331,19 @@ export default function CompanyProfileForm({
|
|||||||
// field of its own, so it falls back to the registering user's account name.
|
// field of its own, so it falls back to the registering user's account name.
|
||||||
const companyEmail = watch("companyEmail");
|
const companyEmail = watch("companyEmail");
|
||||||
const companyPhone = watch("companyPhone");
|
const companyPhone = watch("companyPhone");
|
||||||
const gmSourceName = etradeOwner?.name ?? user.name?.en ?? "";
|
// A Fayda-verified owner outranks eTrade's registered owner — it's the
|
||||||
const gmSourceEmail = companyEmail || user.email || "";
|
// higher-trust source, and the whole point of proving identity is to stop
|
||||||
|
// trusting typed/looked-up data for this.
|
||||||
|
const gmSourceName =
|
||||||
|
identity?.owner.name ?? etradeOwner?.name ?? user.name?.en ?? "";
|
||||||
|
const gmSourceEmail =
|
||||||
|
identity?.owner.email ?? (companyEmail || user.email || "");
|
||||||
const gmSourcePhone =
|
const gmSourcePhone =
|
||||||
companyPhone || etradeOwner?.phone || toEthiopianE164(user.phoneNumber) || "";
|
identity?.owner.phone ??
|
||||||
|
companyPhone ??
|
||||||
|
etradeOwner?.phone ??
|
||||||
|
toEthiopianE164(user.phoneNumber) ??
|
||||||
|
"";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gmSameAsOwner) return;
|
if (!gmSameAsOwner) return;
|
||||||
@@ -343,10 +381,9 @@ export default function CompanyProfileForm({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||||
|
|
||||||
// The contact-person step has no location/address of its own, so the linked
|
// The contact-person step has no address of its own, so the linked PoA takes
|
||||||
// PoA takes the company's — location as entered, address as composed from the
|
// the company's composed address. poaLocation (the city) stays typed on the
|
||||||
// company's address fields. Both stay mirrored while the link is checked.
|
// PoA step — the company step no longer has a location field to mirror.
|
||||||
const companyLocation = watch("companyLocation");
|
|
||||||
const companyAddress = watch("companyAddress");
|
const companyAddress = watch("companyAddress");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -354,7 +391,6 @@ export default function CompanyProfileForm({
|
|||||||
setValue("poaName", contactName ?? "");
|
setValue("poaName", contactName ?? "");
|
||||||
setValue("poaEmail", contactEmail ?? "");
|
setValue("poaEmail", contactEmail ?? "");
|
||||||
setValue("poaPhone", contactPhone ?? "");
|
setValue("poaPhone", contactPhone ?? "");
|
||||||
setValue("poaLocation", companyLocation ?? "");
|
|
||||||
setValue("poaAddress", companyAddress ?? "");
|
setValue("poaAddress", companyAddress ?? "");
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
@@ -362,7 +398,6 @@ export default function CompanyProfileForm({
|
|||||||
contactName,
|
contactName,
|
||||||
contactEmail,
|
contactEmail,
|
||||||
contactPhone,
|
contactPhone,
|
||||||
companyLocation,
|
|
||||||
companyAddress,
|
companyAddress,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -387,10 +422,11 @@ export default function CompanyProfileForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// The delegation letter is seeded into the same nationality document set as
|
// The DARS delegation paper ships in the same nationality document set as the
|
||||||
// the rest, but belongs on the PoA step next to the details it evidences —
|
// rest (the API guarantees it is there), but belongs on the PoA step next to
|
||||||
// so it's split out here and the Documents step renders the remainder. Both
|
// the details it evidences — so it's split out here and the Documents step
|
||||||
// halves share `documentFiles`, so the existing bulk upload still carries it.
|
// renders the remainder. Both halves share `documentFiles`, so the existing
|
||||||
|
// bulk upload still carries it.
|
||||||
const poaDocumentField = uploadSetting?.fields?.find(
|
const poaDocumentField = uploadSetting?.fields?.find(
|
||||||
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
|
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
|
||||||
);
|
);
|
||||||
@@ -398,11 +434,11 @@ export default function CompanyProfileForm({
|
|||||||
() =>
|
() =>
|
||||||
uploadSetting
|
uploadSetting
|
||||||
? {
|
? {
|
||||||
...uploadSetting,
|
...uploadSetting,
|
||||||
fields: uploadSetting.fields.filter(
|
fields: uploadSetting.fields.filter(
|
||||||
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
|
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
[uploadSetting],
|
[uploadSetting],
|
||||||
);
|
);
|
||||||
@@ -495,6 +531,9 @@ export default function CompanyProfileForm({
|
|||||||
"renewedTo",
|
"renewedTo",
|
||||||
]);
|
]);
|
||||||
const hasRegistrationDetails = registration.some((v) => v && v.trim());
|
const hasRegistrationDetails = registration.some((v) => v && v.trim());
|
||||||
|
// A previously-saved (rehydrated) TIN counts as verified without a refetch —
|
||||||
|
// the registration fields being populated at all is proof it passed before.
|
||||||
|
const tinVerified = tinStatus === "verified" || hasRegistrationDetails;
|
||||||
|
|
||||||
// Single source of truth for step sequence — navigation, labels and the
|
// 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.
|
// progress bar all derive from this so adding/removing a step is one edit.
|
||||||
@@ -507,13 +546,13 @@ export default function CompanyProfileForm({
|
|||||||
];
|
];
|
||||||
const currentIdx = stepOrder.indexOf(step);
|
const currentIdx = stepOrder.indexOf(step);
|
||||||
|
|
||||||
// The delegation letter is what proves the representative was actually
|
// The DARS delegation paper is what proves the representative was actually
|
||||||
// delegated, so it's required the moment a PoA exists — and unconditionally
|
// delegated, so it's required the moment a PoA exists — and unconditionally
|
||||||
// for a freight forwarder, whose PoA itself is mandatory. Skipped entirely
|
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
|
||||||
// when the document set predates the field (seeder not yet re-run).
|
// the same rule on save, so skipping it here only costs the customer a
|
||||||
|
// round-trip.
|
||||||
const poaProvided = hasPoaDetails(watch());
|
const poaProvided = hasPoaDetails(watch());
|
||||||
const delegationRequired =
|
const delegationRequired = requirePoa || poaProvided;
|
||||||
Boolean(poaDocumentField) && (requirePoa || poaProvided);
|
|
||||||
const delegationPresent =
|
const delegationPresent =
|
||||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||||
(() => {
|
(() => {
|
||||||
@@ -548,7 +587,10 @@ export default function CompanyProfileForm({
|
|||||||
if (step === "documents") {
|
if (step === "documents") {
|
||||||
const docErrors = validateRequiredDocuments();
|
const docErrors = validateRequiredDocuments();
|
||||||
const licenseErrors = validateLicenses();
|
const licenseErrors = validateLicenses();
|
||||||
if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) {
|
if (
|
||||||
|
Object.keys(docErrors).length > 0 ||
|
||||||
|
Object.keys(licenseErrors).length > 0
|
||||||
|
) {
|
||||||
setDocumentFieldErrors(docErrors);
|
setDocumentFieldErrors(docErrors);
|
||||||
setLicenseFieldErrors(licenseErrors);
|
setLicenseFieldErrors(licenseErrors);
|
||||||
setSaveError("Please upload all required documents before continuing.");
|
setSaveError("Please upload all required documents before continuing.");
|
||||||
@@ -572,15 +614,50 @@ export default function CompanyProfileForm({
|
|||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 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.
|
||||||
|
if (step === "company" && tinStatus === "taken") {
|
||||||
|
setSaveError(
|
||||||
|
"This TIN is already registered to another company account.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "company" && !tinVerified) {
|
||||||
|
setSaveError(
|
||||||
|
"We need to confirm your TIN with eTrade before continuing.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fayda verification is proved outside the form state, so it gates here
|
||||||
|
// rather than through zod. The passport number is a plain typed field —
|
||||||
|
// buildOnboardingSchema already requires it when passportRequired, so
|
||||||
|
// saveCurrentStep()'s trigger() below catches that; checking the stale
|
||||||
|
// server-side identity.owner.passportNumber here would block a value the
|
||||||
|
// user just typed but hasn't saved yet.
|
||||||
|
if (step === "company" && identity?.faydaRequired && !identity.owner.verified) {
|
||||||
|
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
step === "poa" &&
|
||||||
|
verifiedIdentity &&
|
||||||
|
requirePoa &&
|
||||||
|
!identity?.poa.verified
|
||||||
|
) {
|
||||||
|
setSaveError(
|
||||||
|
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The PoA step also gates on a file, which lives outside the form state.
|
// The PoA step also gates on a file, which lives outside the form state.
|
||||||
if (step === "poa" && delegationRequired && !delegationPresent) {
|
if (step === "poa" && delegationRequired && !delegationPresent) {
|
||||||
setDocumentFieldErrors({
|
setDocumentFieldErrors({
|
||||||
[POA_DELEGATION_FILE_KEY]: "Delegation letter is required",
|
[POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required",
|
||||||
});
|
});
|
||||||
setSaveError(
|
setSaveError(
|
||||||
requirePoa
|
requirePoa
|
||||||
? "Freight forwarders must provide Power of Attorney details and a delegation letter."
|
? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper."
|
||||||
: "Upload the delegation letter for the Power of Attorney you entered, or clear the PoA details to skip.",
|
: "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.",
|
||||||
);
|
);
|
||||||
// Fall through to validate the text fields too, so every problem shows at once.
|
// Fall through to validate the text fields too, so every problem shows at once.
|
||||||
await trigger(stepFields.poa);
|
await trigger(stepFields.poa);
|
||||||
@@ -604,172 +681,97 @@ export default function CompanyProfileForm({
|
|||||||
<form onSubmit={(e) => e.preventDefault()}>
|
<form onSubmit={(e) => e.preventDefault()}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{step === "company" && (
|
{step === "company" && (
|
||||||
<Stack gap="sm">
|
<Stack gap="xl">
|
||||||
<ETradeInfo
|
<StepSection
|
||||||
tin={watch("tinNumber")}
|
index={1}
|
||||||
register={register("tinNumber")}
|
title="VAT number"
|
||||||
error={errors.tinNumber?.message}
|
status={
|
||||||
onDataLoaded={handleETradeDataLoaded}
|
watch("vatNumber")?.length === 10 && !errors.vatNumber
|
||||||
/>
|
? "done"
|
||||||
|
: "todo"
|
||||||
<TextInput
|
}
|
||||||
label="Company Name"
|
>
|
||||||
placeholder="Global Logistics Ltd"
|
|
||||||
error={errors.companyName?.message}
|
|
||||||
{...register("companyName")}
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="Company Email"
|
|
||||||
type="email"
|
|
||||||
placeholder="ops@company.com"
|
|
||||||
error={errors.companyEmail?.message}
|
|
||||||
{...register("companyEmail")}
|
|
||||||
/>
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="companyPhone"
|
|
||||||
label="Company Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
<TextInput
|
|
||||||
label="Location"
|
|
||||||
placeholder="Addis Ababa, Ethiopia"
|
|
||||||
error={errors.companyLocation?.message}
|
|
||||||
{...register("companyLocation")}
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="VAT Number"
|
label="VAT Number"
|
||||||
placeholder="VAT-12345"
|
placeholder="0012345678"
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
error={errors.vatNumber?.message}
|
error={errors.vatNumber?.message}
|
||||||
{...register("vatNumber")}
|
{...register("vatNumber")}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
</StepSection>
|
||||||
label={
|
|
||||||
<Group gap={6} align="center" wrap="nowrap">
|
|
||||||
<span>FAN Number (16 digits)</span>
|
|
||||||
<Tooltip
|
|
||||||
label="The FAN must belong to the person with power of attorney. If the company has no power of attorney, use the general manager's FAN."
|
|
||||||
multiline
|
|
||||||
w={260}
|
|
||||||
withArrow
|
|
||||||
position="top-start"
|
|
||||||
>
|
|
||||||
<Info
|
|
||||||
size={14}
|
|
||||||
color="var(--mantine-color-gray-6)"
|
|
||||||
className="cursor-help"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
placeholder="1234567890123456"
|
|
||||||
maxLength={16}
|
|
||||||
error={errors.fanNumber?.message}
|
|
||||||
{...register("fanNumber")}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{hasRegistrationDetails && (
|
<StepSection
|
||||||
<>
|
index={2}
|
||||||
<Divider my="sm" />
|
title="Owner identity"
|
||||||
<Group gap="xs" align="center">
|
subtitle={
|
||||||
<Text fw={600} size="sm" c="edr-text">
|
verifiedIdentity
|
||||||
Registration Details
|
? "Verify the company owner with Fayda — their name, phone, email and address come from the verification."
|
||||||
</Text>
|
: "Provide the company owner's passport number."
|
||||||
<Text size="xs" c="dimmed">
|
}
|
||||||
from eTrade · read-only
|
status={
|
||||||
</Text>
|
verifiedIdentity
|
||||||
</Group>
|
? identity?.owner.verified
|
||||||
<SimpleGrid cols={2} spacing="sm">
|
? "done"
|
||||||
<ReadOnlyField
|
: identity?.faydaRequired
|
||||||
label="License Number"
|
? "blocked"
|
||||||
value={watch("licenceNumber")}
|
: "todo"
|
||||||
|
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
|
||||||
|
? "done"
|
||||||
|
: identity?.passportRequired
|
||||||
|
? "blocked"
|
||||||
|
: "todo"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{identity && (
|
||||||
|
<>
|
||||||
|
<FaydaVerifyPanel
|
||||||
|
subject="owner"
|
||||||
|
title="Company owner"
|
||||||
|
state={identity.owner}
|
||||||
|
required={identity.faydaRequired}
|
||||||
|
onVerified={() => onIdentityChange?.()}
|
||||||
/>
|
/>
|
||||||
<ReadOnlyField
|
{identity.passportRequired && (
|
||||||
label="Status"
|
<TextInput
|
||||||
value={watch("statusDescription")}
|
label="Owner Passport Number"
|
||||||
/>
|
placeholder="P1234567"
|
||||||
<ReadOnlyField
|
error={errors.ownerPassportNumber?.message}
|
||||||
label="Date Registered"
|
{...register("ownerPassportNumber")}
|
||||||
value={watch("dateRegistered")}
|
/>
|
||||||
/>
|
)}
|
||||||
<ReadOnlyField
|
</>
|
||||||
label="Renewal Date"
|
)}
|
||||||
value={watch("renewalDate")}
|
</StepSection>
|
||||||
/>
|
|
||||||
<ReadOnlyField
|
|
||||||
label="Renewed From"
|
|
||||||
value={watch("renewedFrom")}
|
|
||||||
/>
|
|
||||||
<ReadOnlyField
|
|
||||||
label="Renewed To"
|
|
||||||
value={watch("renewedTo")}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider my="sm" />
|
<StepSection
|
||||||
<Text fw={600} size="sm" c="edr-text">
|
index={3}
|
||||||
Address Information
|
title="Company TIN"
|
||||||
</Text>
|
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
||||||
<SimpleGrid cols={2} spacing="md">
|
status={
|
||||||
<Controller
|
tinVerified
|
||||||
name="region"
|
? "done"
|
||||||
control={control}
|
: tinStatus === "taken"
|
||||||
render={({ field }) => (
|
? "blocked"
|
||||||
<Select
|
: "todo"
|
||||||
label="Region"
|
}
|
||||||
placeholder="Select region"
|
>
|
||||||
required
|
<ETradeInfo
|
||||||
searchable
|
tin={watch("tinNumber")}
|
||||||
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
|
register={register("tinNumber")}
|
||||||
error={errors.region?.message}
|
error={errors.tinNumber?.message}
|
||||||
// "" (unresolved eTrade value, or a legacy row whose
|
onDataLoaded={handleETradeDataLoaded}
|
||||||
// region isn't in the list) must read as "nothing picked".
|
onStatusChange={setTinStatus}
|
||||||
value={field.value || null}
|
|
||||||
onChange={(v) => field.onChange(v ?? "")}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<TextInput
|
{tinVerified && (
|
||||||
label="Zone"
|
<ETradeCompanyCard
|
||||||
placeholder="EASTERN TIGRAY"
|
tin={watch("tinNumber")}
|
||||||
required
|
register={register}
|
||||||
error={errors.zone?.message}
|
watch={watch}
|
||||||
{...register("zone")}
|
errors={errors}
|
||||||
/>
|
control={control}
|
||||||
</SimpleGrid>
|
/>
|
||||||
<SimpleGrid cols={2} spacing="md">
|
)}
|
||||||
<TextInput
|
</StepSection>
|
||||||
label="Woreda"
|
|
||||||
placeholder="EROB"
|
|
||||||
required
|
|
||||||
error={errors.woreda?.message}
|
|
||||||
{...register("woreda")}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Kebele"
|
|
||||||
placeholder="ARAS"
|
|
||||||
required
|
|
||||||
error={errors.kebele?.message}
|
|
||||||
{...register("kebele")}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="House No"
|
|
||||||
placeholder="House Number"
|
|
||||||
required
|
|
||||||
error={errors.houseNo?.message}
|
|
||||||
{...register("houseNo")}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -778,14 +780,24 @@ export default function CompanyProfileForm({
|
|||||||
<Text fw={600} size="sm" c="edr-text">
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
General Manager
|
General Manager
|
||||||
</Text>
|
</Text>
|
||||||
|
{/* GM is a plain typed role, not the person the Fayda
|
||||||
|
verification proves — the owner is (see the Company step).
|
||||||
|
They're very often the same human, which "same as owner" is
|
||||||
|
for once the owner has verified. */}
|
||||||
<LinkCheckboxCard
|
<LinkCheckboxCard
|
||||||
checked={gmSameAsOwner}
|
checked={gmSameAsOwner}
|
||||||
onToggle={toggleGmSameAsOwner}
|
onToggle={toggleGmSameAsOwner}
|
||||||
title="Same as business owner"
|
title={
|
||||||
|
identity?.owner.verified
|
||||||
|
? "Same as verified owner"
|
||||||
|
: "Same as business owner"
|
||||||
|
}
|
||||||
description={
|
description={
|
||||||
etradeOwner
|
identity?.owner.verified
|
||||||
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
|
? "Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
|
||||||
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
|
: etradeOwner
|
||||||
|
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
|
||||||
|
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -861,10 +873,19 @@ export default function CompanyProfileForm({
|
|||||||
<>
|
<>
|
||||||
<Text size="sm" c="edr-muted">
|
<Text size="sm" c="edr-muted">
|
||||||
{requirePoa
|
{requirePoa
|
||||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and a delegation letter are required."
|
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
|
||||||
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation letter authorising them."}
|
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
|
||||||
</Text>
|
</Text>
|
||||||
{watch("contactPersonName") && (
|
{identity && (
|
||||||
|
<FaydaVerifyPanel
|
||||||
|
subject="poa"
|
||||||
|
title="Power of Attorney"
|
||||||
|
state={identity.poa}
|
||||||
|
required={identity.faydaRequired}
|
||||||
|
onVerified={() => onIdentityChange?.()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!verifiedIdentity && watch("contactPersonName") && (
|
||||||
<LinkCheckboxCard
|
<LinkCheckboxCard
|
||||||
checked={poaSameAsContact}
|
checked={poaSameAsContact}
|
||||||
onToggle={togglePoaSameAsContact}
|
onToggle={togglePoaSameAsContact}
|
||||||
@@ -872,40 +893,54 @@ export default function CompanyProfileForm({
|
|||||||
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
|
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TextInput
|
{!verifiedIdentity && (
|
||||||
label="PoA Name"
|
<>
|
||||||
placeholder="Authorized Representative Name"
|
<TextInput
|
||||||
error={errors.poaName?.message}
|
label="PoA Name"
|
||||||
{...register("poaName")}
|
placeholder="Authorized Representative Name"
|
||||||
/>
|
error={errors.poaName?.message}
|
||||||
<SimpleGrid cols={2} spacing="md">
|
{...register("poaName")}
|
||||||
<TextInput
|
/>
|
||||||
label="PoA Email"
|
<SimpleGrid cols={2} spacing="md">
|
||||||
type="email"
|
<TextInput
|
||||||
placeholder="poa@company.com"
|
label="PoA Email"
|
||||||
error={errors.poaEmail?.message}
|
type="email"
|
||||||
{...register("poaEmail")}
|
placeholder="poa@company.com"
|
||||||
/>
|
error={errors.poaEmail?.message}
|
||||||
<ControlledPhoneField
|
{...register("poaEmail")}
|
||||||
control={control}
|
/>
|
||||||
name="poaPhone"
|
<ControlledPhoneField
|
||||||
label="PoA Phone"
|
control={control}
|
||||||
/>
|
name="poaPhone"
|
||||||
</SimpleGrid>
|
label="PoA Phone"
|
||||||
<SimpleGrid cols={2} spacing="md">
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="PoA Location"
|
||||||
|
placeholder="City, Country"
|
||||||
|
error={errors.poaLocation?.message}
|
||||||
|
{...register("poaLocation")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="PoA Address"
|
||||||
|
placeholder="Full Address"
|
||||||
|
error={errors.poaAddress?.message}
|
||||||
|
{...register("poaAddress")}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* The city is the one field the Fayda address claim does not
|
||||||
|
reliably decompose into, so it stays typed either way. */}
|
||||||
|
{verifiedIdentity && (
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Location"
|
label="PoA Location"
|
||||||
placeholder="City, Country"
|
placeholder="City, Country"
|
||||||
error={errors.poaLocation?.message}
|
error={errors.poaLocation?.message}
|
||||||
{...register("poaLocation")}
|
{...register("poaLocation")}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
)}
|
||||||
label="PoA Address"
|
|
||||||
placeholder="Full Address"
|
|
||||||
error={errors.poaAddress?.message}
|
|
||||||
{...register("poaAddress")}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{poaDocumentSetting && (
|
{poaDocumentSetting && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { Badge, Card, Group, Select, SimpleGrid, Text, TextInput } from "@mantine/core";
|
||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
import type {
|
||||||
|
Control,
|
||||||
|
FieldErrors,
|
||||||
|
UseFormRegister,
|
||||||
|
UseFormWatch,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import { ETHIOPIAN_REGIONS } from "@edr/types";
|
||||||
|
|
||||||
|
import type { FormData } from "./schema";
|
||||||
|
import { ReadOnlyField } from "./ReadOnlyField";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One field of the verified-registration card: locked read-only once eTrade
|
||||||
|
* supplied a value, but falls back to an editable input when eTrade left it
|
||||||
|
* blank — otherwise a gap in eTrade's own data would leave the field
|
||||||
|
* permanently empty and the user stuck (zod requires all of these).
|
||||||
|
*/
|
||||||
|
function LockedField({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
register,
|
||||||
|
watch,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
name: keyof FormData;
|
||||||
|
register: UseFormRegister<FormData>;
|
||||||
|
watch: UseFormWatch<FormData>;
|
||||||
|
errors: FieldErrors<FormData>;
|
||||||
|
}) {
|
||||||
|
const value = watch(name) as string | undefined;
|
||||||
|
if (value && value.trim()) {
|
||||||
|
return <ReadOnlyField label={label} value={value} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label={label}
|
||||||
|
description="eTrade didn't provide this — please confirm"
|
||||||
|
error={errors[name]?.message as string | undefined}
|
||||||
|
{...register(name)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ETradeCompanyCard({
|
||||||
|
tin,
|
||||||
|
register,
|
||||||
|
watch,
|
||||||
|
errors,
|
||||||
|
control,
|
||||||
|
}: {
|
||||||
|
tin: string;
|
||||||
|
register: UseFormRegister<FormData>;
|
||||||
|
watch: UseFormWatch<FormData>;
|
||||||
|
errors: FieldErrors<FormData>;
|
||||||
|
control: Control<FormData>;
|
||||||
|
}) {
|
||||||
|
const companyName = watch("companyName");
|
||||||
|
const region = watch("region");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding="md" radius="md" withBorder>
|
||||||
|
<Group justify="space-between" align="center" mb="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
{companyName && companyName.trim() ? companyName : "Company record"}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
leftSection={<CheckCircle2 size={11} />}
|
||||||
|
>
|
||||||
|
Verified with eTrade
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<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 && region.trim() ? (
|
||||||
|
<ReadOnlyField label="Region" value={region} />
|
||||||
|
) : (
|
||||||
|
<Controller
|
||||||
|
name="region"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
label="Region"
|
||||||
|
description="eTrade didn't provide this — please confirm"
|
||||||
|
placeholder="Select region"
|
||||||
|
searchable
|
||||||
|
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
|
||||||
|
error={errors.region?.message}
|
||||||
|
value={field.value || null}
|
||||||
|
onChange={(v) => field.onChange(v ?? "")}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<LockedField
|
||||||
|
label="Zone"
|
||||||
|
name="zone"
|
||||||
|
register={register}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
<LockedField
|
||||||
|
label="Woreda"
|
||||||
|
name="woreda"
|
||||||
|
register={register}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
<LockedField
|
||||||
|
label="Kebele"
|
||||||
|
name="kebele"
|
||||||
|
register={register}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
<LockedField
|
||||||
|
label="House No"
|
||||||
|
name="houseNo"
|
||||||
|
register={register}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Badge, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type SectionStatus = "todo" | "done" | "blocked";
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<
|
||||||
|
SectionStatus,
|
||||||
|
{ color: string; label: string; icon?: ReactNode } | null
|
||||||
|
> = {
|
||||||
|
todo: null,
|
||||||
|
done: { color: "green", label: "Done", icon: <Check size={11} /> },
|
||||||
|
blocked: { color: "red", label: "Action needed", icon: <X size={11} /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One numbered section of the Company Information step — a title, an
|
||||||
|
* optional subtitle, a status badge, and its content. Purely presentational;
|
||||||
|
* the parent decides each section's status.
|
||||||
|
*/
|
||||||
|
export default function StepSection({
|
||||||
|
index,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
status,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
status: SectionStatus;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const badge = STATUS_BADGE[status];
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Group gap="sm" align="center">
|
||||||
|
<Text
|
||||||
|
fw={700}
|
||||||
|
size="sm"
|
||||||
|
c={status === "done" ? "edr-green" : "edr-text"}
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
border: "1.5px solid var(--mantine-color-edr-border-0)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index}
|
||||||
|
</Text>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{badge && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={badge.color}
|
||||||
|
leftSection={badge.icon}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<div style={{ paddingLeft: 34 }}>
|
||||||
|
<Stack gap="sm">{children}</Stack>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,12 +25,11 @@ export function buildPayload(
|
|||||||
companyName: data.companyName,
|
companyName: data.companyName,
|
||||||
companyEmail: data.companyEmail,
|
companyEmail: data.companyEmail,
|
||||||
companyPhone: data.companyPhone,
|
companyPhone: data.companyPhone,
|
||||||
companyLocation: data.companyLocation,
|
|
||||||
companyAddress: data.companyAddress,
|
companyAddress: data.companyAddress,
|
||||||
tin: data.tinNumber,
|
tin: data.tinNumber,
|
||||||
vatNumber: data.vatNumber,
|
vatNumber: data.vatNumber,
|
||||||
fanNumber: data.fanNumber,
|
|
||||||
attributes: {
|
attributes: {
|
||||||
|
ownerPassportNumber: data.ownerPassportNumber || undefined,
|
||||||
contactPersonName: data.contactPersonName,
|
contactPersonName: data.contactPersonName,
|
||||||
contactPersonPosition: data.contactPersonPosition || undefined,
|
contactPersonPosition: data.contactPersonPosition || undefined,
|
||||||
contactPersonEmail: data.contactPersonEmail || undefined,
|
contactPersonEmail: data.contactPersonEmail || undefined,
|
||||||
@@ -58,11 +57,10 @@ export function stepPayload(
|
|||||||
companyName: d.companyName,
|
companyName: d.companyName,
|
||||||
companyEmail: d.companyEmail,
|
companyEmail: d.companyEmail,
|
||||||
companyPhone: d.companyPhone,
|
companyPhone: d.companyPhone,
|
||||||
companyLocation: d.companyLocation,
|
|
||||||
companyAddress: d.companyAddress,
|
companyAddress: d.companyAddress,
|
||||||
tin: d.tinNumber,
|
tin: d.tinNumber,
|
||||||
vatNumber: d.vatNumber,
|
vatNumber: d.vatNumber,
|
||||||
fanNumber: d.fanNumber,
|
ownerPassportNumber: d.ownerPassportNumber || undefined,
|
||||||
licenceNumber: d.licenceNumber,
|
licenceNumber: d.licenceNumber,
|
||||||
statusDescription: d.statusDescription,
|
statusDescription: d.statusDescription,
|
||||||
dateRegistered: d.dateRegistered,
|
dateRegistered: d.dateRegistered,
|
||||||
@@ -110,11 +108,10 @@ export function toFormValues(p: ProfileResponse): FormData {
|
|||||||
companyName: p.companyName ?? "",
|
companyName: p.companyName ?? "",
|
||||||
companyEmail: p.companyEmail ?? "",
|
companyEmail: p.companyEmail ?? "",
|
||||||
companyPhone: p.companyPhone ?? "",
|
companyPhone: p.companyPhone ?? "",
|
||||||
companyLocation: p.companyLocation ?? "",
|
|
||||||
companyAddress: p.companyAddress ?? "",
|
companyAddress: p.companyAddress ?? "",
|
||||||
tinNumber: tin,
|
tinNumber: tin,
|
||||||
vatNumber: p.vatNumber ?? "",
|
vatNumber: p.vatNumber ?? "",
|
||||||
fanNumber: p.fanNumber ?? "",
|
ownerPassportNumber: p.identity?.owner.passportNumber ?? "",
|
||||||
licenceNumber: p.licenceNumber ?? "",
|
licenceNumber: p.licenceNumber ?? "",
|
||||||
statusDescription: p.statusDescription ?? "",
|
statusDescription: p.statusDescription ?? "",
|
||||||
dateRegistered: p.dateRegistered ?? "",
|
dateRegistered: p.dateRegistered ?? "",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export const onboardingSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.min(1, "Company phone is required")
|
.min(1, "Company phone is required")
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
.refine(isValidPhone, "Enter a valid phone number"),
|
||||||
companyLocation: z.string().min(1, "Location is required"),
|
|
||||||
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||||
// standalone input — the granular fields live in the registration section.
|
// standalone input — the granular fields live in the registration section.
|
||||||
companyAddress: z.string().optional(),
|
companyAddress: z.string().optional(),
|
||||||
@@ -27,7 +26,10 @@ export const onboardingSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.min(1, "VAT number is required")
|
.min(1, "VAT number is required")
|
||||||
.length(10, "VAT number must be exactly 10 digits"),
|
.length(10, "VAT number must be exactly 10 digits"),
|
||||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
// The owner's passport number — the foreign-company identity credential
|
||||||
|
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
|
||||||
|
// enforced in buildOnboardingSchema since that depends on `nationality`.
|
||||||
|
ownerPassportNumber: z.string().optional(),
|
||||||
licenceNumber: z.string().optional(),
|
licenceNumber: z.string().optional(),
|
||||||
statusDescription: z.string().optional(),
|
statusDescription: z.string().optional(),
|
||||||
dateRegistered: z.string().optional(),
|
dateRegistered: z.string().optional(),
|
||||||
@@ -109,14 +111,35 @@ export const hasPoaDetails = (d: Partial<FormData>) =>
|
|||||||
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
|
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
|
||||||
* since files live outside the form state).
|
* since files live outside the form state).
|
||||||
*/
|
*/
|
||||||
export function buildOnboardingSchema(requirePoa: boolean) {
|
export function buildOnboardingSchema(
|
||||||
if (!requirePoa) return onboardingSchema;
|
requirePoa: boolean,
|
||||||
|
/**
|
||||||
|
* True when the PoA's identity fields come from a Fayda verification rather
|
||||||
|
* than the form (Ethiopian companies). Requiring them here would fail
|
||||||
|
* validation against inputs the step no longer renders — the verification
|
||||||
|
* itself is what the step gates on instead.
|
||||||
|
*/
|
||||||
|
faydaOwnedPoa = false,
|
||||||
|
/** True for a foreign company: the owner's passport number is mandatory. */
|
||||||
|
passportRequired = false,
|
||||||
|
) {
|
||||||
|
const poaRequired = requirePoa && !faydaOwnedPoa;
|
||||||
|
if (!poaRequired && !passportRequired) return onboardingSchema;
|
||||||
return onboardingSchema.superRefine((d, ctx) => {
|
return onboardingSchema.superRefine((d, ctx) => {
|
||||||
const required: [keyof FormData, string][] = [
|
const required: [keyof FormData, string][] = [];
|
||||||
["poaName", "PoA name is required for freight forwarders"],
|
if (poaRequired) {
|
||||||
["poaEmail", "PoA email is required for freight forwarders"],
|
required.push(
|
||||||
["poaPhone", "PoA phone is required for freight forwarders"],
|
["poaName", "PoA name is required for freight forwarders"],
|
||||||
];
|
["poaEmail", "PoA email is required for freight forwarders"],
|
||||||
|
["poaPhone", "PoA phone is required for freight forwarders"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (passportRequired) {
|
||||||
|
required.push([
|
||||||
|
"ownerPassportNumber",
|
||||||
|
"The owner's passport number is required",
|
||||||
|
]);
|
||||||
|
}
|
||||||
for (const [path, message] of required) {
|
for (const [path, message] of required) {
|
||||||
if (!d[path]?.trim()) {
|
if (!d[path]?.trim()) {
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
|
||||||
@@ -130,11 +153,10 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
"companyName",
|
"companyName",
|
||||||
"companyEmail",
|
"companyEmail",
|
||||||
"companyPhone",
|
"companyPhone",
|
||||||
"companyLocation",
|
|
||||||
"companyAddress",
|
"companyAddress",
|
||||||
"tinNumber",
|
"tinNumber",
|
||||||
"vatNumber",
|
"vatNumber",
|
||||||
"fanNumber",
|
"ownerPassportNumber",
|
||||||
"licenceNumber",
|
"licenceNumber",
|
||||||
"statusDescription",
|
"statusDescription",
|
||||||
"dateRegistered",
|
"dateRegistered",
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type {
|
import type {
|
||||||
CompanyProfileInput,
|
CompanyProfileInput,
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
} from "@/services/companies.service";
|
} from "@/services/companies.service";
|
||||||
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
|
import { extractApiError } from "@/utils/result";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Grid,
|
|
||||||
Group,
|
Group,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -18,10 +21,15 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
|
||||||
import OnboardingRoleSelect from "./OnboardingRoleSelect";
|
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({
|
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
@@ -31,33 +39,72 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
|||||||
.min(1, "Company phone is required")
|
.min(1, "Company phone is required")
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
.refine(isValidPhone, "Enter a valid phone number"),
|
||||||
companyLocation: z.string().min(1, "Location is required"),
|
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"),
|
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
|
||||||
vatNumber: z
|
vatNumber: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.max(20, "VAT number is too long")
|
.max(20, "VAT number is too long")
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.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>;
|
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 {
|
interface TabCompanyProfileProps {
|
||||||
profile?: ProfileResponse;
|
profile?: ProfileResponse;
|
||||||
mode?: "edit" | "create";
|
mode?: "edit" | "create";
|
||||||
onCreateSuccess?: () => void;
|
onCreateSuccess?: () => void;
|
||||||
|
user?: AuthUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TabCompanyProfile({
|
export default function TabCompanyProfile({
|
||||||
profile,
|
profile,
|
||||||
mode = "edit",
|
mode = "edit",
|
||||||
onCreateSuccess,
|
onCreateSuccess,
|
||||||
|
user,
|
||||||
}: TabCompanyProfileProps) {
|
}: TabCompanyProfileProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isCreate = mode === "create";
|
const isCreate = mode === "create";
|
||||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||||
|
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
|
||||||
|
|
||||||
const defaultValues = useMemo((): CompanyProfileFormData => {
|
const defaultValues = useMemo((): CompanyProfileFormData => {
|
||||||
if (profile) {
|
if (profile) {
|
||||||
@@ -68,8 +115,19 @@ export default function TabCompanyProfile({
|
|||||||
companyLocation: profile.companyLocation,
|
companyLocation: profile.companyLocation,
|
||||||
companyAddress: profile.companyAddress ?? "",
|
companyAddress: profile.companyAddress ?? "",
|
||||||
tinNumber: profile.tinNumber,
|
tinNumber: profile.tinNumber,
|
||||||
fanNumber: profile.fanNumber ?? "",
|
|
||||||
vatNumber: profile.vatNumber ?? "",
|
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 {
|
return {
|
||||||
@@ -79,8 +137,19 @@ export default function TabCompanyProfile({
|
|||||||
companyLocation: "",
|
companyLocation: "",
|
||||||
companyAddress: "",
|
companyAddress: "",
|
||||||
tinNumber: "",
|
tinNumber: "",
|
||||||
fanNumber: "",
|
|
||||||
vatNumber: "",
|
vatNumber: "",
|
||||||
|
ownerPassportNumber: "",
|
||||||
|
licenceNumber: "",
|
||||||
|
statusDescription: "",
|
||||||
|
dateRegistered: "",
|
||||||
|
renewedFrom: "",
|
||||||
|
renewalDate: "",
|
||||||
|
renewedTo: "",
|
||||||
|
region: "",
|
||||||
|
zone: "",
|
||||||
|
woreda: "",
|
||||||
|
kebele: "",
|
||||||
|
houseNo: "",
|
||||||
};
|
};
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
@@ -89,23 +158,112 @@ export default function TabCompanyProfile({
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
formState: { errors, isDirty },
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isDirty, dirtyFields },
|
||||||
} = useForm<CompanyProfileFormData>({
|
} = useForm<CompanyProfileFormData>({
|
||||||
resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
|
resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
|
||||||
values: defaultValues,
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: CompanyProfileFormData) => {
|
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 = {
|
const base = {
|
||||||
companyName: data.companyName,
|
|
||||||
companyEmail: data.companyEmail,
|
companyEmail: data.companyEmail,
|
||||||
companyPhone: data.companyPhone,
|
companyPhone: data.companyPhone,
|
||||||
companyLocation: data.companyLocation,
|
companyLocation: data.companyLocation,
|
||||||
companyAddress: data.companyAddress,
|
companyAddress: data.companyAddress,
|
||||||
tin: data.tinNumber,
|
|
||||||
fanNumber: data.fanNumber,
|
|
||||||
vatNumber: data.vatNumber ?? "",
|
vatNumber: data.vatNumber ?? "",
|
||||||
|
...etradeBundle,
|
||||||
|
...(data.ownerPassportNumber !== undefined
|
||||||
|
? { ownerPassportNumber: data.ownerPassportNumber }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isCreate) {
|
if (isCreate) {
|
||||||
@@ -116,6 +274,8 @@ export default function TabCompanyProfile({
|
|||||||
: "customer";
|
: "customer";
|
||||||
const payload: CreateCompanyPayload = {
|
const payload: CreateCompanyPayload = {
|
||||||
...base,
|
...base,
|
||||||
|
companyName: data.companyName,
|
||||||
|
tin: data.tinNumber,
|
||||||
companyType,
|
companyType,
|
||||||
companyProfiles: selectedRoles.map((type) => ({
|
companyProfiles: selectedRoles.map((type) => ({
|
||||||
type: type as CompanyProfileInput["type"],
|
type: type as CompanyProfileInput["type"],
|
||||||
@@ -140,6 +300,15 @@ export default function TabCompanyProfile({
|
|||||||
mutation.mutate(data);
|
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
|
// During onboarding the role selection gates the form: nothing else shows
|
||||||
// until the user picks Importer/Exporter or Freight Forwarder.
|
// until the user picks Importer/Exporter or Freight Forwarder.
|
||||||
const showForm = !isCreate || selectedRoles.length > 0;
|
const showForm = !isCreate || selectedRoles.length > 0;
|
||||||
@@ -161,89 +330,109 @@ export default function TabCompanyProfile({
|
|||||||
<Text c="edr-muted" size="sm" mb="lg">
|
<Text c="edr-muted" size="sm" mb="lg">
|
||||||
{isCreate
|
{isCreate
|
||||||
? "Enter your company registration details to get started"
|
? "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>
|
</Text>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="xl">
|
||||||
<TextInput
|
<StepSection
|
||||||
label="Company Name"
|
index={1}
|
||||||
placeholder="Global Logistics Ltd"
|
title="VAT number"
|
||||||
error={errors.companyName?.message}
|
status={watch("vatNumber") ? "done" : "todo"}
|
||||||
{...register("companyName")}
|
>
|
||||||
/>
|
<TextInput
|
||||||
|
label="VAT Number (optional)"
|
||||||
|
placeholder="e.g. 0012345678"
|
||||||
|
maxLength={20}
|
||||||
|
error={errors.vatNumber?.message}
|
||||||
|
{...register("vatNumber")}
|
||||||
|
/>
|
||||||
|
</StepSection>
|
||||||
|
|
||||||
<Grid>
|
{identity && (
|
||||||
<Grid.Col span={6}>
|
<StepSection
|
||||||
<TextInput
|
index={2}
|
||||||
label="Company Email"
|
title="Owner identity"
|
||||||
type="email"
|
subtitle={
|
||||||
placeholder="ops@company.com"
|
verifiedIdentity
|
||||||
error={errors.companyEmail?.message}
|
? "Re-verify the company owner with Fayda — their name, phone, email and address are refreshed from the verification."
|
||||||
{...register("companyEmail")}
|
: "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={identity.owner}
|
||||||
|
required={identity.faydaRequired}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
pendingReview={pendingOwnerReview}
|
||||||
|
onVerified={() =>
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
{identity.passportRequired && (
|
||||||
<Grid.Col span={6}>
|
<TextInput
|
||||||
<ControlledPhoneField
|
label="Owner Passport Number"
|
||||||
|
placeholder="P1234567"
|
||||||
|
description="The owner's identity credential — Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
|
||||||
|
error={errors.ownerPassportNumber?.message}
|
||||||
|
{...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}
|
control={control}
|
||||||
name="companyPhone"
|
|
||||||
label="Company Phone"
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
)}
|
||||||
</Grid>
|
</StepSection>
|
||||||
|
|
||||||
<Grid>
|
<TextInput
|
||||||
<Grid.Col span={6}>
|
label="Location"
|
||||||
<TextInput
|
placeholder="Addis Ababa, Ethiopia"
|
||||||
label="Location"
|
error={errors.companyLocation?.message}
|
||||||
placeholder="Addis Ababa, Ethiopia"
|
{...register("companyLocation")}
|
||||||
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="FAN Number (16 digits)"
|
|
||||||
placeholder="1234567890123456"
|
|
||||||
maxLength={16}
|
|
||||||
error={errors.fanNumber?.message}
|
|
||||||
{...register("fanNumber")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<TextInput
|
|
||||||
label="VAT Number (optional)"
|
|
||||||
placeholder="e.g. 0012345678"
|
|
||||||
maxLength={20}
|
|
||||||
error={errors.vatNumber?.message}
|
|
||||||
{...register("vatNumber")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group
|
<Group
|
||||||
@@ -261,11 +450,11 @@ export default function TabCompanyProfile({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{mutation.isError && (
|
{saveErrorMessage && (
|
||||||
<Group gap={6} c="red">
|
<Group gap={6} c="red">
|
||||||
<XCircle size={16} />
|
<XCircle size={16} />
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{isCreate ? "Failed to create profile" : "Save failed"}
|
{saveErrorMessage}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
@@ -296,3 +485,111 @@ export default function TabCompanyProfile({
|
|||||||
</Stack>
|
</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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function documentSettingCode(nationality: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The delegation letter ships in the same nationality document set, but it is
|
* The DARS delegation paper ships in the same nationality document set, but it is
|
||||||
* edited on the Power of Attorney tab (where it is staged for review alongside
|
* edited on the Power of Attorney tab (where it is staged for review alongside
|
||||||
* the PoA details), so it is excluded from this tab's uploader.
|
* the PoA details), so it is excluded from this tab's uploader.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
|
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -35,8 +36,17 @@ interface TabGeneralManagerProps {
|
|||||||
onContinue?: () => void;
|
onContinue?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The general manager is a plain typed role, not the person the Fayda
|
||||||
|
* verification proves — the owner is. They're very often the same human,
|
||||||
|
* which is what "Same as owner" is for: once the owner has verified, this
|
||||||
|
* copies their name/email/phone in rather than making the customer re-type
|
||||||
|
* data the company already proved.
|
||||||
|
*/
|
||||||
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
|
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const owner = profile.identity?.owner;
|
||||||
|
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
const defaultValues = useMemo((): FormData => {
|
||||||
return {
|
return {
|
||||||
@@ -51,12 +61,32 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
setValue,
|
||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
values: defaultValues,
|
values: defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toggleGmSameAsOwner = (checked: boolean) => {
|
||||||
|
setGmSameAsOwner(checked);
|
||||||
|
if (checked && owner) {
|
||||||
|
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
|
||||||
|
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
|
||||||
|
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the copy live while the checkbox is on — e.g. the owner re-verifies
|
||||||
|
// with updated details.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gmSameAsOwner || !owner) return;
|
||||||
|
setValue("generalManagerName", owner.name ?? "");
|
||||||
|
setValue("generalManagerEmail", owner.email ?? "");
|
||||||
|
setValue("generalManagerPhone", owner.phone ?? "");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [gmSameAsOwner, owner?.name, owner?.email, owner?.phone]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: FormData) =>
|
mutationFn: (data: FormData) =>
|
||||||
api.companies.updateProfile.call({
|
api.companies.updateProfile.call({
|
||||||
@@ -84,6 +114,14 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{owner?.verified && (
|
||||||
|
<LinkCheckboxCard
|
||||||
|
checked={gmSameAsOwner}
|
||||||
|
onToggle={toggleGmSameAsOwner}
|
||||||
|
title="Same as verified owner"
|
||||||
|
description="Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Full Name"
|
label="Full Name"
|
||||||
placeholder="Abebe Bikila"
|
placeholder="Abebe Bikila"
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
type LicenseFileStatus,
|
type LicenseFileStatus,
|
||||||
} from "@/services/companies.service";
|
} from "@/services/companies.service";
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
|
import { verifaydaService } from "@/services/verifayda.service";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -136,10 +138,16 @@ export default function TabPowerOfAttorney({
|
|||||||
const hasLetterAfterSave = Boolean(pickedFile) || remainingLetters.length > 0;
|
const hasLetterAfterSave = Boolean(pickedFile) || remainingLetters.length > 0;
|
||||||
|
|
||||||
// A freight forwarder signs on other companies' behalf, so its PoA — details
|
// A freight forwarder signs on other companies' behalf, so its PoA — details
|
||||||
// and delegation letter both — is mandatory rather than optional.
|
// and DARS delegation paper both — is mandatory rather than optional.
|
||||||
const requirePoa = profile.companyProfiles.some(
|
const requirePoa = profile.companyProfiles.some(
|
||||||
(p) => p.type === "freight_forwarder",
|
(p) => p.type === "freight_forwarder",
|
||||||
);
|
);
|
||||||
|
// An Ethiopian company does not type its representative's details — they
|
||||||
|
// come from the Fayda verification. A foreign company keeps the typed form:
|
||||||
|
// its representative may hold no Fayda ID.
|
||||||
|
const identity = profile.identity;
|
||||||
|
const verifiedIdentity = identity?.faydaRequired === true;
|
||||||
|
|
||||||
const poaValues = watch([
|
const poaValues = watch([
|
||||||
"poaName",
|
"poaName",
|
||||||
"poaEmail",
|
"poaEmail",
|
||||||
@@ -147,7 +155,9 @@ export default function TabPowerOfAttorney({
|
|||||||
"poaLocation",
|
"poaLocation",
|
||||||
"poaAddress",
|
"poaAddress",
|
||||||
]);
|
]);
|
||||||
const poaProvided = poaValues.some((v) => v?.trim());
|
const poaProvided = verifiedIdentity
|
||||||
|
? (identity?.poa.verified ?? false)
|
||||||
|
: poaValues.some((v) => v?.trim());
|
||||||
const letterRequired = requirePoa || poaProvided;
|
const letterRequired = requirePoa || poaProvided;
|
||||||
const letterMissing = letterRequired && !hasLetterAfterSave;
|
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||||
|
|
||||||
@@ -155,22 +165,32 @@ export default function TabPowerOfAttorney({
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: FormData) => {
|
mutationFn: async (data: FormData) => {
|
||||||
// A fresh upload already stages the removal of every live letter, so the
|
// Every identity field except the city is written by the verification, so
|
||||||
// explicit removals only need applying when no replacement was picked.
|
// an Ethiopian company only ever saves the paper and the location here.
|
||||||
|
const fields = verifiedIdentity
|
||||||
|
? { poaLocation: data.poaLocation || undefined }
|
||||||
|
: {
|
||||||
|
poaName: data.poaName || undefined,
|
||||||
|
poaPhone: data.poaPhone || undefined,
|
||||||
|
poaEmail: data.poaEmail || undefined,
|
||||||
|
poaLocation: data.poaLocation || undefined,
|
||||||
|
poaAddress: data.poaAddress || undefined,
|
||||||
|
};
|
||||||
|
// A fresh upload already stages the removal of every paper on file, so
|
||||||
|
// the explicit removals only need applying when no replacement was
|
||||||
|
// picked. Saving the details after it means the API sees the new paper.
|
||||||
if (pickedFile) {
|
if (pickedFile) {
|
||||||
await companiesService.uploadPoaDelegation(pickedFile);
|
await companiesService.uploadPoaDelegation(pickedFile);
|
||||||
} else {
|
return api.companies.updateProfile.call(fields);
|
||||||
for (const fileId of removeIds) {
|
|
||||||
await companiesService.removePoaDelegation(fileId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return api.companies.updateProfile.call({
|
// Nothing replacing it, so the details go first: the API judges a removal
|
||||||
poaName: data.poaName || undefined,
|
// against the PoA the customer is keeping, and clearing both together is
|
||||||
poaPhone: data.poaPhone || undefined,
|
// the only way it will let the paper go.
|
||||||
poaEmail: data.poaEmail || undefined,
|
const saved = await api.companies.updateProfile.call(fields);
|
||||||
poaLocation: data.poaLocation || undefined,
|
for (const fileId of removeIds) {
|
||||||
poaAddress: data.poaAddress || undefined,
|
await companiesService.removePoaDelegation(fileId);
|
||||||
});
|
}
|
||||||
|
return saved;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setPickedFile(null);
|
setPickedFile(null);
|
||||||
@@ -185,6 +205,24 @@ export default function TabPowerOfAttorney({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A verified representative cannot be removed by blanking the form — their
|
||||||
|
* fields are owned by the verification — so removal is its own action that
|
||||||
|
* clears the identity and the delegation paper together.
|
||||||
|
*/
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: () => verifaydaService.removePoa(),
|
||||||
|
onSuccess: () => {
|
||||||
|
resetAll();
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.poaDelegation.queryKey(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const onSubmit = (data: FormData) => {
|
const onSubmit = (data: FormData) => {
|
||||||
// The letter lives outside the form state, so it's gated here rather than
|
// The letter lives outside the form state, so it's gated here rather than
|
||||||
// in the zod resolver.
|
// in the zod resolver.
|
||||||
@@ -234,37 +272,66 @@ export default function TabPowerOfAttorney({
|
|||||||
</Group>
|
</Group>
|
||||||
<Text c="edr-muted" size="sm" mb="lg">
|
<Text c="edr-muted" size="sm" mb="lg">
|
||||||
{requirePoa
|
{requirePoa
|
||||||
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its delegation letter are required."
|
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its DARS delegation paper are required."
|
||||||
: "Power of Attorney details are optional. If you name a representative, upload the delegation letter authorising them."}
|
: "Power of Attorney details are optional. If you name a representative, upload the DARS delegation paper authorising them."}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{identity && (
|
||||||
|
<FaydaVerifyPanel
|
||||||
|
subject="poa"
|
||||||
|
title="Power of Attorney"
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.poaDelegation.queryKey(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
{/* Name, email, phone and address are written by the Fayda
|
||||||
label="PoA Full Name"
|
verification for an Ethiopian company, so only the city — which
|
||||||
placeholder="Authorized Representative Name"
|
the address claim does not reliably decompose into — is typed. */}
|
||||||
error={errors.poaName?.message}
|
{!verifiedIdentity && (
|
||||||
{...register("poaName")}
|
<>
|
||||||
/>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Email"
|
label="PoA Full Name"
|
||||||
type="email"
|
placeholder="Authorized Representative Name"
|
||||||
placeholder="poa@company.com"
|
error={errors.poaName?.message}
|
||||||
error={errors.poaEmail?.message}
|
{...register("poaName")}
|
||||||
{...register("poaEmail")}
|
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
<Grid>
|
||||||
<ControlledPhoneField
|
<Grid.Col span={6}>
|
||||||
control={control}
|
<TextInput
|
||||||
name="poaPhone"
|
label="PoA Email"
|
||||||
label="PoA Phone"
|
type="email"
|
||||||
/>
|
placeholder="poa@company.com"
|
||||||
</Grid.Col>
|
error={errors.poaEmail?.message}
|
||||||
</Grid>
|
{...register("poaEmail")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="poaPhone"
|
||||||
|
label="PoA Phone"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
@@ -275,14 +342,16 @@ export default function TabPowerOfAttorney({
|
|||||||
{...register("poaLocation")}
|
{...register("poaLocation")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={6}>
|
{!verifiedIdentity && (
|
||||||
<TextInput
|
<Grid.Col span={6}>
|
||||||
label="PoA Address"
|
<TextInput
|
||||||
placeholder="Full Address"
|
label="PoA Address"
|
||||||
error={errors.poaAddress?.message}
|
placeholder="Full Address"
|
||||||
{...register("poaAddress")}
|
error={errors.poaAddress?.message}
|
||||||
/>
|
{...register("poaAddress")}
|
||||||
</Grid.Col>
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -292,7 +361,7 @@ export default function TabPowerOfAttorney({
|
|||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<FileText size={18} />
|
<FileText size={18} />
|
||||||
<Text fw={600} c="edr-text">
|
<Text fw={600} c="edr-text">
|
||||||
Delegation letter
|
DARS delegation paper
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button
|
||||||
@@ -309,14 +378,15 @@ export default function TabPowerOfAttorney({
|
|||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
onClick={() => uploadInputRef.current?.click()}
|
onClick={() => uploadInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
{hasLetterAfterSave ? "Replace letter" : "Upload letter"}
|
{hasLetterAfterSave ? "Replace paper" : "Upload paper"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text c="edr-muted" size="xs">
|
<Text c="edr-muted" size="xs">
|
||||||
The signed letter in which the General Manager delegates the
|
The delegation paper issued by the Documents Authentication and
|
||||||
representative above. Submitted to EDR for review together with the
|
Registration Service (DARS) for the representative above — the
|
||||||
details; it takes effect once approved.
|
authenticated copy, not a plain letter. Submitted to EDR for review
|
||||||
|
together with the details; it takes effect once approved.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{saveBlocked && letterMissing && (
|
{saveBlocked && letterMissing && (
|
||||||
@@ -326,8 +396,8 @@ export default function TabPowerOfAttorney({
|
|||||||
icon={<XCircle size={18} />}
|
icon={<XCircle size={18} />}
|
||||||
>
|
>
|
||||||
{requirePoa
|
{requirePoa
|
||||||
? "Upload the delegation letter before saving — it is required for freight forwarders."
|
? "Upload the DARS delegation paper before saving — it is required for freight forwarders."
|
||||||
: "Upload the delegation letter for the representative you named, or clear the PoA details."}
|
: "Upload the DARS delegation paper for the representative you named, or clear the PoA details."}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -345,7 +415,7 @@ export default function TabPowerOfAttorney({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="edr-muted" ta="center">
|
<Text size="sm" c="edr-muted" ta="center">
|
||||||
No delegation letter uploaded.
|
No DARS delegation paper uploaded.
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -397,7 +467,7 @@ export default function TabPowerOfAttorney({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
aria-label="Discard selected letter"
|
aria-label="Discard selected paper"
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
onClick={() => setPickedFile(null)}
|
onClick={() => setPickedFile(null)}
|
||||||
>
|
>
|
||||||
@@ -414,7 +484,7 @@ export default function TabPowerOfAttorney({
|
|||||||
<Group gap={6} c="edr-amber-text">
|
<Group gap={6} c="edr-amber-text">
|
||||||
<Clock size={13} />
|
<Clock size={13} />
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
Awaiting EDR review — this letter takes effect once approved.
|
Awaiting EDR review — this paper takes effect once approved.
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
@@ -456,6 +526,20 @@ export default function TabPowerOfAttorney({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
|
{mode === "edit" &&
|
||||||
|
verifiedIdentity &&
|
||||||
|
identity?.poa.verified &&
|
||||||
|
!requirePoa && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
loading={removeMutation.isPending}
|
||||||
|
onClick={() => removeMutation.mutate()}
|
||||||
|
>
|
||||||
|
Remove representative
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{mode === "edit" && (
|
{mode === "edit" && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -484,7 +568,7 @@ export default function TabPowerOfAttorney({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One letter already on file. `pending_add` / `pending_remove` reflect a change
|
* One paper already on file. `pending_add` / `pending_remove` reflect a change
|
||||||
* request the backoffice hasn't ruled on yet; `markedForRemoval` and
|
* request the backoffice hasn't ruled on yet; `markedForRemoval` and
|
||||||
* `supersededBy` are this session's unsaved edits.
|
* `supersededBy` are this session's unsaved edits.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { client } from "@/utils/api";
|
|||||||
import { unwrap } from "@/utils/endpoint";
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import type { ApiResponse } from "@/types/apiResponse";
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
import type { CompanyIdentityState } from "./verifayda.service";
|
||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
@@ -160,6 +161,8 @@ export interface OnboardingPoaState {
|
|||||||
required: boolean;
|
required: boolean;
|
||||||
provided: boolean;
|
provided: boolean;
|
||||||
delegationLetterUploaded: boolean;
|
delegationLetterUploaded: boolean;
|
||||||
|
/** True when a reviewer sent the DARS delegation paper back for correction. */
|
||||||
|
delegationLetterFlagged: boolean;
|
||||||
missingFields: { key: string; label: string }[];
|
missingFields: { key: string; label: string }[];
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
}
|
}
|
||||||
@@ -179,6 +182,8 @@ export interface OnboardingRequirements {
|
|||||||
documents: OnboardingDocumentField[];
|
documents: OnboardingDocumentField[];
|
||||||
licenseProfiles: OnboardingLicenseProfile[];
|
licenseProfiles: OnboardingLicenseProfile[];
|
||||||
poa: OnboardingPoaState;
|
poa: OnboardingPoaState;
|
||||||
|
/** Fayda verification state; `required` is false for a foreign company. */
|
||||||
|
identity: CompanyIdentityState;
|
||||||
progress: { completed: number; total: number };
|
progress: { completed: number; total: number };
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { client } from "@/utils/api";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the company's people a verification is for. The owner is NOT the
|
||||||
|
* general manager — GM is a plain typed role the portal offers a "same as
|
||||||
|
* owner" copy for, but only the owner and the PoA are ever Fayda-verified.
|
||||||
|
*/
|
||||||
|
export type IdentitySubject = "owner" | "poa";
|
||||||
|
|
||||||
|
/** One person's Fayda verification state, as the API reports it. */
|
||||||
|
export interface IdentityVerificationState {
|
||||||
|
verified: boolean;
|
||||||
|
name: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
address: string | null;
|
||||||
|
verifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OwnerIdentityState extends IdentityVerificationState {
|
||||||
|
/**
|
||||||
|
* Typed passport number — the foreign-company identity credential.
|
||||||
|
* Independent of Fayda: never written by a verification, and still required
|
||||||
|
* even if the owner also verifies.
|
||||||
|
*/
|
||||||
|
passportNumber: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyIdentityState {
|
||||||
|
/** True when Fayda verification of the owner (and PoA) is mandatory — Ethiopian companies only. */
|
||||||
|
faydaRequired: boolean;
|
||||||
|
/** True when the owner's passport number is mandatory — foreign companies only. */
|
||||||
|
passportRequired: boolean;
|
||||||
|
owner: OwnerIdentityState;
|
||||||
|
poa: IdentityVerificationState;
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message posted from the /callback popup back to the opener window. */
|
||||||
|
export interface FaydaCallbackMessage {
|
||||||
|
type: "fayda-callback";
|
||||||
|
code?: string;
|
||||||
|
state?: string;
|
||||||
|
error?: string;
|
||||||
|
errorDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const verifaydaService = {
|
||||||
|
/**
|
||||||
|
* Returns the eSignet authorize URL to open in a popup. `PORTAL` selects the
|
||||||
|
* portal's own registered redirect_uri — the backoffice and mobile clients
|
||||||
|
* have their own.
|
||||||
|
*/
|
||||||
|
start: async (): Promise<string> => {
|
||||||
|
const response = await client.post<
|
||||||
|
ApiResponse<{ authorizationUrl: string }>
|
||||||
|
>("/api/fayda/verification/start", {
|
||||||
|
purpose: "VERIFY",
|
||||||
|
platform: "PORTAL",
|
||||||
|
});
|
||||||
|
return unwrap(response.data).authorizationUrl;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange the callback code+state for a verified identity and bind it to one
|
||||||
|
* of the company's people. The API writes that person's name, phone, email
|
||||||
|
* and address from the Fayda payload — none of it is typed here.
|
||||||
|
*/
|
||||||
|
completeIdentity: async (
|
||||||
|
subject: IdentitySubject,
|
||||||
|
code: string,
|
||||||
|
state: string,
|
||||||
|
): Promise<CompanyIdentityState> => {
|
||||||
|
const response = await client.post<ApiResponse<CompanyIdentityState>>(
|
||||||
|
"/api/companies/identity/fayda/complete",
|
||||||
|
{ subject, code, state },
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the Power of Attorney — verified identity, details and delegation
|
||||||
|
* paper together. A verified person's fields are locked, so blanking the form
|
||||||
|
* is no longer a way to remove them. Refused for a freight forwarder.
|
||||||
|
*/
|
||||||
|
removePoa: async (): Promise<CompanyIdentityState> => {
|
||||||
|
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
|
||||||
|
"/api/companies/identity/fayda/poa",
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CompanyProfileResponse } from "@/services/companies.service";
|
import type { CompanyProfileResponse } from "@/services/companies.service";
|
||||||
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
|
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -34,6 +35,14 @@ export interface ProfileResponse {
|
|||||||
generalManagerName: string | null;
|
generalManagerName: string | null;
|
||||||
generalManagerEmail: string | null;
|
generalManagerEmail: string | null;
|
||||||
generalManagerPhone: string | null;
|
generalManagerPhone: string | null;
|
||||||
|
/**
|
||||||
|
* Fayda verification state for the owner and the PoA — not the general
|
||||||
|
* manager, which stays a plain typed role. `identity.faydaRequired` /
|
||||||
|
* `identity.passportRequired` is the Ethiopian/foreign switch: an Ethiopian
|
||||||
|
* company verifies the owner (and PoA) with Fayda; a foreign one requires a
|
||||||
|
* typed passport number for the owner instead.
|
||||||
|
*/
|
||||||
|
identity: CompanyIdentityState;
|
||||||
poaName: string | null;
|
poaName: string | null;
|
||||||
poaPhone: string | null;
|
poaPhone: string | null;
|
||||||
poaEmail: string | null;
|
poaEmail: string | null;
|
||||||
@@ -85,4 +94,6 @@ export interface UpdateProfilePayload {
|
|||||||
poaEmail?: string;
|
poaEmail?: string;
|
||||||
poaLocation?: string;
|
poaLocation?: string;
|
||||||
poaAddress?: string;
|
poaAddress?: string;
|
||||||
|
/** The owner's passport number — the foreign-company identity credential. */
|
||||||
|
ownerPassportNumber?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user