fix: customer settings fix

This commit is contained in:
Nathnael
2026-07-29 14:30:35 +00:00
parent aba39b0ec3
commit 848c0d7768
10 changed files with 761 additions and 110 deletions

View File

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

View File

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

View File

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

View File

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