Merge pull request #1012 from Tria-plc/freight/nati-1

fayda verification and validation
This commit is contained in:
Nathnael Wondisha
2026-07-29 17:36:43 +03:00
committed by GitHub
40 changed files with 3742 additions and 565 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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);
});
});

View File

@@ -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: [

View File

@@ -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();
});
});

View File

@@ -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;
} }

View File

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

View File

@@ -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.`,
);
}
}
}
} }

View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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);
} }
} }

View File

@@ -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;
} }

View File

@@ -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)

View File

@@ -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;
} }

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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. */

View File

@@ -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 && (

View File

@@ -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) => (

View File

@@ -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

View File

@@ -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 />} />

View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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.

View 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>
);
}

View File

@@ -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>

View File

@@ -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 && (
<> <>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 ?? "",

View File

@@ -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",

View File

@@ -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}
/>
)}
/>
);
}

View File

@@ -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.
*/ */

View File

@@ -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"

View File

@@ -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.
*/ */

View File

@@ -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;

View File

@@ -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);
},
};

View File

@@ -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;
} }