mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
feat(WIP): require the gm for fayda on ethiopian companies
This commit is contained in:
@@ -408,6 +408,30 @@ export class CompaniesController {
|
|||||||
return this.companiesService.completeIdentityVerification(user.id, dto);
|
return this.companiesService.completeIdentityVerification(user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("identity/gm/same-as-owner")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
|
||||||
|
"Refused until the owner is Fayda-verified — there would be nothing proven to copy.",
|
||||||
|
})
|
||||||
|
async setGmSameAsOwner(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<CompanyIdentityStateDto> {
|
||||||
|
return this.companiesService.setGmSameAsOwner(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("identity/gm")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
|
||||||
|
"Leaves the GM open to be verified in their own right, or typed where Fayda is optional.",
|
||||||
|
})
|
||||||
|
async clearGmIdentity(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<CompanyIdentityStateDto> {
|
||||||
|
return this.companiesService.clearGmIdentity(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete("identity/fayda/poa")
|
@Delete("identity/fayda/poa")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
|
|||||||
* is named, both nationalities must verify them, and their details come from
|
* is named, both nationalities must verify them, and their details come from
|
||||||
* the verified payload rather than the form.
|
* the verified payload rather than the form.
|
||||||
*
|
*
|
||||||
* The owner is NOT the general manager — GM is a separate, plain typed role
|
* The owner is NOT the general manager. The GM is proved the same way, by one
|
||||||
* the portal offers a "same as owner" copy for, but it is never itself
|
* of two routes — verifying in their own right, or being declared the owner,
|
||||||
* Fayda-verified or gated on.
|
* which reuses that verification rather than making one human prove themselves
|
||||||
|
* twice. It stays out of the trading gate either way: the GM names who to talk
|
||||||
|
* to, not what the company may do.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Ctx {
|
interface Ctx {
|
||||||
@@ -418,10 +420,12 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
).resolves.toBeDefined();
|
).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still requires a Fayda-verified PoA from a foreign company", async () => {
|
it("accepts a typed PoA from a foreign company, whose representative may hold no Fayda ID", async () => {
|
||||||
// The owner's credential is nationality-specific; the representative's is
|
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
||||||
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
|
// representative can be held to it. A foreign company is offered the
|
||||||
// typed foreign name is not a representative the platform can accept.
|
// verification and uses it where its representative holds one, but a typed
|
||||||
|
// name stays sufficient — holding it to Fayda would leave a foreign
|
||||||
|
// company whose representative has no Fayda ID unable to trade at all.
|
||||||
const { service } = makeService({
|
const { service } = makeService({
|
||||||
profileTypes: [ProfileType.importer],
|
profileTypes: [ProfileType.importer],
|
||||||
nationality: CompanyNationality.Foreign,
|
nationality: CompanyNationality.Foreign,
|
||||||
@@ -434,6 +438,48 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
files: [paper()],
|
files: [paper()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses a foreign company that named no PoA at all", async () => {
|
||||||
|
// The typed fallback is a different credential, not a waiver: a freight
|
||||||
|
// forwarder acts on other companies' behalf and needs a representative
|
||||||
|
// whatever its nationality.
|
||||||
|
const { service } = makeService({
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
nationality: CompanyNationality.Foreign,
|
||||||
|
attributes: { ownerPassportNumber: "P1234567" },
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds an Ethiopian company to a Fayda-verified PoA, typed details notwithstanding", async () => {
|
||||||
|
// The relaxation above is scoped to foreign companies only — an Ethiopian
|
||||||
|
// representative holds a Fayda ID, so typing a name must not substitute.
|
||||||
|
const { service } = makeService({
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
nationality: CompanyNationality.Ethiopian,
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
poaName: "Abebe Bekele",
|
||||||
|
poaEmail: "abebe@example.com",
|
||||||
|
poaPhone: "+251911000000",
|
||||||
|
},
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.createCompanyProfileForUser(
|
service.createCompanyProfileForUser(
|
||||||
"user-1",
|
"user-1",
|
||||||
@@ -463,4 +509,119 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
),
|
),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// General manager
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("reuses the owner's verified identity when the GM is declared the same person", async () => {
|
||||||
|
// The GM is very often the owner. Copying the proven identity is the whole
|
||||||
|
// point — asking one human to complete two verifications proves nothing
|
||||||
|
// extra, and typing the details instead would forge a verified badge.
|
||||||
|
const { service, ctx } = makeService({
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
ownerEmail: "abebe@example.com",
|
||||||
|
ownerPhone: "+251911222333",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await service.setGmSameAsOwner("user-1");
|
||||||
|
|
||||||
|
expect(state.gm.verified).toBe(true);
|
||||||
|
expect(state.gmSameAsOwner).toBe(true);
|
||||||
|
expect(state.gm.name).toBe("Abebe Bikila");
|
||||||
|
expect(ctx.attributes.gmFaydaSub).toBe("owner-sub");
|
||||||
|
// The notifiers mail the flat column, so a linked GM has to land there too.
|
||||||
|
expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to declare the GM is the owner while the owner is unverified", async () => {
|
||||||
|
// Without a verification there is no proven identity to copy — only typed
|
||||||
|
// text, which would arrive wearing a badge it had not earned.
|
||||||
|
const { service } = makeService({ attributes: {} });
|
||||||
|
|
||||||
|
await expect(service.setGmSameAsOwner("user-1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the GM verify as the same human as the owner", async () => {
|
||||||
|
// The owner/PoA collision check exists because self-delegation is not
|
||||||
|
// delegation. It must not fire here: the GM being the owner is a supported
|
||||||
|
// answer, so verifying with the owner's own Fayda sub has to succeed.
|
||||||
|
const { service, ctx } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED },
|
||||||
|
verification: {
|
||||||
|
purpose: "VERIFY",
|
||||||
|
verified: true,
|
||||||
|
sub: "owner-sub",
|
||||||
|
fullName: "Abebe Bikila",
|
||||||
|
email: "abebe@example.com",
|
||||||
|
phoneNumber: "+251911222333",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await service.completeIdentityVerification("user-1", {
|
||||||
|
subject: "gm",
|
||||||
|
code: "c",
|
||||||
|
state: "s",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.gm.verified).toBe(true);
|
||||||
|
expect(ctx.attributes.gmFaydaSub).toBe("owner-sub");
|
||||||
|
expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses a PoA who is the owner", async () => {
|
||||||
|
// The GM exemption above must not have widened into the PoA.
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED },
|
||||||
|
verification: {
|
||||||
|
purpose: "VERIFY",
|
||||||
|
verified: true,
|
||||||
|
sub: "owner-sub",
|
||||||
|
fullName: "Abebe Bikila",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeIdentityVerification("user-1", {
|
||||||
|
subject: "poa",
|
||||||
|
code: "c",
|
||||||
|
state: "s",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a pre-existing typed GM as unverified rather than blank", async () => {
|
||||||
|
// Companies onboarded before the GM was verifiable have typed details and
|
||||||
|
// no gm* attributes. Those details are still what the notifiers mail, so
|
||||||
|
// they must survive — flagged unverified so the portal offers the upgrade.
|
||||||
|
const { service, company } = makeService({
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
generalManagerName: "Legacy Manager",
|
||||||
|
generalManagerEmail: "legacy@example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = service.getCompanyIdentityState(company() as never);
|
||||||
|
|
||||||
|
expect(state.gm.verified).toBe(false);
|
||||||
|
expect(state.gm.name).toBe("Legacy Manager");
|
||||||
|
expect(state.gm.email).toBe("legacy@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let an unproven GM block the company from trading", async () => {
|
||||||
|
// The GM names who to talk to, not what the company may do. Capturing it
|
||||||
|
// through Fayda changed how it is collected, not whether it gates.
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser("user-1", ProfileType.importer),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
buildCompanyIdentityState,
|
buildCompanyIdentityState,
|
||||||
CompanyIdentityStateDto,
|
CompanyIdentityStateDto,
|
||||||
CompleteIdentityVerificationDto,
|
CompleteIdentityVerificationDto,
|
||||||
|
IDENTITY_SUBJECTS,
|
||||||
IdentitySubject,
|
IdentitySubject,
|
||||||
} from "./dto/complete-identity-verification.dto";
|
} from "./dto/complete-identity-verification.dto";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
@@ -122,31 +123,47 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `attributes` key prefix per verifiable person. The owner is NOT the general
|
* `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
|
* manager: the owner is who the verification proves the company through, the
|
||||||
* once the owner is verified), while the owner is who this verification
|
* GM is personnel it names. They're very often the same human, which is what
|
||||||
* actually proves. They're very often the same human; that's what the copy is
|
* the portal's "same as owner" copy is for.
|
||||||
* for.
|
|
||||||
*/
|
*/
|
||||||
const IDENTITY_PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
const IDENTITY_PREFIX: Record<IdentitySubject, string> = {
|
||||||
owner: "owner",
|
owner: "owner",
|
||||||
poa: "poa",
|
poa: "poa",
|
||||||
|
gm: "gm",
|
||||||
};
|
};
|
||||||
|
|
||||||
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
|
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
|
||||||
owner: "owner",
|
owner: "owner",
|
||||||
poa: "Power of Attorney",
|
poa: "Power of Attorney",
|
||||||
|
gm: "General Manager",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed GM columns a GM verification also writes. Three notifier services mail
|
||||||
|
* `company.generalManagerEmail` directly, so leaving these behind would mean a
|
||||||
|
* verified GM whose address the system never actually uses.
|
||||||
|
*/
|
||||||
|
const GM_TYPED_FIELDS = [
|
||||||
|
"generalManagerName",
|
||||||
|
"generalManagerEmail",
|
||||||
|
"generalManagerPhone",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identity fields a Fayda verification owns outright, per person. Once verified
|
* 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
|
* 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
|
* 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
|
* guarantee away.
|
||||||
* Fayda-verified, so it stays freely editable regardless of the owner's state.
|
*
|
||||||
|
* The GM's entries are its typed columns: a verified GM is locked the same way
|
||||||
|
* the others are, while an unverified one (a foreign company's, or a record
|
||||||
|
* that predates this) stays freely editable.
|
||||||
*/
|
*/
|
||||||
const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
|
const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
|
||||||
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
|
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
|
||||||
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
|
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
|
||||||
|
gm: [...GM_TYPED_FIELDS],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -834,7 +851,7 @@ export class CompaniesService {
|
|||||||
|
|
||||||
// Renaming a Fayda-verified person by hand would launder the guarantee
|
// Renaming a Fayda-verified person by hand would launder the guarantee
|
||||||
// away, so the fields the verification owns are refused once it exists.
|
// away, so the fields the verification owns are refused once it exists.
|
||||||
for (const subject of ["owner", "poa"] as IdentitySubject[]) {
|
for (const subject of IDENTITY_SUBJECTS) {
|
||||||
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||||
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
||||||
const incoming = (dto as Record<string, unknown>)[field];
|
const incoming = (dto as Record<string, unknown>)[field];
|
||||||
@@ -2693,12 +2710,17 @@ export class CompaniesService {
|
|||||||
|
|
||||||
// The owner delegating power of attorney to themselves is not a
|
// The owner delegating power of attorney to themselves is not a
|
||||||
// delegation — it would let one identity satisfy both halves of the check.
|
// delegation — it would let one identity satisfy both halves of the check.
|
||||||
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
// Only owner/PoA collide this way: the GM is very often the owner, and
|
||||||
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
// saying so is a supported answer rather than a conflict, so it is left out
|
||||||
if (otherSub && otherSub === result.sub) {
|
// of this check entirely.
|
||||||
throw new BadRequestException(
|
if (dto.subject === "owner" || dto.subject === "poa") {
|
||||||
`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 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 now = new Date().toISOString();
|
||||||
@@ -2714,13 +2736,26 @@ export class CompaniesService {
|
|||||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A GM verification also lands on the typed columns the rest of the system
|
||||||
|
// already reads (the booking, train-scheduling and contract notifiers all
|
||||||
|
// mail `generalManagerEmail`), and clears any earlier "same as owner"
|
||||||
|
// declaration — verifying in their own right is the GM answering for
|
||||||
|
// themselves.
|
||||||
|
if (dto.subject === "gm") {
|
||||||
|
identity.gmSameAsOwner = false;
|
||||||
|
if (result.fullName) identity.generalManagerName = result.fullName;
|
||||||
|
if (result.email) identity.generalManagerEmail = result.email;
|
||||||
|
if (result.phoneNumber)
|
||||||
|
identity.generalManagerPhone = normalizeE164(result.phoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
// An approved company's *owner* is its identity proof, so re-verifying one
|
// An approved company's *owner* is its identity proof, so re-verifying one
|
||||||
// is staged for backoffice review rather than quietly rewriting a live
|
// is staged for backoffice review rather than quietly rewriting a live
|
||||||
// record. The PoA is personnel — the company names its own representative,
|
// record. The PoA and GM are personnel — the company names its own
|
||||||
// and the delegation letter backing them is what the reviewer sees — so a
|
// representative and manager, and the delegation letter backing the PoA is
|
||||||
// PoA verification lands live, matching the typed PoA fields in
|
// what the reviewer sees — so those land live, matching their typed
|
||||||
// `SELF_SERVICE_ATTRIBUTES`.
|
// counterparts in `SELF_SERVICE_ATTRIBUTES`.
|
||||||
if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
|
if (company.status === CompanyStatus.Active && dto.subject === "owner") {
|
||||||
await this.stageIdentityChange(company, userId, identity);
|
await this.stageIdentityChange(company, userId, identity);
|
||||||
return this.getCompanyIdentityState(company);
|
return this.getCompanyIdentityState(company);
|
||||||
}
|
}
|
||||||
@@ -2734,6 +2769,85 @@ export class CompaniesService {
|
|||||||
return this.getCompanyIdentityState(updated);
|
return this.getCompanyIdentityState(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare that the General Manager is the company's owner.
|
||||||
|
*
|
||||||
|
* The GM is very often the owner, and making that human verify twice buys
|
||||||
|
* nothing — the owner's verification already proves them. So this copies the
|
||||||
|
* owner's verified identity across rather than starting a second flow, and
|
||||||
|
* records `gmSameAsOwner` so the portal can show it as a declaration rather
|
||||||
|
* than as a verification the GM passed in their own right.
|
||||||
|
*
|
||||||
|
* Refused until the owner is actually verified: without that there is no
|
||||||
|
* proven identity to copy, only typed text that would arrive wearing a
|
||||||
|
* verified badge.
|
||||||
|
*/
|
||||||
|
async setGmSameAsOwner(userId: string): Promise<CompanyIdentityStateDto> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
const attrs = company.attributes ?? {};
|
||||||
|
const ownerSub = attrs.ownerFaydaSub as string | undefined;
|
||||||
|
if (!ownerSub) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Verify the company owner with Fayda first — there is no proven identity to reuse yet.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied: Record<string, unknown> = {
|
||||||
|
gmSameAsOwner: true,
|
||||||
|
gmFaydaSub: ownerSub,
|
||||||
|
gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(),
|
||||||
|
gmName: attrs.ownerName ?? null,
|
||||||
|
gmEmail: attrs.ownerEmail ?? null,
|
||||||
|
gmPhone: attrs.ownerPhone ?? null,
|
||||||
|
gmAddress: attrs.ownerAddress ?? null,
|
||||||
|
gmBirthdate: attrs.ownerBirthdate ?? null,
|
||||||
|
gmGender: attrs.ownerGender ?? null,
|
||||||
|
// Kept in step for the notifiers, same as a GM verification does.
|
||||||
|
generalManagerName: attrs.ownerName ?? null,
|
||||||
|
generalManagerEmail: attrs.ownerEmail ?? null,
|
||||||
|
generalManagerPhone: attrs.ownerPhone ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = await this.companiesRepo.update(company.id, {
|
||||||
|
attributes: { ...attrs, ...copied },
|
||||||
|
});
|
||||||
|
if (!updated)
|
||||||
|
throw new NotFoundException(`Company ${company.id} not found`);
|
||||||
|
updated.companyProfiles = company.companyProfiles;
|
||||||
|
return this.getCompanyIdentityState(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo the "same as owner" declaration, clearing the copied identity so the
|
||||||
|
* GM can be verified in their own right (or typed, where Fayda is optional).
|
||||||
|
*/
|
||||||
|
async clearGmIdentity(userId: string): Promise<CompanyIdentityStateDto> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
const attrs = { ...(company.attributes ?? {}) };
|
||||||
|
for (const key of [
|
||||||
|
"gmSameAsOwner",
|
||||||
|
"gmFaydaSub",
|
||||||
|
"gmFaydaVerifiedAt",
|
||||||
|
"gmName",
|
||||||
|
"gmEmail",
|
||||||
|
"gmPhone",
|
||||||
|
"gmAddress",
|
||||||
|
"gmBirthdate",
|
||||||
|
"gmGender",
|
||||||
|
...GM_TYPED_FIELDS,
|
||||||
|
]) {
|
||||||
|
attrs[key] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.companiesRepo.update(company.id, {
|
||||||
|
attributes: attrs,
|
||||||
|
});
|
||||||
|
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
|
* Drop the Power of Attorney entirely — the verified identity, the details it
|
||||||
* wrote and the delegation paper together.
|
* wrote and the delegation paper together.
|
||||||
@@ -2864,14 +2978,28 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The representative is not. A PoA acts for the company inside Ethiopia
|
|
||||||
// whoever owns it, so they are always an Ethiopian holding a Fayda ID —
|
|
||||||
// a foreign company nominates one rather than typing a name.
|
|
||||||
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
);
|
);
|
||||||
if (!opts.requirePoa && !poaNamed) return;
|
if (!opts.requirePoa && !poaNamed) return;
|
||||||
|
|
||||||
|
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
||||||
|
// representative can be held to it. A foreign company is offered the
|
||||||
|
// verification and nominates a Fayda-holding representative where it can,
|
||||||
|
// but a typed name has to remain sufficient — otherwise a foreign company
|
||||||
|
// whose representative holds no Fayda ID could never trade at all. Mirrors
|
||||||
|
// `poaProven` in buildCompanyIdentityState; the two must agree.
|
||||||
|
if (state.passportRequired) {
|
||||||
|
if (!state.poa.verified && !state.poa.name?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
opts.requirePoa
|
||||||
|
? "Name your Power of Attorney — a freight forwarder cannot operate without one."
|
||||||
|
: "Complete the Power of Attorney you named, or remove the representative.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!state.poa.verified) {
|
if (!state.poa.verified) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
opts.requirePoa
|
opts.requirePoa
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import { Company, CompanyNationality } from "../entities/company.entity";
|
|||||||
import { ProfileType } from "../entities/company-profile.entity";
|
import { ProfileType } from "../entities/company-profile.entity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two people a company is verified through — its owner and its Power of
|
* The three people a company is verified through — its owner, its Power of
|
||||||
* Attorney. "Owner" is not the same as the General Manager: a company's GM is
|
* Attorney and its General Manager. The owner is the person the company's
|
||||||
* a plain typed role (with a "same as owner" copy the portal offers), while
|
* existence is proven by; the other two are personnel it names.
|
||||||
* the owner is the person this verification proves. They're very often the
|
*
|
||||||
* same human, which is exactly what the copy is for.
|
* The GM is very often the owner, which is what the portal's "same as owner"
|
||||||
|
* copy is for: that path reuses the owner's verified identity outright rather
|
||||||
|
* than asking the same human to verify twice.
|
||||||
*/
|
*/
|
||||||
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
|
export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const;
|
||||||
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
||||||
|
|
||||||
export class CompleteIdentityVerificationDto {
|
export class CompleteIdentityVerificationDto {
|
||||||
@@ -73,6 +75,19 @@ export class CompanyIdentityStateDto {
|
|||||||
@ApiProperty({ type: IdentityVerificationStateDto })
|
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||||
poa!: IdentityVerificationStateDto;
|
poa!: IdentityVerificationStateDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: IdentityVerificationStateDto,
|
||||||
|
description:
|
||||||
|
"General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.",
|
||||||
|
})
|
||||||
|
gm!: IdentityVerificationStateDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.",
|
||||||
|
})
|
||||||
|
gmSameAsOwner!: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
|
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
|
||||||
@@ -81,11 +96,28 @@ export class CompanyIdentityStateDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `attributes` key prefix per person. */
|
/** `attributes` key prefix per person. */
|
||||||
const PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
const PREFIX: Record<IdentitySubject, string> = {
|
||||||
owner: "owner",
|
owner: "owner",
|
||||||
poa: "poa",
|
poa: "poa",
|
||||||
|
gm: "gm",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed GM fields, kept in step with the Fayda-written ones.
|
||||||
|
*
|
||||||
|
* The GM predates this verification: its details are plain company columns
|
||||||
|
* that three notifier services mail (booking-lifecycle, train-scheduling and
|
||||||
|
* contract notifiers all read `company.generalManagerEmail`). A verification
|
||||||
|
* therefore writes BOTH — the `gm*` attributes carry the proof, these carry
|
||||||
|
* the value everything else already reads — and an unverified company keeps
|
||||||
|
* showing whatever was typed before this existed.
|
||||||
|
*/
|
||||||
|
const GM_TYPED_KEYS = {
|
||||||
|
name: "generalManagerName",
|
||||||
|
email: "generalManagerEmail",
|
||||||
|
phone: "generalManagerPhone",
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** company.attributes keys that together mean "a PoA was entered". */
|
/** company.attributes keys that together mean "a PoA was entered". */
|
||||||
const POA_KEYS = [
|
const POA_KEYS = [
|
||||||
"poaName",
|
"poaName",
|
||||||
@@ -101,7 +133,7 @@ function stateFor(
|
|||||||
): IdentityVerificationStateDto {
|
): IdentityVerificationStateDto {
|
||||||
const p = PREFIX[subject];
|
const p = PREFIX[subject];
|
||||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||||
return {
|
const state: IdentityVerificationStateDto = {
|
||||||
verified: Boolean(read(`${p}FaydaSub`)),
|
verified: Boolean(read(`${p}FaydaSub`)),
|
||||||
name: read(`${p}Name`),
|
name: read(`${p}Name`),
|
||||||
phone: read(`${p}Phone`),
|
phone: read(`${p}Phone`),
|
||||||
@@ -111,6 +143,18 @@ function stateFor(
|
|||||||
birthdate: read(`${p}Birthdate`),
|
birthdate: read(`${p}Birthdate`),
|
||||||
gender: read(`${p}Gender`),
|
gender: read(`${p}Gender`),
|
||||||
};
|
};
|
||||||
|
if (subject !== "gm") return state;
|
||||||
|
|
||||||
|
// Companies onboarded before the GM was verifiable have typed details and no
|
||||||
|
// `gm*` attributes at all. Report those rather than a blank card — they are
|
||||||
|
// still what the notifiers mail — leaving `verified` false so the portal
|
||||||
|
// offers the upgrade instead of pretending the identity is proven.
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
name: state.name ?? read(GM_TYPED_KEYS.name),
|
||||||
|
email: state.email ?? read(GM_TYPED_KEYS.email),
|
||||||
|
phone: state.phone ?? read(GM_TYPED_KEYS.phone),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,14 +188,34 @@ export function buildCompanyIdentityState(
|
|||||||
(p) => p.type === ProfileType.freightForwarder,
|
(p) => p.type === ProfileType.freightForwarder,
|
||||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||||
|
|
||||||
// Only the *owner's* credential is nationality-specific. A Power of Attorney
|
const gm = stateFor(attrs, "gm");
|
||||||
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
|
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
|
||||||
// proven with Fayda — a foreign company nominates a representative who holds
|
|
||||||
// one rather than typing a name nothing backs.
|
|
||||||
const ownerProven = faydaRequired
|
const ownerProven = faydaRequired
|
||||||
? owner.verified
|
? owner.verified
|
||||||
: !passportRequired || Boolean(owner.passportNumber);
|
: !passportRequired || Boolean(owner.passportNumber);
|
||||||
const complete = ownerProven && (!poaDue || poa.verified);
|
|
||||||
|
|
||||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
||||||
|
// personnel can be held to it. A foreign company may nominate a
|
||||||
|
// representative who holds one — and is offered the verification — but a
|
||||||
|
// typed name has to remain sufficient, or a foreign company whose PoA has no
|
||||||
|
// Fayda ID could never finish onboarding.
|
||||||
|
const poaProven = faydaRequired
|
||||||
|
? poa.verified
|
||||||
|
: poa.verified || Boolean(poa.name?.trim());
|
||||||
|
|
||||||
|
// The GM is deliberately absent from this verdict: it names who to talk to,
|
||||||
|
// not what the company may do, and it has never gated trading. Capturing it
|
||||||
|
// through Fayda changes how it is collected, not whether it is required.
|
||||||
|
const complete = ownerProven && (!poaDue || poaProven);
|
||||||
|
|
||||||
|
return {
|
||||||
|
faydaRequired,
|
||||||
|
passportRequired,
|
||||||
|
owner,
|
||||||
|
poa,
|
||||||
|
gm,
|
||||||
|
gmSameAsOwner,
|
||||||
|
complete,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -437,6 +437,10 @@ export default function OnboardingWizardDialog({
|
|||||||
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
|
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
|
||||||
// a foreign one requires a typed passport number for the owner instead.
|
// a foreign one requires a typed passport number for the owner instead.
|
||||||
identity: requirementsQuery.data?.identity,
|
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.
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
|
|||||||
case "contact":
|
case "contact":
|
||||||
return !profile.contactPersonName || !profile.contactPersonPhone;
|
return !profile.contactPersonName || !profile.contactPersonPhone;
|
||||||
case "gm":
|
case "gm":
|
||||||
|
// The GM is established through Fayda — verified in their own right or
|
||||||
|
// declared the same person as the owner — so the identity answers this,
|
||||||
|
// not the typed columns. A company that may still type them (foreign,
|
||||||
|
// whose manager may hold no Fayda ID) is judged on those instead.
|
||||||
|
if (profile.identity?.gm.verified) return false;
|
||||||
|
if (profile.identity?.faydaRequired) return true;
|
||||||
return (
|
return (
|
||||||
!profile.generalManagerName ||
|
!profile.generalManagerName ||
|
||||||
!profile.generalManagerEmail ||
|
!profile.generalManagerEmail ||
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
toFormValues,
|
toFormValues,
|
||||||
} from "./companyProfileForm/helpers";
|
} from "./companyProfileForm/helpers";
|
||||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
|
import { verifaydaService } from "@/services/verifayda.service";
|
||||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||||
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
||||||
@@ -67,6 +68,7 @@ export default function CompanyProfileForm({
|
|||||||
uploadedDocumentKeys,
|
uploadedDocumentKeys,
|
||||||
onUploadDocuments,
|
onUploadDocuments,
|
||||||
identity,
|
identity,
|
||||||
|
onIdentityChange,
|
||||||
}: {
|
}: {
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
documentFiles?: Record<string, File | File[] | null>;
|
documentFiles?: Record<string, File | File[] | null>;
|
||||||
@@ -106,6 +108,12 @@ export default function CompanyProfileForm({
|
|||||||
>;
|
>;
|
||||||
/** Fayda verification state for the owner and the PoA (undefined until loaded). */
|
/** Fayda verification state for the owner and the PoA (undefined until loaded). */
|
||||||
identity?: CompanyIdentityState;
|
identity?: CompanyIdentityState;
|
||||||
|
/**
|
||||||
|
* Refetch the profile + requirements. Only the in-page identity actions need
|
||||||
|
* this — a Fayda verification navigates the whole tab away and comes back to
|
||||||
|
* a freshly booted app, so it has nothing to notify.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
@@ -339,7 +347,11 @@ export default function CompanyProfileForm({
|
|||||||
// "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.
|
||||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
// Seeded from the server so a resumed draft reopens with the declaration the
|
||||||
|
// company already made, rather than an unticked box over a linked GM.
|
||||||
|
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
||||||
|
identity?.gmSameAsOwner ?? false,
|
||||||
|
);
|
||||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||||
|
|
||||||
// General Manager source. The company step's email/phone are seeded from
|
// General Manager source. The company step's email/phone are seeded from
|
||||||
@@ -366,6 +378,10 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gmSameAsOwner) return;
|
if (!gmSameAsOwner) return;
|
||||||
|
// A verified owner's identity is copied server-side and read back from
|
||||||
|
// `identity.gm`; mirroring it into form fields here would send typed
|
||||||
|
// values for something the API already owns.
|
||||||
|
if (identity?.owner.verified) return;
|
||||||
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
||||||
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true });
|
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true });
|
||||||
setValue("generalManagerPhone", gmSourcePhone, {
|
setValue("generalManagerPhone", gmSourcePhone, {
|
||||||
@@ -374,18 +390,78 @@ export default function CompanyProfileForm({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]);
|
}, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]);
|
||||||
|
|
||||||
const toggleGmSameAsOwner = (checked: boolean) => {
|
/**
|
||||||
|
* "Same as owner" has two meanings depending on what backs the owner.
|
||||||
|
*
|
||||||
|
* A Fayda-verified owner is a proven identity, so the declaration is made
|
||||||
|
* server-side: the API copies that identity onto the GM and records what it
|
||||||
|
* did. Anything typed here would arrive wearing a verified badge it hadn't
|
||||||
|
* earned, which is exactly what the verification exists to prevent.
|
||||||
|
*
|
||||||
|
* A foreign company's owner is backed by a typed passport instead, so there
|
||||||
|
* is nothing proven to copy — that stays the local field-mirroring it has
|
||||||
|
* always been.
|
||||||
|
*/
|
||||||
|
const [gmLinkPending, setGmLinkPending] = useState(false);
|
||||||
|
const toggleGmSameAsOwner = async (checked: boolean) => {
|
||||||
setGmSameAsOwner(checked);
|
setGmSameAsOwner(checked);
|
||||||
if (!checked) {
|
if (!identity?.owner.verified) {
|
||||||
setValue("generalManagerName", "");
|
if (!checked) {
|
||||||
setValue("generalManagerEmail", "");
|
setValue("generalManagerName", "");
|
||||||
setValue("generalManagerPhone", "");
|
setValue("generalManagerEmail", "");
|
||||||
|
setValue("generalManagerPhone", "");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGmLinkPending(true);
|
||||||
|
try {
|
||||||
|
if (checked) await verifaydaService.setGmSameAsOwner();
|
||||||
|
else await verifaydaService.clearGmIdentity();
|
||||||
|
onIdentityChange?.();
|
||||||
|
} catch (err) {
|
||||||
|
setGmSameAsOwner(!checked);
|
||||||
|
setSaveError(
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ??
|
||||||
|
(err instanceof Error ? err.message : "Could not update the general manager"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setGmLinkPending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const gmName = watch("generalManagerName");
|
// Where the GM's details come from depends on how they were established: a
|
||||||
const gmEmail = watch("generalManagerEmail");
|
// Fayda verification (or a "same as owner" declaration) owns them outright,
|
||||||
const gmPhone = watch("generalManagerPhone");
|
// and only a company that may still type them falls back to form state.
|
||||||
|
const gmVerified = identity?.gm.verified ?? false;
|
||||||
|
const gmName = gmVerified
|
||||||
|
? (identity?.gm.name ?? "")
|
||||||
|
: watch("generalManagerName");
|
||||||
|
const gmEmail = gmVerified
|
||||||
|
? (identity?.gm.email ?? "")
|
||||||
|
: watch("generalManagerEmail");
|
||||||
|
const gmPhone = gmVerified
|
||||||
|
? (identity?.gm.phone ?? "")
|
||||||
|
: watch("generalManagerPhone");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the GM has been established at all — by verification, by the
|
||||||
|
* "same as owner" declaration, or (only where Fayda is optional) by typing.
|
||||||
|
* Fayda is an Ethiopian national ID, so a foreign company's GM may hold none.
|
||||||
|
*/
|
||||||
|
const gmTyped = Boolean(
|
||||||
|
watch("generalManagerName")?.trim() &&
|
||||||
|
watch("generalManagerEmail")?.trim() &&
|
||||||
|
watch("generalManagerPhone")?.trim(),
|
||||||
|
);
|
||||||
|
const gmEstablished =
|
||||||
|
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
|
||||||
|
|
||||||
|
/** Same rule for the representative: verified, or typed where Fayda is optional. */
|
||||||
|
const poaEstablished =
|
||||||
|
(identity?.poa.verified ?? false) ||
|
||||||
|
(identity ? !identity.faydaRequired && Boolean(watch("poaName")?.trim()) : false);
|
||||||
|
|
||||||
// While linked, mirror the source values into the (disabled) target fields so
|
// While linked, mirror the source values into the (disabled) target fields so
|
||||||
// the copy stays current even if the user goes back and edits the source.
|
// the copy stays current even if the user goes back and edits the source.
|
||||||
@@ -627,9 +703,23 @@ export default function CompanyProfileForm({
|
|||||||
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (step === "poa" && requirePoa && !identity?.poa.verified) {
|
// The GM is established through Fayda now, so the step gates on the
|
||||||
|
// identity rather than on typed text — same strength as the old required
|
||||||
|
// fields, different evidence. A foreign company's GM may hold no Fayda ID,
|
||||||
|
// so typed details still satisfy it there.
|
||||||
|
if (step === "personnel" && !gmEstablished) {
|
||||||
setSaveError(
|
setSaveError(
|
||||||
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
|
identity?.faydaRequired
|
||||||
|
? "Verify the general manager with Fayda, or tick “same as owner” if they are the company's owner."
|
||||||
|
: "Add the general manager's details, or verify them with Fayda.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "poa" && requirePoa && !poaEstablished) {
|
||||||
|
setSaveError(
|
||||||
|
identity?.faydaRequired
|
||||||
|
? "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda."
|
||||||
|
: "Freight forwarders act on other companies' behalf, so a Power of Attorney is required.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -764,10 +854,11 @@ 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
|
{/* The GM is very often the owner. Where the owner is
|
||||||
verification proves — the owner is (see the Company step).
|
Fayda-verified this reuses that proven identity outright
|
||||||
They're very often the same human, which "same as owner" is
|
rather than making the same human verify twice; where the
|
||||||
for once the owner has verified. */}
|
owner is backed by a typed passport there is nothing proven
|
||||||
|
to copy, so it stays a local prefill. */}
|
||||||
<LinkCheckboxCard
|
<LinkCheckboxCard
|
||||||
checked={gmSameAsOwner}
|
checked={gmSameAsOwner}
|
||||||
onToggle={toggleGmSameAsOwner}
|
onToggle={toggleGmSameAsOwner}
|
||||||
@@ -778,33 +869,53 @@ export default function CompanyProfileForm({
|
|||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
identity?.owner.verified
|
identity?.owner.verified
|
||||||
? "Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
|
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
|
||||||
: etradeOwner
|
: etradeOwner
|
||||||
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
|
? "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."
|
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
|
||||||
label="Name"
|
{/* Verifying a second person is only meaningful when the GM is
|
||||||
placeholder="Abebe Bikila"
|
someone other than the owner. */}
|
||||||
error={errors.generalManagerName?.message}
|
{!gmSameAsOwner && identity && (
|
||||||
{...register("generalManagerName")}
|
<FaydaVerifyPanel
|
||||||
/>
|
subject="gm"
|
||||||
<SimpleGrid cols={2} spacing="md">
|
title="General Manager"
|
||||||
<TextInput
|
state={identity.gm}
|
||||||
label="Email"
|
required={identity.faydaRequired}
|
||||||
type="email"
|
disabled={gmLinkPending}
|
||||||
placeholder="gm@company.com"
|
|
||||||
error={errors.generalManagerEmail?.message}
|
|
||||||
{...register("generalManagerEmail")}
|
|
||||||
/>
|
/>
|
||||||
<ControlledPhoneField
|
)}
|
||||||
control={control}
|
|
||||||
name="generalManagerPhone"
|
{/* Typed details survive only where Fayda cannot be required —
|
||||||
label="Phone"
|
a foreign company's manager may hold no Fayda ID. Once
|
||||||
required
|
verified the API owns these fields, so they go away. */}
|
||||||
/>
|
{!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && (
|
||||||
</SimpleGrid>
|
<>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
placeholder="Abebe Bikila"
|
||||||
|
error={errors.generalManagerName?.message}
|
||||||
|
{...register("generalManagerName")}
|
||||||
|
/>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="gm@company.com"
|
||||||
|
error={errors.generalManagerEmail?.message}
|
||||||
|
{...register("generalManagerEmail")}
|
||||||
|
/>
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="generalManagerPhone"
|
||||||
|
label="Phone"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -872,14 +983,17 @@ export default function CompanyProfileForm({
|
|||||||
required={requirePoa}
|
required={requirePoa}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* The city is the one field the Fayda address claim does not
|
{/* The address comes from the Fayda claim along with the name,
|
||||||
reliably decompose into, so it stays typed. */}
|
so it is shown on the panel rather than typed. Only a company
|
||||||
<TextInput
|
whose representative may hold no Fayda ID still types it. */}
|
||||||
label="PoA Location"
|
{!identity?.poa.verified && !identity?.faydaRequired && (
|
||||||
placeholder="City, Country"
|
<TextInput
|
||||||
error={errors.poaLocation?.message}
|
label="PoA Location"
|
||||||
{...register("poaLocation")}
|
placeholder="City, Country"
|
||||||
/>
|
error={errors.poaLocation?.message}
|
||||||
|
{...register("poaLocation")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* The paper authorises the representative the verification
|
{/* The paper authorises the representative the verification
|
||||||
named, so it only has meaning once one exists. */}
|
named, so it only has meaning once one exists. */}
|
||||||
|
|||||||
@@ -69,12 +69,24 @@ export const onboardingSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.min(1, "Contact person phone is required")
|
.min(1, "Contact person phone is required")
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
.refine(isValidPhone, "Enter a valid phone number"),
|
||||||
generalManagerName: z.string().min(1, "Manager name is required"),
|
// Optional here, not unrequired: the GM is now established by Fayda — either
|
||||||
generalManagerEmail: z.string().email("Invalid Manager email"),
|
// verified in their own right or declared the same person as the owner — so
|
||||||
|
// for an Ethiopian company these fields are never typed and would fail a
|
||||||
|
// blanket `min(1)`. Presence is gated per nationality in the step's own
|
||||||
|
// check, where the identity state is available; zod only polices format for
|
||||||
|
// the foreign companies that still type them.
|
||||||
|
generalManagerName: z.string().optional(),
|
||||||
|
generalManagerEmail: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(v) => !v || z.string().email().safeParse(v).success,
|
||||||
|
"Invalid Manager email",
|
||||||
|
),
|
||||||
generalManagerPhone: z
|
generalManagerPhone: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "Manager phone is required")
|
.optional()
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||||
poaName: z.string().optional(),
|
poaName: z.string().optional(),
|
||||||
poaPhone: z
|
poaPhone: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { 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";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react";
|
import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -15,17 +16,29 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { verifaydaService } from "@/services/verifayda.service";
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
|
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
|
||||||
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
|
|
||||||
|
// Optional, not unrequired: an Ethiopian company's GM is established through
|
||||||
|
// Fayda and never types these, so a blanket `min(1)` would fail a form that is
|
||||||
|
// correct. Presence is gated below, where the identity state says which route
|
||||||
|
// applies; zod only polices format for the companies that still type them.
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
generalManagerName: z.string().min(1, "GM name is required"),
|
generalManagerName: z.string().optional(),
|
||||||
generalManagerEmail: z.string().email("Invalid GM email"),
|
generalManagerEmail: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(v) => !v || z.string().email().safeParse(v).success,
|
||||||
|
"Invalid GM email",
|
||||||
|
),
|
||||||
generalManagerPhone: z
|
generalManagerPhone: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "GM phone is required")
|
.optional()
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
type FormData = z.infer<typeof schema>;
|
||||||
@@ -37,16 +50,20 @@ interface TabGeneralManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The general manager is a plain typed role, not the person the Fayda
|
* The general manager's identity comes from Fayda: either verified in their
|
||||||
* verification proves — the owner is. They're very often the same human,
|
* own right, or declared to be the owner — very often the same human, which is
|
||||||
* which is what "Same as owner" is for: once the owner has verified, this
|
* what "Same as owner" is for. Typed details survive only for a foreign
|
||||||
* copies their name/email/phone in rather than making the customer re-type
|
* company, whose manager may hold no Fayda ID at all.
|
||||||
* 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 owner = profile.identity?.owner;
|
||||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
const identity = profile.identity;
|
||||||
|
const gm = identity?.gm;
|
||||||
|
const faydaRequired = identity?.faydaRequired ?? false;
|
||||||
|
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
||||||
|
identity?.gmSameAsOwner ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
const defaultValues = useMemo((): FormData => {
|
||||||
return {
|
return {
|
||||||
@@ -68,25 +85,45 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
values: defaultValues,
|
values: defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleGmSameAsOwner = (checked: boolean) => {
|
/**
|
||||||
|
* With a Fayda-verified owner the declaration is made server-side — the API
|
||||||
|
* copies the proven identity onto the GM — so nothing is typed here. Without
|
||||||
|
* one (a foreign company, whose owner is backed by a passport) there is
|
||||||
|
* nothing proven to copy and this stays a local prefill.
|
||||||
|
*/
|
||||||
|
const [linkPending, setLinkPending] = useState(false);
|
||||||
|
const [linkError, setLinkError] = useState<string | null>(null);
|
||||||
|
const toggleGmSameAsOwner = async (checked: boolean) => {
|
||||||
setGmSameAsOwner(checked);
|
setGmSameAsOwner(checked);
|
||||||
if (checked && owner) {
|
setLinkError(null);
|
||||||
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
|
if (!owner?.verified) {
|
||||||
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
|
if (checked && owner) {
|
||||||
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
|
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
|
||||||
|
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
|
||||||
|
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLinkPending(true);
|
||||||
|
try {
|
||||||
|
if (checked) await verifaydaService.setGmSameAsOwner();
|
||||||
|
else await verifaydaService.clearGmIdentity();
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setGmSameAsOwner(!checked);
|
||||||
|
setLinkError(
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ??
|
||||||
|
(err instanceof Error ? err.message : "Could not update the general manager"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLinkPending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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({
|
||||||
@@ -102,6 +139,11 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
|
|
||||||
const onSubmit = (data: FormData) => mutation.mutate(data);
|
const onSubmit = (data: FormData) => mutation.mutate(data);
|
||||||
|
|
||||||
|
// Nothing to save when Fayda owns the details: the verification and the
|
||||||
|
// "same as owner" declaration both write server-side, so the form would be
|
||||||
|
// posting empty strings over a proven identity.
|
||||||
|
const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<Group gap="sm" mb="xs">
|
<Group gap="sm" mb="xs">
|
||||||
@@ -114,40 +156,70 @@ 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
|
||||||
<LinkCheckboxCard
|
checked={gmSameAsOwner}
|
||||||
checked={gmSameAsOwner}
|
onToggle={toggleGmSameAsOwner}
|
||||||
onToggle={toggleGmSameAsOwner}
|
title={
|
||||||
title="Same as verified owner"
|
owner?.verified ? "Same as verified owner" : "Same as business owner"
|
||||||
description="Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
|
}
|
||||||
/>
|
description={
|
||||||
)}
|
owner?.verified
|
||||||
<TextInput
|
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
|
||||||
label="Full Name"
|
: "Reuse the owner's name, email and phone. Uncheck to enter different details."
|
||||||
placeholder="Abebe Bikila"
|
}
|
||||||
error={errors.generalManagerName?.message}
|
|
||||||
{...register("generalManagerName")}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Grid>
|
{linkError && (
|
||||||
<Grid.Col span={6}>
|
<Alert color="red" variant="light" icon={<XCircle size={18} />}>
|
||||||
|
{linkError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verifying a second person only means something when the manager
|
||||||
|
is someone other than the owner. */}
|
||||||
|
{!gmSameAsOwner && gm && (
|
||||||
|
<FaydaVerifyPanel
|
||||||
|
subject="gm"
|
||||||
|
title="General Manager"
|
||||||
|
state={gm}
|
||||||
|
required={faydaRequired}
|
||||||
|
disabled={linkPending || mutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Typed details survive only where Fayda cannot be required — a
|
||||||
|
foreign company's manager may hold no Fayda ID. Once verified the
|
||||||
|
API owns these fields and refuses edits, so they go away. */}
|
||||||
|
{!gmSameAsOwner && !gm?.verified && !faydaRequired && (
|
||||||
|
<>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email Address"
|
label="Full Name"
|
||||||
type="email"
|
placeholder="Abebe Bikila"
|
||||||
placeholder="gm@company.com"
|
error={errors.generalManagerName?.message}
|
||||||
error={errors.generalManagerEmail?.message}
|
{...register("generalManagerName")}
|
||||||
{...register("generalManagerEmail")}
|
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
<Grid>
|
||||||
<ControlledPhoneField
|
<Grid.Col span={6}>
|
||||||
control={control}
|
<TextInput
|
||||||
name="generalManagerPhone"
|
label="Email Address"
|
||||||
label="Phone Number"
|
type="email"
|
||||||
required
|
placeholder="gm@company.com"
|
||||||
/>
|
error={errors.generalManagerEmail?.message}
|
||||||
</Grid.Col>
|
{...register("generalManagerEmail")}
|
||||||
</Grid>
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="generalManagerPhone"
|
||||||
|
label="Phone Number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group
|
<Group
|
||||||
@@ -171,7 +243,7 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
{mode === "edit" && (
|
{mode === "edit" && typedFieldsInUse && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -181,13 +253,26 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{/* Saving only means something while the details are typed: under
|
||||||
type="submit"
|
Fayda both routes write server-side, so a submit would post
|
||||||
leftSection={<Save size={16} />}
|
empty strings at an identity the API owns and refuses to
|
||||||
loading={mutation.isPending}
|
overwrite. Onboarding still needs a way forward, so the button
|
||||||
>
|
becomes a plain Continue rather than disappearing. */}
|
||||||
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
{typedFieldsInUse ? (
|
||||||
</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<Save size={16} />}
|
||||||
|
loading={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
mode === "onboarding" && (
|
||||||
|
<Button type="button" onClick={() => onContinue?.()}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -263,19 +263,22 @@ export default function TabPowerOfAttorney({
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Name, email, phone and address are all written by the Fayda
|
{/* Name, email, phone and address all come from the Fayda
|
||||||
verification, so only the city — which the address claim does
|
verification and are shown on the panel above. Only a company
|
||||||
not reliably decompose into — is typed. */}
|
whose representative may hold no Fayda ID still types a
|
||||||
<Grid>
|
location. */}
|
||||||
<Grid.Col span={6}>
|
{!poaProvided && !(identity?.faydaRequired ?? false) && (
|
||||||
<TextInput
|
<Grid>
|
||||||
label="PoA Location"
|
<Grid.Col span={6}>
|
||||||
placeholder="City, Country"
|
<TextInput
|
||||||
error={errors.poaLocation?.message}
|
label="PoA Location"
|
||||||
{...register("poaLocation")}
|
placeholder="City, Country"
|
||||||
/>
|
error={errors.poaLocation?.message}
|
||||||
</Grid.Col>
|
{...register("poaLocation")}
|
||||||
</Grid>
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* ------------------------ Delegation letter ------------------------ */}
|
{/* ------------------------ Delegation letter ------------------------ */}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { unwrap } from "@/utils/endpoint";
|
|||||||
import type { ApiResponse } from "@/types/apiResponse";
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which of the company's people a verification is for. The owner is NOT the
|
* Which of the company's people a verification is for. The owner is who the
|
||||||
* general manager — GM is a plain typed role the portal offers a "same as
|
* company is proven through; the PoA and GM are personnel it names. The GM is
|
||||||
* owner" copy for, but only the owner and the PoA are ever Fayda-verified.
|
* very often the owner — "same as owner" reuses that verification rather than
|
||||||
|
* making the same human prove themselves twice.
|
||||||
*/
|
*/
|
||||||
export type IdentitySubject = "owner" | "poa";
|
export type IdentitySubject = "owner" | "poa" | "gm";
|
||||||
|
|
||||||
/** One person's Fayda verification state, as the API reports it. */
|
/** One person's Fayda verification state, as the API reports it. */
|
||||||
export interface IdentityVerificationState {
|
export interface IdentityVerificationState {
|
||||||
@@ -29,12 +30,24 @@ export interface OwnerIdentityState extends IdentityVerificationState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyIdentityState {
|
export interface CompanyIdentityState {
|
||||||
/** True when Fayda verification of the owner (and PoA) is mandatory — Ethiopian companies only. */
|
/**
|
||||||
|
* True when Fayda verification is mandatory — Ethiopian companies only.
|
||||||
|
* Doubles as "may this person be typed instead": Fayda is an Ethiopian
|
||||||
|
* national ID, so a foreign company's GM and PoA are offered the
|
||||||
|
* verification but fall back to typed details when they hold none.
|
||||||
|
*/
|
||||||
faydaRequired: boolean;
|
faydaRequired: boolean;
|
||||||
/** True when the owner's passport number is mandatory — foreign companies only. */
|
/** True when the owner's passport number is mandatory — foreign companies only. */
|
||||||
passportRequired: boolean;
|
passportRequired: boolean;
|
||||||
owner: OwnerIdentityState;
|
owner: OwnerIdentityState;
|
||||||
poa: IdentityVerificationState;
|
poa: IdentityVerificationState;
|
||||||
|
/**
|
||||||
|
* General manager. `verified` covers both routes: the GM verifying in their
|
||||||
|
* own right, and the company declaring the GM is the owner (in which case
|
||||||
|
* `gmSameAsOwner` is set and the owner's Fayda sub backs it).
|
||||||
|
*/
|
||||||
|
gm: IdentityVerificationState;
|
||||||
|
gmSameAsOwner: boolean;
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +119,30 @@ export const verifaydaService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare the General Manager is the company's owner, reusing the owner's
|
||||||
|
* verified identity rather than making the same human verify twice. The copy
|
||||||
|
* happens server-side from the stored owner identity — the portal never
|
||||||
|
* supplies the values — and is refused until the owner is verified.
|
||||||
|
*/
|
||||||
|
setGmSameAsOwner: async (): Promise<CompanyIdentityState> => {
|
||||||
|
const response = await client.post<ApiResponse<CompanyIdentityState>>(
|
||||||
|
"/api/companies/identity/gm/same-as-owner",
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the GM's identity — the "same as owner" declaration or a verification
|
||||||
|
* of their own — leaving them open to be re-established either way.
|
||||||
|
*/
|
||||||
|
clearGmIdentity: async (): Promise<CompanyIdentityState> => {
|
||||||
|
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
|
||||||
|
"/api/companies/identity/gm",
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop the Power of Attorney — verified identity, details and delegation
|
* Drop the Power of Attorney — verified identity, details and delegation
|
||||||
* paper together. A verified person's fields are locked, so blanking the form
|
* paper together. A verified person's fields are locked, so blanking the form
|
||||||
|
|||||||
@@ -36,11 +36,15 @@ export interface ProfileResponse {
|
|||||||
generalManagerEmail: string | null;
|
generalManagerEmail: string | null;
|
||||||
generalManagerPhone: string | null;
|
generalManagerPhone: string | null;
|
||||||
/**
|
/**
|
||||||
* Fayda verification state for the owner and the PoA — not the general
|
* Fayda verification state for the owner, the PoA and the general manager.
|
||||||
* manager, which stays a plain typed role. `identity.faydaRequired` /
|
* `identity.faydaRequired` / `identity.passportRequired` is the
|
||||||
* `identity.passportRequired` is the Ethiopian/foreign switch: an Ethiopian
|
* Ethiopian/foreign switch: an Ethiopian company verifies all three with
|
||||||
* company verifies the owner (and PoA) with Fayda; a foreign one requires a
|
* Fayda, while a foreign one proves its owner with a typed passport number
|
||||||
* typed passport number for the owner instead.
|
* and may type its GM and PoA, whose holders may have no Fayda ID.
|
||||||
|
*
|
||||||
|
* The `generalManager*` fields above are the same person's details written
|
||||||
|
* flat — a verification keeps them in step, since the booking, contract and
|
||||||
|
* train-scheduling notifiers mail `generalManagerEmail` directly.
|
||||||
*/
|
*/
|
||||||
identity: CompanyIdentityState;
|
identity: CompanyIdentityState;
|
||||||
poaName: string | null;
|
poaName: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user