mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
Merge pull request #1012 from Tria-plc/freight/nati-1
fayda verification and validation
This commit is contained in:
@@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.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 { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||
@@ -188,9 +192,20 @@ export class CompaniesController {
|
||||
@Post("fetch-etrade-info")
|
||||
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
|
||||
async fetchETradeInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: FetchETradeDto,
|
||||
): Promise<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);
|
||||
}
|
||||
|
||||
@@ -378,6 +393,32 @@ export class CompaniesController {
|
||||
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")
|
||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompanyNationality, CompanyStatus } from "./entities/company.entity";
|
||||
import { ProfileType } from "./entities/company-profile.entity";
|
||||
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
|
||||
|
||||
/**
|
||||
* A person's identity is proved through Fayda: name, email, phone and address
|
||||
* come from the verified payload, not typed. Fayda's userinfo carries no
|
||||
* national ID number, so none is collected or derived here.
|
||||
*
|
||||
* - Ethiopian company: the owner (and its PoA, once named) is verified through
|
||||
* Fayda, and their details can't be edited afterwards.
|
||||
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
|
||||
* supplies a typed passport number — required on its own, whether or not the
|
||||
* owner also completes a (purely optional) Fayda verification.
|
||||
*
|
||||
* The owner is NOT the general manager — GM is a separate, plain typed role
|
||||
* the portal offers a "same as owner" copy for, but it is never itself
|
||||
* Fayda-verified or gated on.
|
||||
*/
|
||||
|
||||
interface Ctx {
|
||||
attributes: Record<string, unknown>;
|
||||
files: { id: string; code: string; reviewStatus?: string | null }[];
|
||||
profileTypes: ProfileType[];
|
||||
status: CompanyStatus;
|
||||
nationality: CompanyNationality;
|
||||
verification: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const OWNER_VERIFIED = {
|
||||
ownerFaydaSub: "owner-sub",
|
||||
ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z",
|
||||
ownerName: "Abebe Bikila",
|
||||
};
|
||||
|
||||
const POA_VERIFIED = {
|
||||
poaFaydaSub: "poa-sub",
|
||||
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
|
||||
poaName: "Tirunesh Dibaba",
|
||||
poaEmail: "tirunesh@example.com",
|
||||
poaPhone: "+251911000000",
|
||||
};
|
||||
|
||||
const paper = () => ({
|
||||
id: "file-1",
|
||||
code: POA_DELEGATION_FILE_KEY,
|
||||
reviewStatus: null,
|
||||
});
|
||||
|
||||
function makeService(overrides: Partial<Ctx> = {}) {
|
||||
const ctx: Ctx = {
|
||||
attributes: {},
|
||||
files: [],
|
||||
profileTypes: [ProfileType.importer],
|
||||
status: CompanyStatus.Pending,
|
||||
nationality: CompanyNationality.Ethiopian,
|
||||
verification: {
|
||||
purpose: "VERIFY",
|
||||
verified: true,
|
||||
sub: "new-sub",
|
||||
fullName: "Haile Gebrselassie",
|
||||
email: "haile@example.com",
|
||||
phoneNumber: "+251922000000",
|
||||
address: "Addis Ababa",
|
||||
birthdate: "1973-04-18",
|
||||
gender: "Male",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const company = () => ({
|
||||
id: "company-1",
|
||||
status: ctx.status,
|
||||
nationality: ctx.nationality,
|
||||
attributes: ctx.attributes,
|
||||
companyProfiles: ctx.profileTypes.map((type, i) => ({
|
||||
id: `profile-${i}`,
|
||||
type,
|
||||
})),
|
||||
type: "customer",
|
||||
});
|
||||
|
||||
const deps = {
|
||||
companiesRepo: {
|
||||
findById: jest.fn(async () => company()),
|
||||
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||
if (patch.attributes)
|
||||
ctx.attributes = patch.attributes as Record<string, unknown>;
|
||||
return company();
|
||||
}),
|
||||
findByTin: jest.fn(async () => null),
|
||||
},
|
||||
companyProfilesRepo: {
|
||||
findByCompanyId: jest.fn(async () =>
|
||||
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
|
||||
),
|
||||
findByType: jest.fn(async (_id: string, type: ProfileType) =>
|
||||
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
|
||||
),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "new",
|
||||
...row,
|
||||
})),
|
||||
},
|
||||
changeRequestRepo: {
|
||||
findPendingByCompanyId: jest.fn(async () => null),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "cr-1",
|
||||
...row,
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
id: "external-1",
|
||||
companyId: "company-1",
|
||||
company: company(),
|
||||
onboardingCompleted: false,
|
||||
})),
|
||||
},
|
||||
filesService: {
|
||||
findByResource: jest.fn(async () => ctx.files),
|
||||
findById: jest.fn(async () => null),
|
||||
remove: jest.fn(async () => undefined),
|
||||
},
|
||||
companyNotifier: { changeRequestSubmitted: jest.fn() },
|
||||
verifayda: {
|
||||
completeVerification: jest.fn(async () => ctx.verification),
|
||||
},
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
deps.verifayda as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: company() }) as never,
|
||||
);
|
||||
|
||||
return { service, ctx, deps, company };
|
||||
}
|
||||
|
||||
describe("Fayda identity verification binds a person to the company", () => {
|
||||
it("writes the verified identity", async () => {
|
||||
const { service, ctx } = makeService();
|
||||
|
||||
const state = await service.completeIdentityVerification("user-1", {
|
||||
subject: "owner",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(ctx.attributes.ownerFaydaSub).toBe("new-sub");
|
||||
expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie");
|
||||
expect(state.owner.verified).toBe(true);
|
||||
});
|
||||
|
||||
it("fills every PoA detail from the payload, address included", async () => {
|
||||
const { service, ctx } = makeService();
|
||||
|
||||
await service.completeIdentityVerification("user-1", {
|
||||
subject: "poa",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(ctx.attributes.poaName).toBe("Haile Gebrselassie");
|
||||
expect(ctx.attributes.poaEmail).toBe("haile@example.com");
|
||||
expect(ctx.attributes.poaPhone).toBe("+251922000000");
|
||||
expect(ctx.attributes.poaAddress).toBe("Addis Ababa");
|
||||
});
|
||||
|
||||
it("verifies successfully even though Fayda returns no national ID number", async () => {
|
||||
// Fayda's userinfo carries no FAN/FIN claim at all — this must be the
|
||||
// normal, successful path, not an error.
|
||||
const { service } = makeService({
|
||||
verification: {
|
||||
purpose: "VERIFY",
|
||||
verified: true,
|
||||
sub: "x",
|
||||
fullName: "No Fan Here",
|
||||
},
|
||||
});
|
||||
|
||||
const state = await service.completeIdentityVerification("user-1", {
|
||||
subject: "owner",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(state.owner.verified).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to make one identity both owner and PoA", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ownerFaydaSub: "same-person" },
|
||||
verification: {
|
||||
purpose: "VERIFY",
|
||||
verified: true,
|
||||
sub: "same-person",
|
||||
fullName: "Abebe Bikila",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.completeIdentityVerification("user-1", {
|
||||
subject: "poa",
|
||||
code: "c",
|
||||
state: "s",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("stages the change for review on an approved company", async () => {
|
||||
// Swapping the person who can act for a live company is exactly what the
|
||||
// backoffice review exists for, so it must not rewrite the row directly.
|
||||
const { service, ctx, deps } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
|
||||
await service.completeIdentityVerification("user-1", {
|
||||
subject: "poa",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
||||
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to rename a verified person by hand", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("never locks or gates the general manager — it is not the verified subject", async () => {
|
||||
// GM is a plain typed role; the portal offers a "same as owner" copy, but
|
||||
// the backend must not treat it as identity-owned or require it verified.
|
||||
const { service } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", {
|
||||
generalManagerName: "Someone Else",
|
||||
generalManagerEmail: "someone@example.com",
|
||||
generalManagerPhone: "+251911223344",
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => {
|
||||
// The company is applying for the forwarder role, so it must not already
|
||||
// hold it — createCompanyProfileForUser short-circuits on an existing profile
|
||||
// and would never reach the gate.
|
||||
const applyingForFf = {
|
||||
profileTypes: [ProfileType.importer],
|
||||
attributes: { ...POA_VERIFIED },
|
||||
files: [paper()],
|
||||
};
|
||||
|
||||
it("blocks the forwarder role while the owner is unverified", async () => {
|
||||
const { service } = makeService(applyingForFf);
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("blocks the forwarder role while the PoA is unverified", async () => {
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
poaName: "Tirunesh Dibaba",
|
||||
poaEmail: "t@example.com",
|
||||
poaPhone: "+251911000000",
|
||||
},
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("grants the forwarder role once owner and PoA are both verified", async () => {
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("never asks a foreign company for Fayda, verified or not", async () => {
|
||||
const { service } = makeService({
|
||||
nationality: CompanyNationality.Foreign,
|
||||
});
|
||||
|
||||
const state = await service.completeIdentityVerification("user-1", {
|
||||
subject: "owner",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
// Still lets the owner verify — a foreign owner verifying is allowed, just
|
||||
// never required — but the passport is the thing that actually gates it.
|
||||
expect(state.owner.verified).toBe(true);
|
||||
expect(state.faydaRequired).toBe(false);
|
||||
expect(state.passportRequired).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks the forwarder role for a foreign company with no owner passport", async () => {
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: {
|
||||
poaName: "Jean Dupont",
|
||||
poaEmail: "jean@example.com",
|
||||
poaPhone: "+33100000000",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: {
|
||||
ownerPassportNumber: "P1234567",
|
||||
poaName: "Jean Dupont",
|
||||
poaEmail: "jean@example.com",
|
||||
poaPhone: "+33100000000",
|
||||
},
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
||||
// Verifying is optional for a foreign owner, but it does not waive the
|
||||
// passport requirement — the two are independent credentials.
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
poaName: "Jean Dupont",
|
||||
poaEmail: "jean@example.com",
|
||||
poaPhone: "+33100000000",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -38,6 +39,8 @@ import { CompanyNotifierService } from "./company-notifier.service";
|
||||
// imports this module back for portal recipient targeting, hence forwardRef.
|
||||
NotificationsModule,
|
||||
forwardRef(() => NotificationInboxModule),
|
||||
// Fayda identity verification for the company's owner and PoA.
|
||||
VerifaydaModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompanyStatus } from "./entities/company.entity";
|
||||
import { ProfileType } from "./entities/company-profile.entity";
|
||||
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
|
||||
|
||||
/**
|
||||
* EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS
|
||||
* delegation paper on file. The rule used to live only in the onboarding
|
||||
* wizard's completion check, so every other write that could break the pairing
|
||||
* — saving PoA details, deleting the paper, picking up the forwarder role —
|
||||
* went unguarded. These cover those writes.
|
||||
*/
|
||||
|
||||
interface Ctx {
|
||||
attributes: Record<string, unknown>;
|
||||
files: { id: string; code: string; reviewStatus?: string | null }[];
|
||||
profileTypes: ProfileType[];
|
||||
status: CompanyStatus;
|
||||
pendingSnapshot: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" };
|
||||
|
||||
/**
|
||||
* The forwarder role is gated on Fayda-verified identities as well as on the
|
||||
* delegation paper. These tests are about the paper, so they run against a
|
||||
* company whose identities are already verified — the identity rule itself is
|
||||
* covered in companies.fayda-identity.spec.ts.
|
||||
*/
|
||||
const VERIFIED_IDENTITIES = {
|
||||
ownerFaydaSub: "owner-sub",
|
||||
poaFaydaSub: "poa-sub",
|
||||
};
|
||||
|
||||
function makeService(overrides: Partial<Ctx> = {}) {
|
||||
const ctx: Ctx = {
|
||||
attributes: {},
|
||||
files: [],
|
||||
profileTypes: [ProfileType.importer],
|
||||
status: CompanyStatus.Pending,
|
||||
pendingSnapshot: null,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const company = () => ({
|
||||
id: "company-1",
|
||||
status: ctx.status,
|
||||
attributes: ctx.attributes,
|
||||
companyProfiles: ctx.profileTypes.map((type, i) => ({
|
||||
id: `profile-${i}`,
|
||||
type,
|
||||
})),
|
||||
type: "customer",
|
||||
});
|
||||
|
||||
const deps = {
|
||||
companiesRepo: {
|
||||
findById: jest.fn(async () => company()),
|
||||
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||
ctx.attributes = (patch.attributes ??
|
||||
ctx.attributes) as Record<string, unknown>;
|
||||
return company();
|
||||
}),
|
||||
findByTin: jest.fn(async () => null),
|
||||
},
|
||||
companyProfilesRepo: {
|
||||
findByCompanyId: jest.fn(async () =>
|
||||
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
|
||||
),
|
||||
findByType: jest.fn(async (_id: string, type: ProfileType) =>
|
||||
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
|
||||
),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "new",
|
||||
...row,
|
||||
})),
|
||||
},
|
||||
changeRequestRepo: {
|
||||
findPendingByCompanyId: jest.fn(async () =>
|
||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||
),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "cr-1",
|
||||
...row,
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
id: "external-1",
|
||||
companyId: "company-1",
|
||||
company: company(),
|
||||
onboardingCompleted: false,
|
||||
})),
|
||||
},
|
||||
filesService: {
|
||||
findByResource: jest.fn(async () => ctx.files),
|
||||
findById: jest.fn(async (id: string) =>
|
||||
ctx.files.find((f) => f.id === id)
|
||||
? {
|
||||
...ctx.files.find((f) => f.id === id),
|
||||
resource: "companies",
|
||||
resourceId: "company-1",
|
||||
name: "dars.pdf",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
remove: jest.fn(async () => undefined),
|
||||
},
|
||||
companyNotifier: { changeRequestSubmitted: jest.fn() },
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for
|
||||
// the PoA paths, so short-circuit it rather than mock the whole graph.
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: company() }) as never,
|
||||
);
|
||||
|
||||
return { service, ctx, deps };
|
||||
}
|
||||
|
||||
const paper = (reviewStatus: string | null = null) => ({
|
||||
id: "file-1",
|
||||
code: POA_DELEGATION_FILE_KEY,
|
||||
reviewStatus,
|
||||
});
|
||||
|
||||
describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
it("rejects PoA details saved with no paper on file", async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", POA as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("accepts PoA details once the paper is on file", async () => {
|
||||
const { service } = makeService({ files: [paper()] });
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", POA as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects a paper the reviewer sent back for correction", async () => {
|
||||
const { service } = makeService({ files: [paper("change_requested")] });
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", POA as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("leaves edits that don't touch the PoA alone", async () => {
|
||||
// A company carrying legacy details must not be locked out of every other
|
||||
// field until it produces a paper.
|
||||
const { service } = makeService({ attributes: { ...POA }, files: [] });
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", { companyEmail: "x@y.com" } as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses to remove the paper while the PoA is still named", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ...POA },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("allows removing the paper once the PoA has been cleared", async () => {
|
||||
const { service } = makeService({ attributes: {}, files: [paper()] });
|
||||
|
||||
await expect(
|
||||
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("judges the removal against a staged clear, not the live row", async () => {
|
||||
// An Active company's edits are staged for review rather than written, so
|
||||
// the live attributes still carry the PoA the customer just cleared.
|
||||
const { service } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
attributes: { ...POA },
|
||||
pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses the forwarder role to a company with no PoA", async () => {
|
||||
const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } });
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("grants the forwarder role once PoA details and paper are both in place", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ...POA, ...VERIFIED_IDENTITIES },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async existsByTin(tin: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { tin } as any });
|
||||
async existsByTin(tin: string, excludeCompanyId?: string): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompanyType } from "./entities/company.entity";
|
||||
import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* EDRFREIGHT-416: onboarding asked for a deselected role's documents.
|
||||
*
|
||||
* Re-running role selection used to only ADD operational profiles, so a role
|
||||
* the user unticked on the way back left its company_profile row behind — and
|
||||
* every role-driven requirement (business license, forwarder PoA) is derived
|
||||
* from those rows. startOnboarding now reconciles both directions.
|
||||
*/
|
||||
|
||||
interface ExistingProfile {
|
||||
id: string;
|
||||
type: ProfileType;
|
||||
status: ProfileStatus;
|
||||
}
|
||||
|
||||
function makeService(existing: ExistingProfile[]) {
|
||||
const companyProfilesRepo = {
|
||||
findByCompanyId: jest.fn(async () => existing),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "new",
|
||||
...row,
|
||||
})),
|
||||
softDelete: jest.fn(async () => undefined),
|
||||
};
|
||||
const companiesRepo = { update: jest.fn(async () => null) };
|
||||
const profilesRepo = {
|
||||
findByUserId: jest.fn(async () => ({
|
||||
id: "external-1",
|
||||
companyId: "company-1",
|
||||
company: { id: "company-1" },
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
companiesRepo as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
profilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
|
||||
);
|
||||
|
||||
return { service, companyProfilesRepo };
|
||||
}
|
||||
|
||||
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
|
||||
|
||||
const start = (service: CompaniesService, roles: ProfileType[]) =>
|
||||
service.startOnboarding(identity as never, CompanyType.Customer, roles);
|
||||
|
||||
describe("re-running role selection reconciles the operational profiles", () => {
|
||||
it("drops the profile for a role the user deselected", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
{
|
||||
id: "p-ff",
|
||||
type: ProfileType.freightForwarder,
|
||||
status: ProfileStatus.Pending,
|
||||
},
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).toHaveBeenCalledWith("p-ff");
|
||||
expect(companyProfilesRepo.softDelete).toHaveBeenCalledTimes(1);
|
||||
expect(companyProfilesRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an already-approved profile even when it is unticked", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
{
|
||||
id: "p-exp",
|
||||
type: ProfileType.exporter,
|
||||
status: ProfileStatus.Active,
|
||||
},
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still adds a newly-picked role", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer, ProfileType.exporter]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
|
||||
expect(companyProfilesRepo.create).toHaveBeenCalledTimes(1);
|
||||
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: ProfileType.exporter }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,23 @@ import {
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
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 { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
@@ -58,10 +71,6 @@ const LICENSE_CODE = "business_license";
|
||||
/** Code for a license file staged in an open change request (not yet live). */
|
||||
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. */
|
||||
const COMPANY_RESOURCE = "companies";
|
||||
/** 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" },
|
||||
];
|
||||
|
||||
/**
|
||||
* `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 {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
@@ -100,6 +165,7 @@ export class CompaniesService {
|
||||
private readonly etradeService: ETradeService,
|
||||
private readonly companyNotifier: CompanyNotifierService,
|
||||
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
|
||||
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
|
||||
*
|
||||
* Idempotent: if the user already has a profile, returns it unchanged (only
|
||||
* adding any newly-chosen roles). The draft company carries a placeholder TIN
|
||||
* Idempotent: if the user already has a profile, returns it unchanged, with
|
||||
* the operational profiles reconciled against the roles just chosen (added
|
||||
* and — for still-pending ones — removed). The draft company carries a placeholder TIN
|
||||
* (the real one is filled on the Company Information step) and stays
|
||||
* status=pending / onboardingCompleted=false until the wizard finishes.
|
||||
*/
|
||||
@@ -271,7 +338,7 @@ export class CompaniesService {
|
||||
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||
if (existing) {
|
||||
const companyId = existing.company?.id ?? existing.companyId;
|
||||
await this.ensureCompanyProfiles(companyId, companyType, roles);
|
||||
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||
if (nationality) {
|
||||
await this.companiesRepo.update(companyId, { nationality });
|
||||
}
|
||||
@@ -302,25 +369,44 @@ export class CompaniesService {
|
||||
onboardingCompleted: false,
|
||||
});
|
||||
|
||||
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
|
||||
await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
|
||||
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
/** Create any of the requested operational profiles that don't exist yet. */
|
||||
private async ensureCompanyProfiles(
|
||||
/**
|
||||
* Reconcile the company's operational profiles with the roles the user has
|
||||
* selected: create the missing ones, drop the ones they deselected.
|
||||
*
|
||||
* Dropping matters because every role-driven onboarding requirement — the
|
||||
* per-profile business license, the freight-forwarder PoA rule, the license
|
||||
* cards in the wizard — is derived from these rows. A row left behind after
|
||||
* the user went back and unticked a role keeps asking for that role's
|
||||
* documents (EDRFREIGHT-416). Only still-pending profiles are removed: an
|
||||
* approved one is live (it can carry bookings and contracts) and re-running
|
||||
* role selection must never delete it.
|
||||
*/
|
||||
private async syncCompanyProfiles(
|
||||
companyId: string,
|
||||
companyType: CompanyType,
|
||||
roles: ProfileType[],
|
||||
): Promise<void> {
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
for (const type of roles) {
|
||||
if (!allowedTypes.includes(type)) continue;
|
||||
const existing = await this.companyProfilesRepo.findByType(
|
||||
companyId,
|
||||
type,
|
||||
);
|
||||
if (existing) continue;
|
||||
const chosen = roles.filter((t) => allowedTypes.includes(t));
|
||||
const existing = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
|
||||
for (const profile of existing) {
|
||||
if (chosen.includes(profile.type)) continue;
|
||||
if (profile.status !== ProfileStatus.Pending) continue;
|
||||
// The license files uploaded against this profile go with it: they are
|
||||
// only ever read per company_profile id, so a soft-deleted profile
|
||||
// leaves nothing behind to prompt for. Re-picking the role creates a
|
||||
// fresh profile the user uploads against again.
|
||||
await this.companyProfilesRepo.softDelete(profile.id);
|
||||
}
|
||||
|
||||
for (const type of chosen) {
|
||||
if (existing.some((p) => p.type === type)) continue;
|
||||
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
@@ -599,7 +685,9 @@ export class CompaniesService {
|
||||
*/
|
||||
private mapProfileDtoToCompanyUpdates(
|
||||
company: Company,
|
||||
dto: Partial<UpdateProfileDto>,
|
||||
dto: Partial<UpdateProfileDto> & {
|
||||
faydaIdentity?: VerifiedIdentityAttributes;
|
||||
},
|
||||
): Record<string, any> {
|
||||
const companyUpdates: Record<string, any> = {};
|
||||
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
||||
@@ -617,7 +705,6 @@ export class CompaniesService {
|
||||
if (dto.tin !== undefined && dto.tin !== company.tin)
|
||||
companyUpdates.tin = dto.tin;
|
||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||
if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
|
||||
|
||||
if (dto.contactPersonName !== undefined)
|
||||
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||
@@ -661,6 +748,72 @@ export class CompaniesService {
|
||||
if (dto.etradePhone !== undefined)
|
||||
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;
|
||||
return companyUpdates;
|
||||
}
|
||||
@@ -702,6 +855,21 @@ export class CompaniesService {
|
||||
): Promise<ProfileResponseDto> {
|
||||
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) {
|
||||
await this.assertTinAvailable(company, dto.tin);
|
||||
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
|
||||
// account.
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.findOne(Company, {
|
||||
const company = await manager.findOne(Company, {
|
||||
where: { id: existing.companyId },
|
||||
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([
|
||||
this.filesService.findWithOpenChangeRequest(
|
||||
[existing.companyId],
|
||||
@@ -1382,6 +1563,18 @@ export class CompaniesService {
|
||||
);
|
||||
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
|
||||
// is minted only when a backoffice reviewer approves the role.
|
||||
await this.companyProfilesRepo.create({
|
||||
@@ -1419,6 +1612,14 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
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) {
|
||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||
// carry no reference until approved.
|
||||
@@ -1453,11 +1654,17 @@ export class CompaniesService {
|
||||
userId: string,
|
||||
): Promise<OnboardingRequirementsResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
const identity = this.getCompanyIdentityState(company);
|
||||
|
||||
// 1. Required company-information fields.
|
||||
const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
|
||||
(f) => !f.get(company),
|
||||
).map((f) => ({ key: f.key, label: f.label }));
|
||||
// 1. Required company-information fields. The FAN is never one of them —
|
||||
// Fayda verification doesn't produce a FAN, so it's never collected as
|
||||
// part of onboarding at all (see the identity block below).
|
||||
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.
|
||||
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
|
||||
// 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(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
);
|
||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
const missingPoaFields = poaRequired
|
||||
? REQUIRED_POA_FIELDS.filter(
|
||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||
)
|
||||
: [];
|
||||
// Only gate on the letter once the document set actually carries the field.
|
||||
const delegationField = (setting?.fields ?? []).find(
|
||||
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
|
||||
);
|
||||
const missingDelegation =
|
||||
Boolean(delegationField) &&
|
||||
(poaRequired || poaProvided) &&
|
||||
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
|
||||
// An Ethiopian company does not type its PoA details at all — they arrive
|
||||
// from the Fayda verification — so reporting them as missing fields would
|
||||
// ask for something the form no longer offers. The identity block below
|
||||
// reports "verify your PoA" instead.
|
||||
const missingPoaFields =
|
||||
poaRequired && !identity.faydaRequired
|
||||
? REQUIRED_POA_FIELDS.filter(
|
||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||
)
|
||||
: [];
|
||||
const delegation = await this.getPoaDelegationState(company.id);
|
||||
const delegationDue = poaRequired || poaProvided;
|
||||
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 = [
|
||||
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
@@ -1534,29 +1746,62 @@ export class CompaniesService {
|
||||
),
|
||||
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...(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
|
||||
// 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 poaItemCount =
|
||||
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
|
||||
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
|
||||
(poaRequired && !identity.faydaRequired
|
||||
? 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 =
|
||||
this.REQUIRED_COMPANY_INFO.length +
|
||||
requiredInfo.length +
|
||||
requiredDocCount +
|
||||
licenseProfiles.length +
|
||||
poaItemCount;
|
||||
poaItemCount +
|
||||
identityItemCount;
|
||||
const completed =
|
||||
total -
|
||||
(missingInfo.length +
|
||||
missingDocs.length +
|
||||
missingLicenses.length +
|
||||
missingPoaFields.length +
|
||||
(missingDelegation ? 1 : 0));
|
||||
(missingDelegation || flaggedDelegation ? 1 : 0) +
|
||||
missingIdentityCount);
|
||||
|
||||
return new OnboardingRequirementsResponseDto({
|
||||
documentSettingCode,
|
||||
@@ -1567,10 +1812,15 @@ export class CompaniesService {
|
||||
poa: {
|
||||
required: poaRequired,
|
||||
provided: poaProvided,
|
||||
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
|
||||
delegationLetterUploaded: delegation.onFile,
|
||||
delegationLetterFlagged: delegation.flagged,
|
||||
missingFields: missingPoaFields,
|
||||
complete: missingPoaFields.length === 0 && !missingDelegation,
|
||||
complete:
|
||||
missingPoaFields.length === 0 &&
|
||||
!missingDelegation &&
|
||||
!flaggedDelegation,
|
||||
},
|
||||
identity,
|
||||
progress: { completed, total },
|
||||
isComplete: outstanding.length === 0,
|
||||
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
|
||||
// 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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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(
|
||||
userId: string,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
@@ -2149,6 +2732,18 @@ export class CompaniesService {
|
||||
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) {
|
||||
await this.filesService.remove(fileId);
|
||||
await this.withdrawDocumentIntent(company.id, fileId);
|
||||
@@ -2328,7 +2923,10 @@ export class CompaniesService {
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
async fetchETradeData(tin: string) {
|
||||
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
|
||||
private async resolveEtradeRegistration(
|
||||
tin: string,
|
||||
): Promise<CompanyRegistrationData> {
|
||||
const { businessInfo, companyInfo } =
|
||||
await this.etradeService.resolveCompanyData(tin);
|
||||
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.",
|
||||
);
|
||||
}
|
||||
const registrationData = this.etradeService.extractRegistrationData(
|
||||
businessInfo,
|
||||
companyInfo,
|
||||
return this.etradeService.extractRegistrationData(businessInfo, companyInfo);
|
||||
}
|
||||
|
||||
async fetchETradeData(tin: string, excludeCompanyId?: string) {
|
||||
const registrationData = await this.resolveEtradeRegistration(tin);
|
||||
const tinTaken = await this.companiesRepo.existsByTin(
|
||||
tin,
|
||||
excludeCompanyId,
|
||||
);
|
||||
const tinTaken = await this.companiesRepo.existsByTin(tin);
|
||||
return { ...registrationData, tinTaken };
|
||||
}
|
||||
|
||||
/**
|
||||
* An eTrade-sourced field can only ever hold what a fresh eTrade lookup for
|
||||
* this TIN actually returns — the portal never lets the customer type these
|
||||
* once eTrade has supplied them, so a mismatch here means either stale
|
||||
* client state or a hand-crafted request, and either way the write is
|
||||
* refused rather than silently trusting it.
|
||||
*/
|
||||
private async assertEtradeFieldsAuthentic(
|
||||
company: Company,
|
||||
dto: UpdateProfileDto,
|
||||
): Promise<void> {
|
||||
const touched = ETRADE_SOURCED_FIELDS.some(
|
||||
(key) => dto[key] !== undefined,
|
||||
);
|
||||
if (!touched) return;
|
||||
|
||||
const tin = dto.tin ?? company.tin;
|
||||
const registration = await this.resolveEtradeRegistration(tin);
|
||||
const expected: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
|
||||
companyName: registration.companyName,
|
||||
licenceNumber: registration.licenceNumber,
|
||||
statusDescription: registration.statusDescription,
|
||||
dateRegistered: registration.dateRegistered,
|
||||
renewedFrom: registration.renewedFrom,
|
||||
renewalDate: registration.renewalDate,
|
||||
renewedTo: registration.renewedTo,
|
||||
region: registration.region,
|
||||
zone: registration.zone,
|
||||
woreda: registration.woreda,
|
||||
kebele: registration.kebele,
|
||||
houseNo: registration.houseNo,
|
||||
etradePhone:
|
||||
registration.managerPhone ||
|
||||
registration.regularPhone ||
|
||||
registration.mobilePhone,
|
||||
};
|
||||
|
||||
for (const key of ETRADE_SOURCED_FIELDS) {
|
||||
const submitted = dto[key];
|
||||
if (submitted === undefined) continue;
|
||||
const source = expected[key];
|
||||
// eTrade left this field blank — the onboarding/settings card falls back
|
||||
// to letting the customer type it directly, so nothing to check against.
|
||||
if (!source) continue;
|
||||
const same =
|
||||
key === "etradePhone"
|
||||
? normalizeE164(String(submitted)) === normalizeE164(source)
|
||||
: submitted === source;
|
||||
if (!same) {
|
||||
throw new BadRequestException(
|
||||
`${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn, IsString, IsNotEmpty } from "class-validator";
|
||||
|
||||
import { Company, CompanyNationality } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* The two people a company is verified through — its owner and its Power of
|
||||
* Attorney. "Owner" is not the same as the General Manager: a company's GM is
|
||||
* a plain typed role (with a "same as owner" copy the portal offers), while
|
||||
* the owner is the person this verification proves. They're very often the
|
||||
* same human, which is exactly what the copy is for.
|
||||
*/
|
||||
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
|
||||
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
||||
|
||||
export class CompleteIdentityVerificationDto {
|
||||
@ApiProperty({
|
||||
enum: IDENTITY_SUBJECTS,
|
||||
description: "Which of the company's people this verification is for.",
|
||||
})
|
||||
@IsIn(IDENTITY_SUBJECTS)
|
||||
subject!: IdentitySubject;
|
||||
|
||||
@ApiProperty({ description: "Authorization code from the Fayda redirect." })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ description: "CSRF state from the Fayda redirect." })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
state!: string;
|
||||
}
|
||||
|
||||
/** One person's verification state, as reported back to the portal. */
|
||||
export class IdentityVerificationStateDto {
|
||||
@ApiProperty() verified!: boolean;
|
||||
@ApiProperty({ nullable: true }) name!: string | null;
|
||||
@ApiProperty({ nullable: true }) phone!: string | null;
|
||||
@ApiProperty({ nullable: true }) email!: string | null;
|
||||
@ApiProperty({ nullable: true }) address!: string | null;
|
||||
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
|
||||
@ApiProperty({ nullable: true }) birthdate!: string | null;
|
||||
@ApiProperty({ nullable: true }) gender!: string | null;
|
||||
}
|
||||
|
||||
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
description:
|
||||
"Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
|
||||
})
|
||||
passportNumber!: string | null;
|
||||
}
|
||||
|
||||
export class CompanyIdentityStateDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
|
||||
})
|
||||
faydaRequired!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
|
||||
})
|
||||
passportRequired!: boolean;
|
||||
|
||||
@ApiProperty({ type: OwnerIdentityStateDto })
|
||||
owner!: OwnerIdentityStateDto;
|
||||
|
||||
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||
poa!: IdentityVerificationStateDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
|
||||
})
|
||||
complete!: boolean;
|
||||
}
|
||||
|
||||
/** `attributes` key prefix per person. */
|
||||
const PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
||||
owner: "owner",
|
||||
poa: "poa",
|
||||
};
|
||||
|
||||
/** company.attributes keys that together mean "a PoA was entered". */
|
||||
const POA_KEYS = [
|
||||
"poaName",
|
||||
"poaPhone",
|
||||
"poaEmail",
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
] as const;
|
||||
|
||||
function stateFor(
|
||||
attrs: Record<string, unknown>,
|
||||
subject: IdentitySubject,
|
||||
): IdentityVerificationStateDto {
|
||||
const p = PREFIX[subject];
|
||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||
return {
|
||||
verified: Boolean(read(`${p}FaydaSub`)),
|
||||
name: read(`${p}Name`),
|
||||
phone: read(`${p}Phone`),
|
||||
email: read(`${p}Email`),
|
||||
address: read(`${p}Address`),
|
||||
verifiedAt: read(`${p}FaydaVerifiedAt`),
|
||||
birthdate: read(`${p}Birthdate`),
|
||||
gender: read(`${p}Gender`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive both people's verification state from the company row.
|
||||
*
|
||||
* Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
|
||||
* renders from it, so the settings page and the onboarding wizard can never
|
||||
* disagree with the rule the API actually enforces.
|
||||
*/
|
||||
export function buildCompanyIdentityState(
|
||||
company: Company,
|
||||
): CompanyIdentityStateDto {
|
||||
const attrs = company.attributes ?? {};
|
||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||
|
||||
// Fayda is an Ethiopian national ID — a foreign company's owner may not hold
|
||||
// one, so a typed passport number is the mandatory credential there instead.
|
||||
// The two are mutually exclusive by nationality but independently tracked,
|
||||
// since a foreign owner verifying with Fayda doesn't waive the passport.
|
||||
const foreign = company.nationality === CompanyNationality.Foreign;
|
||||
const faydaRequired = !foreign;
|
||||
const passportRequired = foreign;
|
||||
|
||||
const owner: OwnerIdentityStateDto = {
|
||||
...stateFor(attrs, "owner"),
|
||||
passportNumber: read("ownerPassportNumber"),
|
||||
};
|
||||
const poa = stateFor(attrs, "poa");
|
||||
const poaDue =
|
||||
(company.companyProfiles ?? []).some(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||
|
||||
const complete = faydaRequired
|
||||
? owner.verified && (!poaDue || poa.verified)
|
||||
: !passportRequired || Boolean(owner.passportNumber);
|
||||
|
||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
* truth the wizard uses to auto-finish.
|
||||
*/
|
||||
|
||||
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
|
||||
|
||||
export interface OnboardingInfoField {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -40,11 +42,13 @@ export interface OnboardingPoaState {
|
||||
required: boolean;
|
||||
/** True once any PoA detail has been entered. */
|
||||
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;
|
||||
/** True when a reviewer sent the paper back for correction. */
|
||||
delegationLetterFlagged: boolean;
|
||||
/** PoA details still missing (only populated when `required`). */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto {
|
||||
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
|
||||
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. */
|
||||
progress: { completed: number; total: number };
|
||||
|
||||
@@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto {
|
||||
this.documents = init.documents;
|
||||
this.licenseProfiles = init.licenseProfiles;
|
||||
this.poa = init.poa;
|
||||
this.identity = init.identity;
|
||||
this.progress = init.progress;
|
||||
this.isComplete = init.isComplete;
|
||||
this.onboardingCompleted = init.onboardingCompleted;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
} from "./complete-identity-verification.dto";
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import {
|
||||
@@ -52,6 +56,16 @@ export class ProfileResponseDto {
|
||||
|
||||
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
|
||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
||||
@@ -124,5 +138,6 @@ export class ProfileResponseDto {
|
||||
: null;
|
||||
this.reviewNote = openReview?.note ?? null;
|
||||
this.pendingChanges = openReview?.snapshot ?? null;
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
ProfileLicenseFileView,
|
||||
} from '../entities/company-profile.entity';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
} from './complete-identity-verification.dto';
|
||||
|
||||
export class ResponseCompanyProfileDto {
|
||||
id: string;
|
||||
@@ -69,6 +73,28 @@ export class ResponseCompanyDto {
|
||||
* external profiles weren't loaded.
|
||||
*/
|
||||
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;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -95,6 +121,18 @@ export class ResponseCompanyDto {
|
||||
? company.profiles.length === 0 ||
|
||||
company.profiles.some((p) => p.onboardingCompleted)
|
||||
: 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.updatedAt = company.updatedAt;
|
||||
}
|
||||
|
||||
@@ -44,10 +44,11 @@ export class UpdateProfileDto {
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(16)
|
||||
fanNumber?: string;
|
||||
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
||||
// company's PoA (or its general manager), so it is derived from a completed
|
||||
// Fayda verification rather than typed. The global validation pipe runs with
|
||||
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
|
||||
// so — see CompaniesService.completeIdentityVerification.
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -110,6 +111,16 @@ export class UpdateProfileDto {
|
||||
@IsString()
|
||||
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()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
FILE_UPLOAD_SETTINGS_REPOSITORY,
|
||||
IFileUploadSettingsRepository,
|
||||
} from "./interfaces/file-upload-settings.repository.interface";
|
||||
import {
|
||||
COMPANY_ONBOARDING_CODE_PREFIX,
|
||||
POA_DELEGATION_FILE_KEY,
|
||||
poaDelegationField,
|
||||
} from "./poa-delegation.constants";
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadSettingsService {
|
||||
@@ -40,6 +45,22 @@ export class FileUploadSettingsService {
|
||||
async getByCode(code: string): Promise<FileUploadSetting> {
|
||||
const setting = await this.repository.findByCode(code);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { FileUploadField } from "./entities/file-upload-field.entity";
|
||||
|
||||
/**
|
||||
* The DARS delegation paper — the document that evidences a company's Power of
|
||||
* Attorney (EDRFREIGHT-358).
|
||||
*
|
||||
* Every other onboarding document is admin-managed: the rows in
|
||||
* `file_upload_fields` are edited from the backoffice file-settings editor and
|
||||
* the seeder deliberately inserts none. This one is different — a company that
|
||||
* names a PoA must produce a delegation paper authenticated by the Documents
|
||||
* Authentication and Registration Service, and that is a legal requirement
|
||||
* rather than a configuration choice. So the field is defined here in code and
|
||||
* injected into the company onboarding sets on read: no row to forget to seed,
|
||||
* and deleting one in the editor cannot silently switch the requirement off.
|
||||
*/
|
||||
|
||||
/** FileRecord `code` (and upload field key) of the live delegation paper. */
|
||||
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||
|
||||
/** Code for a delegation paper staged in an open change request (not yet live). */
|
||||
export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
||||
|
||||
/** Customer-facing name of the document, used by the API and both web apps. */
|
||||
export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
|
||||
|
||||
/** Prefix of the setting codes the field is injected into. */
|
||||
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
|
||||
|
||||
const POA_DELEGATION_HELP =
|
||||
"Delegation paper issued by the Documents Authentication and Registration " +
|
||||
"Service (DARS) delegating the representative named above. Upload the " +
|
||||
"authenticated copy — a plain letter is not accepted.";
|
||||
|
||||
/**
|
||||
* The field descriptor. `isRequired` stays false because the paper is only due
|
||||
* once a PoA has actually been named (or the company operates as a freight
|
||||
* forwarder) — a rule that spans form fields as well as files, so it is
|
||||
* enforced in CompaniesService rather than by this flag.
|
||||
*/
|
||||
export function poaDelegationField(displayOrder: number): FileUploadField {
|
||||
return {
|
||||
fileKey: POA_DELEGATION_FILE_KEY,
|
||||
fileLabel: POA_DELEGATION_LABEL,
|
||||
helpText: POA_DELEGATION_HELP,
|
||||
isRequired: false,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||
maxSizeMb: 10,
|
||||
displayOrder,
|
||||
} as FileUploadField;
|
||||
}
|
||||
@@ -13,14 +13,14 @@ export class StartVerificationDto {
|
||||
purpose?: 'LOGIN' | 'VERIFY';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['WEB', 'MOBILE'],
|
||||
enum: ['WEB', 'MOBILE', 'PORTAL'],
|
||||
default: 'WEB',
|
||||
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()
|
||||
@IsIn(['WEB', 'MOBILE'])
|
||||
platform?: 'WEB' | 'MOBILE';
|
||||
@IsIn(['WEB', 'MOBILE', 'PORTAL'])
|
||||
platform?: 'WEB' | 'MOBILE' | 'PORTAL';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
@@ -57,6 +57,12 @@ export class CompleteVerificationResultDto {
|
||||
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).' })
|
||||
fullName?: string;
|
||||
|
||||
@@ -74,6 +80,11 @@ export class CompleteVerificationResultDto {
|
||||
@ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
|
||||
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.' })
|
||||
userDataSaved?: boolean;
|
||||
|
||||
|
||||
@@ -56,11 +56,15 @@ export interface CompleteVerificationResult {
|
||||
promptPasswordSetup?: boolean;
|
||||
iamUserId?: string;
|
||||
user?: FaydaUserSummary;
|
||||
/** Fayda OIDC subject — the stable key a verified identity is stored under. */
|
||||
sub?: string;
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
birthdate?: string;
|
||||
gender?: string;
|
||||
/** Verified address, English rendering (falls back to Amharic). */
|
||||
address?: string;
|
||||
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 {
|
||||
return platform === 'MOBILE'
|
||||
? this.faydaConfig.redirectUri
|
||||
: this.faydaConfig.webRedirectUri;
|
||||
if (platform === 'MOBILE') return this.faydaConfig.redirectUri;
|
||||
if (platform === 'PORTAL') return this.faydaConfig.portalRedirectUri;
|
||||
return this.faydaConfig.webRedirectUri;
|
||||
}
|
||||
|
||||
async completeVerification(
|
||||
@@ -210,11 +218,13 @@ export class VerifaydaService {
|
||||
result = {
|
||||
purpose: 'VERIFY',
|
||||
verified: true,
|
||||
sub: normalized.sub,
|
||||
fullName: normalized.fullName,
|
||||
email: normalized.email,
|
||||
phoneNumber: normalized.phoneNumber,
|
||||
birthdate: normalized.birthdate,
|
||||
gender: normalized.gender,
|
||||
address: normalized.addressEn ?? normalized.addressAm,
|
||||
userDataSaved,
|
||||
iamUserId: iamUserId ?? undefined,
|
||||
token: sessionToken?.token,
|
||||
|
||||
Reference in New Issue
Block a user