mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
fix: customer settings fix
This commit is contained in:
@@ -192,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ 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";
|
||||
@@ -116,6 +117,28 @@ const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
|
||||
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;
|
||||
@@ -298,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.
|
||||
*/
|
||||
@@ -314,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 });
|
||||
}
|
||||
@@ -345,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,
|
||||
@@ -720,6 +763,31 @@ export class CompaniesService {
|
||||
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[]) {
|
||||
@@ -787,6 +855,8 @@ 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
|
||||
@@ -2853,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) {
|
||||
@@ -2861,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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user