Merge pull request #1429 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-27 12:40:16 +03:00
committed by GitHub
48 changed files with 1663 additions and 93 deletions

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Attaches an eTrade business licence to each operational profile.
*
* A TIN routinely holds a dozen or more licences, split by activity ("Export
* trade in coffee", "Freight Forwarders"), and until now the company picked one
* for the whole record — every role shared it. Each profile now names the
* business it actually operates as.
*
* Stored as a snapshot ({@link ETradeBusinessOption}: licenceNumber, tradeName,
* activity, renewedTo) rather than a bare licence number, so the portal and the
* backoffice can show which business is attached without an eTrade round-trip —
* eTrade is slow, serves a broken TLS chain, and is regularly down.
*
* Nullable: existing profiles have none until the customer attaches one, and a
* co-operative or investor-licence company has no eTrade record at all.
* Deliberately NOT unique — one business can back several profiles.
*/
export class CompanyProfileEtradeBusiness3760000000000 implements MigrationInterface {
name = 'CompanyProfileEtradeBusiness3760000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
ADD COLUMN IF NOT EXISTS etrade_business jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
DROP COLUMN IF EXISTS etrade_business
`);
}
}

View File

@@ -0,0 +1,112 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, Repository } from "typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
/**
* One portal login belonging to a customer company: the company-side profile
* joined to the IAM account that actually signs in.
*
* The two halves drift apart routinely — `company.email` is business contact
* detail, while `email` here is the credential a reset link goes to — which is
* exactly why staff need to see the IAM side rather than the company row.
*/
export interface CustomerAccount {
/** external_profiles.id */
profileId: string;
userId: string;
firstName: string;
lastName: string;
jobTitle: string | null;
isPrimaryContact: boolean;
onboardingStep: string | null;
onboardingCompleted: boolean;
/** Null when the profile points at a user row that no longer exists. */
username: string | null;
email: string | null;
phoneNumber: string | null;
phoneVerified: boolean | null;
/** IAM account status (`EUserStatus`), surfaced as-is. */
status: string | null;
isActive: boolean | null;
/** False means the account was created but never activated by its owner. */
hasSetPassword: boolean | null;
createdAt: Date;
}
@Injectable()
export class CustomerAccountsService {
constructor(
@InjectRepository(ExternalProfile)
private readonly profiles: Repository<ExternalProfile>,
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
/**
* Every portal account for a company, primary contact first.
*
* Deliberately NOT filtered to active accounts: a suspended or never-activated
* login is the case staff are usually looking into, and hiding it would leave
* "the customer says they can't log in" unanswerable from this screen.
*/
async listForCompany(companyId: string): Promise<CustomerAccount[]> {
const profiles = await this.profiles.find({ where: { companyId } });
if (profiles.length === 0) return [];
const userIds = profiles.map((p) => p.userId).filter(Boolean);
// Explicit select: the User entity's relations include credentials and
// sessions, and this response goes to a browser.
const users = userIds.length
? await this.users
.createQueryBuilder("user")
.select([
"user.id",
"user.username",
"user.email",
"user.phoneNumber",
"user.isPhoneNumberVerified",
"user.status",
"user.isActive",
"user.hasSetPassword",
])
.where({ id: In(userIds) })
.getMany()
: [];
const byId = new Map(users.map((u) => [u.id, u]));
return profiles
.map((p) => {
const user = byId.get(p.userId);
return {
profileId: p.id,
userId: p.userId,
firstName: p.firstName,
lastName: p.lastName,
jobTitle: p.jobTitle ?? null,
isPrimaryContact: p.isPrimaryContact,
onboardingStep: p.onboardingStep ?? null,
onboardingCompleted: p.onboardingCompleted ?? false,
username: user?.username ?? null,
email: user?.email ?? null,
phoneNumber: user?.phoneNumber ?? null,
phoneVerified: user?.isPhoneNumberVerified ?? null,
status: user?.status ?? null,
isActive: user?.isActive ?? null,
hasSetPassword: user?.hasSetPassword ?? null,
createdAt: p.createdAt,
};
})
.sort((a, b) => {
// Primary contact first — it is the account every staff action
// (password reset, notifications) actually targets.
if (a.isPrimaryContact !== b.isPrimaryContact) {
return a.isPrimaryContact ? -1 : 1;
}
return a.createdAt.getTime() - b.createdAt.getTime();
});
}
}

View File

@@ -12,6 +12,10 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
import {
CustomerAccount,
CustomerAccountsService,
} from "./customer-accounts.service";
import {
CustomerResetService,
CustomerResetTarget,
@@ -25,7 +29,22 @@ import {
@Controller("backoffice/customers")
@ApiBearerAuth()
export class CustomerResetController {
constructor(private readonly customerResetService: CustomerResetService) {}
constructor(
private readonly customerResetService: CustomerResetService,
private readonly customerAccountsService: CustomerAccountsService,
) {}
@Get(":companyId/accounts")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({
summary:
"The portal login accounts belonging to a customer, primary contact first",
})
async accounts(
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<CustomerAccount[]> {
return this.customerAccountsService.listForCompany(companyId);
}
@Get(":companyId/reset-target")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)

View File

@@ -13,6 +13,7 @@ import { AccountController } from './account.controller';
import { AccountService } from './account.service';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerAccountsService } from './customer-accounts.service';
import { CustomerResetController } from './customer-reset.controller';
import { CustomerResetService } from './customer-reset.service';
import { ForgotPasswordController } from './forgot-password.controller';
@@ -50,6 +51,7 @@ import { ListUsersService } from './list-users.service';
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,
CustomerAccountsService,
],
// Shipping-line registration mints activation links through the same
// staff-triggered reset path customers use.

View File

@@ -29,6 +29,7 @@ import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
sameCompanyName,
InvoiceDocumentService,
pngDataUrl,
} from "./documents/invoice-document.service";
@@ -875,10 +876,21 @@ export class BillingService {
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
const tradeName = invoice.companyProfile?.etradeBusiness?.tradeName?.trim();
const summary: InvoiceDocumentModel["summary"] = [
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
// filed against, not just the seller. VatNumber shown only when the company has one.
{ label: "Buyer", value: invoice.company?.name ?? null },
// The trade name of the eTrade licence THIS profile operates as. A TIN
// holds many licences and the invoiced role (importer/exporter/forwarder)
// is usually a different business from the one the company registered
// under, so the buyer's name alone doesn't say which one was billed.
// Suppressed when it just repeats the buyer name — most companies trade
// under their registered name and a duplicate row helps nobody.
...(tradeName && !sameCompanyName(tradeName, invoice.company?.name)
? [{ label: "Buyer trade name", value: tradeName }]
: []),
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
...(invoice.company?.vatNumber
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]

View File

@@ -1,4 +1,4 @@
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
import { InvoiceDocumentModel, InvoiceDocumentService, sameCompanyName } from "./invoice-document.service";
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
kind: "INVOICE",
@@ -87,3 +87,38 @@ describe("InvoiceDocumentService.buildThermalHtml", () => {
expect(html).not.toContain("right: 160px");
});
});
describe("sameCompanyName", () => {
it("treats eTrade's legal-suffix spellings as the same name", () => {
expect(sameCompanyName("ABIJOEL PLC", "ABIJOEL P L C")).toBe(true);
expect(
sameCompanyName(
"WISH TRADING PLC",
"WISH TRADING PRIVATE LIMITED COMPANY",
),
).toBe(true);
expect(
sameCompanyName("TUTA TRADING PLC", "TUTA TRADING ONE MEMBER PLC"),
).toBe(true);
});
it("keeps a genuinely different trade name distinct", () => {
// Real pairs from eTrade: the licence trades under a different name than
// the company registered under, which is exactly the row worth printing.
expect(
sameCompanyName("Cozy Coffee Grower and Exporter", "ABIJOEL P L C"),
).toBe(false);
expect(sameCompanyName("MENNA PRODUCTION", "ICOFFEE TRADING PLC")).toBe(
false,
);
expect(
sameCompanyName("YUNABEK TRADING PLC", "YUNABEK INVESTMENT PLC"),
).toBe(false);
});
it("is false when either side is missing, so no row is printed", () => {
expect(sameCompanyName("", "ABIJOEL P L C")).toBe(false);
expect(sameCompanyName(null, null)).toBe(false);
expect(sameCompanyName("ABIJOEL P L C", undefined)).toBe(false);
});
});

View File

@@ -48,6 +48,34 @@ function formatDate(value: unknown): string {
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
}
/**
* Is this trade name just the company name again?
*
* Compared loosely on purpose: eTrade spells the same legal suffix as "PLC",
* "P L C" and "PRIVATE LIMITED COMPANY", and pads names with double spaces, so
* an exact comparison would call two spellings of one name different and print
* a redundant row. Used only to decide whether a trade-name row is worth
* showing — never to decide that two businesses ARE the same.
*/
export function sameCompanyName(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const norm = (v: string | null | undefined) =>
(v ?? "")
.toUpperCase()
.replace(/[.,]/g, "")
.replace(/\s+/g, " ")
.trim()
.replace(/\bPRIVATE LIMITED COMPANY\b/g, "PLC")
.replace(/\bP L C\b/g, "PLC")
.replace(/\bONE (MEMBER|PERSON) PLC\b/g, "PLC")
.replace(/\s+/g, " ")
.trim();
const left = norm(a);
return left !== "" && left === norm(b);
}
/** One billed line on the document (charge type / fee type agnostic). */
export interface InvoiceDocumentLine {
description: string | null;

View File

@@ -32,7 +32,9 @@ import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import type { ETradeBusinessOption } from "@edr/types";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { AttachEtradeBusinessDto } from "./dto/attach-etrade-business.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import {
CompanyIdentityStateDto,
@@ -260,11 +262,42 @@ export class CompaniesController {
): Promise<ResponseCompanyProfileDto[]> {
const profiles = await this.companiesService.addCompanyProfilesForUser(
user.id,
dto.types,
dto.profiles,
);
return profiles.map((p) => new ResponseCompanyProfileDto(p));
}
@Get("etrade-businesses")
@PortalCustomer()
@ApiOperation({
summary:
"The eTrade business licences under this company's TIN, for attaching to its operational profiles",
})
async listEtradeBusinesses(
@CurrentUser() user: CurrentIamUser,
): Promise<ETradeBusinessOption[]> {
return this.companiesService.listEtradeBusinessesForUser(user.id);
}
@Patch("company-profiles/:profileId/etrade-business")
@PortalCustomer()
@ApiOperation({
summary:
"Attach one of the TIN's eTrade businesses to an operational profile (re-attaching refreshes the stored snapshot)",
})
async attachEtradeBusiness(
@CurrentUser() user: CurrentIamUser,
@Param("profileId") profileId: string,
@Body() dto: AttachEtradeBusinessDto,
): Promise<ResponseCompanyProfileDto> {
const profile = await this.companiesService.attachEtradeBusinessToProfile(
user.id,
profileId,
dto.licenceNumber,
);
return new ResponseCompanyProfileDto(profile);
}
@Post("onboarding/start")
@PortalCustomer()
@ApiOperation({
@@ -321,6 +354,7 @@ export class CompaniesController {
user.id,
dto.type,
dto.businessLicense,
dto.licenceNumber,
);
return new ResponseCompanyProfileDto(profile);
}

View File

@@ -162,7 +162,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
{} as never,
deps.filesService as never,
deps.fileUploadSettings as never,
{} as never,
// Only the business-licence lookup is exercised here: adding a role now
// resolves which eTrade business it operates as.
{
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
licenceNumber,
tradeName: "Test Trade Name",
activity: "Freight Forwarders",
renewedTo: "7/7/2026",
}),
} as never,
deps.companyNotifier as never,
{} as never,
deps.verifayda as never,
@@ -502,7 +511,9 @@ describe("the owner is checked against the eTrade licence", () => {
describe("the freight-forwarder gate", () => {
const addForwarder = (service: CompaniesService) =>
service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]);
service.addCompanyProfilesForUser("user-1", [
{ type: ProfileType.freightForwarder, licenceNumber: "LIC-1" },
]);
it("blocks the role while the representative is unverified", async () => {
const { service } = makeService({ attributes: { poaDeclared: "yes" } });

View File

@@ -133,7 +133,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
{} as never,
deps.filesService as never,
{} as never,
{} as never,
// Only the business-licence lookup is exercised here: adding a role now
// resolves which eTrade business it operates as.
{
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
licenceNumber,
tradeName: "Test Trade Name",
activity: "Freight Forwarders",
renewedTo: "7/7/2026",
}),
} as never,
deps.companyNotifier as never,
{} as never,
{} as never,
@@ -200,6 +209,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -263,6 +274,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -277,6 +290,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).resolves.toBeDefined();
});

View File

@@ -0,0 +1,149 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import { ProfileType } from "./entities/company-profile.entity";
import { COOPERATIVE_KEY } from "./entities/company.entity";
/**
* A TIN holds many business licences; each operational profile names the one it
* trades as. What matters here is that the stored business is always eTrade's
* own record, looked up under the company's own TIN — never the client's word
* for it — and that the requirement lifts for a company eTrade knows nothing
* about.
*/
const BUSINESSES = [
{
licenceNumber: "MT/AA/14/670/128936/2007",
tradeName: "Pave Freight Forwarding",
activity: "Freight Forwarders",
renewedTo: "7/7/2026",
},
{
licenceNumber: "MT/AA/14/670/11551235/2017",
tradeName: "Pave Minerals Export",
activity: "Export trade in minerals",
renewedTo: "7/7/2026",
},
];
function makeService(attributes: Record<string, unknown> = {}) {
const company = {
id: "company-1",
tin: "0045014036",
type: "customer",
attributes,
companyProfiles: [{ id: "profile-1", type: ProfileType.exporter }],
};
const created: Record<string, unknown>[] = [];
const companyProfilesRepo = {
findByCompanyId: jest.fn(async () => created),
findByType: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => {
created.push({ id: `profile-${created.length + 2}`, ...row });
return created[created.length - 1];
}),
update: jest.fn(async (id: string, data: Record<string, unknown>) => ({
id,
...data,
})),
};
const etradeService = {
listBusinessOptions: jest.fn(async () => BUSINESSES),
findBusinessOption: jest.fn(async (_tin: string, licenceNumber: string) => {
const match = BUSINESSES.find((b) => b.licenceNumber === licenceNumber);
if (!match) throw new BadRequestException("no such licence");
return match;
}),
};
const service = new CompaniesService(
{} as never,
companyProfilesRepo as never,
{} as never,
{} as never,
{ findByUserId: jest.fn(async () => ({ id: "ext-1", companyId: "company-1" })) } as never,
{} as never,
{} as never,
{} as never,
etradeService as never,
{} as never,
{} as never,
{} as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(async () => ({ profile: {}, company }) as never);
// Private, but every add path goes through it; stubbing it keeps this spec on
// the business-attachment logic instead of the whole company lookup graph.
(service as unknown as Record<string, unknown>).findCompanyById = async () =>
company;
return { service, companyProfilesRepo, etradeService };
}
describe("attaching an eTrade business to a company profile", () => {
it("stores eTrade's own record for the chosen licence, not the client's", async () => {
const { service, companyProfilesRepo } = makeService();
const updated = await service.attachEtradeBusinessToProfile(
"user-1",
"profile-1",
"MT/AA/14/670/128936/2007",
);
expect(companyProfilesRepo.update).toHaveBeenCalledWith("profile-1", {
etradeBusiness: BUSINESSES[0],
});
expect(updated.etradeBusiness).toEqual(BUSINESSES[0]);
});
it("refuses a licence eTrade does not list under this TIN", async () => {
const { service } = makeService();
await expect(
service.attachEtradeBusinessToProfile("user-1", "profile-1", "SOMEONE/ELSES/LICENCE"),
).rejects.toBeInstanceOf(BadRequestException);
});
it("refuses a profile belonging to another company", async () => {
const { service } = makeService();
await expect(
service.attachEtradeBusinessToProfile("user-1", "not-mine", BUSINESSES[0].licenceNumber),
).rejects.toBeInstanceOf(NotFoundException);
});
it("the same business may back more than one profile", async () => {
const { service, etradeService } = makeService();
await service.addCompanyProfilesForUser("user-1", [
{ type: ProfileType.exporter, licenceNumber: BUSINESSES[0].licenceNumber },
{ type: ProfileType.importer, licenceNumber: BUSINESSES[0].licenceNumber },
]);
expect(etradeService.findBusinessOption).toHaveBeenCalledTimes(2);
});
});
describe("choosing a business is required when the company has one to choose", () => {
it("rejects a role added without a licence", async () => {
const { service } = makeService();
await expect(
service.addCompanyProfilesForUser("user-1", [{ type: ProfileType.exporter }]),
).rejects.toBeInstanceOf(BadRequestException);
});
it("lifts the requirement for a co-operative, which has no eTrade record", async () => {
const { service, companyProfilesRepo, etradeService } = makeService({
[COOPERATIVE_KEY]: true,
});
await service.addCompanyProfilesForUser("user-1", [
{ type: ProfileType.exporter },
]);
expect(etradeService.findBusinessOption).not.toHaveBeenCalled();
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ etradeBusiness: null }),
);
});
it("offers a co-operative no businesses to pick from", async () => {
const { service } = makeService({ [COOPERATIVE_KEY]: true });
await expect(service.listEtradeBusinessesForUser("user-1")).resolves.toEqual([]);
});
});

View File

@@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
createdTo,
onboardingCompleted,
hasPendingChangeRequest,
profileType,
sortBy = 'review',
sortOrder = 'DESC',
} = query;
@@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository<Company> {
if (search) {
const term = `%${search.trim()}%`;
// Staff search by whatever is in front of them: the company name, the
// TIN/email, a profile reference off a document — and, since a TIN holds
// many licences, the trade name or licence number of the specific
// business a role operates as. All the per-profile terms share one EXISTS
// so a match on any of them qualifies the company once.
qb.andWhere(
`(company.name ILIKE :term
OR company.tin ILIKE :term
OR company.email ILIKE :term
OR company.licence_number ILIKE :term
OR EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = company.id
AND cp.reference ILIKE :term
AND cp.deleted_at IS NULL
AND (
cp.reference ILIKE :term
OR cp.etrade_business->>'tradeName' ILIKE :term
OR cp.etrade_business->>'licenceNumber' ILIKE :term
)
))`,
{ term },
);
}
// Companies holding a given operational role. EXISTS rather than a filter
// on the joined `companyProfiles` alias: constraining the join would drop
// the company's OTHER profiles from the loaded entity, so the list would
// render an exporter-and-importer as importer-only.
if (profileType) {
qb.andWhere(
`EXISTS (
SELECT 1 FROM freight.company_profiles cp_type
WHERE cp_type.company_id = company.id
AND cp_type.deleted_at IS NULL
AND cp_type.type = :profileType
)`,
{ profileType },
);
}
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
if (sortBy === 'review') {
// Queue ordering: actionable tiers first, newest first within each. The

View File

@@ -47,7 +47,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 type { CompanyRegistrationData, ETradeBusinessOption } 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";
@@ -343,6 +343,11 @@ export class CompaniesService {
companyId: company.id,
type: input.type,
businessLicense: input.businessLicense ?? null,
etradeBusiness: await this.resolveProfileBusiness(
company,
input.licenceNumber,
input.type,
),
status: ProfileStatus.Pending,
});
}
@@ -2149,8 +2154,9 @@ export class CompaniesService {
*/
async addCompanyProfilesForUser(
userId: string,
types: ProfileType[],
inputs: Array<{ type: ProfileType; licenceNumber?: string }>,
): Promise<CompanyProfile[]> {
const types = inputs.map((i) => i.type);
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
@@ -2185,11 +2191,21 @@ export class CompaniesService {
);
}
// Which eTrade business this role operates as. Resolved (and rejected if
// absent) BEFORE the row is created, so a role never lands unattached on
// a company that has licences to pick from.
const etradeBusiness = await this.resolveProfileBusiness(
company,
inputs.find((i) => i.type === type)?.licenceNumber,
type,
);
// 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({
companyId,
type,
etradeBusiness,
status: ProfileStatus.Pending,
});
}
@@ -2207,6 +2223,7 @@ export class CompaniesService {
userId: string,
type: ProfileType,
businessLicense?: string,
licenceNumber?: string,
): Promise<CompanyProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
@@ -2232,12 +2249,18 @@ export class CompaniesService {
);
}
if (!created) {
const etradeBusiness = await this.resolveProfileBusiness(
company,
licenceNumber,
type,
);
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved.
created = await this.companyProfilesRepo.create({
companyId,
type,
businessLicense: businessLicense ?? null,
etradeBusiness,
status: ProfileStatus.Pending,
});
}
@@ -2320,6 +2343,7 @@ export class CompaniesService {
type: p.type,
reference: p.reference ?? "",
uploaded: records.some((r) => r.code === LICENSE_CODE),
etradeBusiness: p.etradeBusiness ?? null,
};
}),
);
@@ -2331,6 +2355,19 @@ export class CompaniesService {
? []
: licenseProfiles.filter((p) => !p.uploaded);
// Which eTrade business each role operates as. Enforced here rather than at
// role creation because the wizard picks roles on its FIRST step, before a
// TIN has been entered — there is nothing to pick from yet. The customer
// attaches one on the documents step, alongside that role's licence file,
// and onboarding cannot be submitted until every role has one.
//
// Lifted for a company with no eTrade record at all: a co-operative or a
// foreign investor has no licence list, so the requirement would be
// unsatisfiable (see `usesManualRegistration`).
const missingBusinesses = usesManualRegistration(company)
? []
: licenseProfiles.filter((p) => !p.etradeBusiness);
// 4. Power of Attorney. Whether there is one at all is the company's own
// declaration — the question the wizard asks outright — and that answer is
// what decides whose identity gets verified, so an unanswered one is itself
@@ -2378,6 +2415,10 @@ export class CompaniesService {
(p) =>
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
),
...missingBusinesses.map(
(p) =>
`Choose which eTrade business your ${p.type.replace(/_/g, " ")} profile operates as`,
),
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
...(missingDelegation
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
@@ -2416,6 +2457,8 @@ export class CompaniesService {
requiredInfo.length +
requiredDocCount +
(cooperative ? 0 : licenseProfiles.length) +
// One "which business?" item per role, on the same terms as the licences.
(usesManualRegistration(company) ? 0 : licenseProfiles.length) +
poaItemCount +
// The declaration and the verification it selects.
2;
@@ -2424,6 +2467,7 @@ export class CompaniesService {
(missingInfo.length +
missingDocs.length +
missingLicenses.length +
missingBusinesses.length +
missingPoaFields.length +
(missingDelegation || flaggedDelegation ? 1 : 0) +
missingIdentityCount);
@@ -3747,6 +3791,86 @@ export class CompaniesService {
return match?.id ?? null;
}
/**
* Resolve the eTrade business a new/updated profile is being attached to.
*
* The client sends a licence number; what gets stored is eTrade's own record
* of it, looked up under THIS company's TIN. That is the whole check — a
* licence belonging to someone else's TIN simply is not in the list, so a
* client cannot attach a profile to a business the company does not hold.
*
* Returns null (rather than throwing) for a company that registered without
* eTrade: a co-operative union or farm holds no business licence, and a
* foreign investor's licence is the Investment Commission's, not the trade
* registry's. There is no list for them to pick from, so the role is theirs
* to hold unattached — the reviewer checks their uploaded documents instead.
*/
private async resolveProfileBusiness(
company: Company,
licenceNumber: string | undefined,
type: ProfileType,
): Promise<ETradeBusinessOption | null> {
if (usesManualRegistration(company)) return null;
if (!licenceNumber) {
throw new BadRequestException(
`Choose which of your eTrade business licences the ${type.replace(/_/g, " ")} profile operates as.`,
);
}
return this.etradeService.findBusinessOption(company.tin, licenceNumber);
}
/**
* The eTrade business licences the current user's company can attach to its
* operational profiles. Empty for a company that registered without eTrade.
*/
async listEtradeBusinessesForUser(
userId: string,
): Promise<ETradeBusinessOption[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
if (usesManualRegistration(company)) return [];
return this.etradeService.listBusinessOptions(company.tin);
}
/**
* Attach (or re-attach) one of the TIN's eTrade businesses to a profile.
*
* Separate from role creation because the onboarding wizard picks roles
* before the TIN is known — the business is chosen later, on the step that
* already collects each role's licence document. Re-attaching also refreshes
* the stored snapshot, which is how a renewed licence's new expiry lands.
*/
async attachEtradeBusinessToProfile(
userId: string,
profileId: string,
licenceNumber: string,
): Promise<CompanyProfile> {
const { company } = await this.getCompanyInfoByUserId(userId);
const profile = (company.companyProfiles ?? []).find(
(p) => p.id === profileId,
);
if (!profile) {
throw new NotFoundException(
`Company profile ${profileId} not found for this company`,
);
}
if (usesManualRegistration(company)) {
throw new BadRequestException(
"This company is not registered with eTrade, so it has no business licences to attach.",
);
}
const business = await this.etradeService.findBusinessOption(
company.tin,
licenceNumber,
);
const updated = await this.companyProfilesRepo.update(profile.id, {
etradeBusiness: business,
});
if (!updated) {
throw new NotFoundException(`Company profile ${profileId} not found`);
}
return updated;
}
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
private async resolveEtradeRegistration(
tin: string,

View File

@@ -1,9 +1,37 @@
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsEnum,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from "class-validator";
import { ProfileType } from "../entities/company-profile.entity";
export class AddCompanyProfileInputDto {
@IsEnum(ProfileType)
type!: ProfileType;
/**
* Which of the TIN's eTrade business licences this role operates as.
*
* Optional at the DTO layer, required by the service for any company that
* HAS an eTrade record — a co-operative or investor-licence company has none
* to pick from, and rejecting them here would be wrong. See
* `CompaniesService.resolveProfileBusiness`.
*/
@IsOptional()
@IsString()
@MaxLength(120)
licenceNumber?: string;
}
export class AddCompanyProfilesDto {
@IsArray()
@ArrayMinSize(1)
@IsEnum(ProfileType, { each: true })
types!: ProfileType[];
@ValidateNested({ each: true })
@Type(() => AddCompanyProfileInputDto)
profiles!: AddCompanyProfileInputDto[];
}

View File

@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
export class AttachEtradeBusinessDto {
/**
* The eTrade licence number of the business this profile operates as. Checked
* against the licences eTrade lists under the company's own TIN, so an
* unknown or someone else's licence is rejected rather than stored.
*/
@IsString()
@IsNotEmpty()
@MaxLength(120)
licenceNumber!: string;
}

View File

@@ -9,4 +9,14 @@ export class CreateCompanyProfileDto {
@IsString()
@MaxLength(100)
businessLicense?: string;
/**
* Which of the TIN's eTrade business licences this role operates as. Required
* by the service for any company that has an eTrade record; see
* `AddCompanyProfileInputDto.licenceNumber`.
*/
@IsOptional()
@IsString()
@MaxLength(120)
licenceNumber?: string;
}

View File

@@ -22,6 +22,16 @@ export class CompanyProfileInputDto {
@IsString()
@MaxLength(100)
businessLicense?: string;
/**
* Which of the TIN's eTrade business licences this role operates as. Required
* by the service for any company that has an eTrade record; see
* `CompaniesService.resolveProfileBusiness`.
*/
@IsOptional()
@IsString()
@MaxLength(120)
licenceNumber?: string;
}
export class CreateCompanyWithProfileDto {

View File

@@ -15,6 +15,7 @@ import {
CompanyStatus,
CompanyType,
} from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 })
@@ -56,6 +57,16 @@ export class ListCompaniesQueryDto {
@IsIn(Object.values(CompanyNationality))
nationality?: CompanyNationality;
@ApiPropertyOptional({
enum: ProfileType,
description:
"Only companies holding this operational role. A company may hold " +
"several; its other roles are still returned on the row.",
})
@IsOptional()
@IsIn(Object.values(ProfileType))
profileType?: ProfileType;
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
@IsOptional()
@IsDateString()

View File

@@ -8,6 +8,7 @@
* truth the wizard uses to auto-finish.
*/
import type { ETradeBusinessOption } from "@edr/types";
import {
CompanyIdentityStateDto,
PoaDeclaration,
@@ -38,6 +39,11 @@ export interface OnboardingLicenseProfile {
reference: string;
/** True when at least one business-license file is stored on the profile. */
uploaded: boolean;
/**
* The eTrade business this role operates as, once the customer has attached
* one. Null while outstanding — the wizard renders the picker off this.
*/
etradeBusiness: ETradeBusinessOption | null;
}
export interface OnboardingPoaState {

View File

@@ -6,6 +6,7 @@ import {
hasInvestorLicence,
isCooperative,
} from '../entities/company.entity';
import type { ETradeBusinessOption } from '@edr/types';
import {
CompanyProfile,
ProfileLicenseFileView,
@@ -31,6 +32,12 @@ export class ResponseCompanyProfileDto {
*/
licenseFiles: ProfileLicenseFileView[];
attributes?: Record<string, any> | null;
/**
* The eTrade business licence this role operates as, or null when nothing is
* attached yet (or the company registered without eTrade). Snapshot — see
* `CompanyProfile.etradeBusiness`.
*/
etradeBusiness?: ETradeBusinessOption | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null;
createdAt: Date;
@@ -45,6 +52,7 @@ export class ResponseCompanyProfileDto {
this.businessLicense = profile.businessLicense;
this.licenseFiles = [];
this.attributes = profile.attributes;
this.etradeBusiness = profile.etradeBusiness ?? null;
this.reviewNote = profile.reviewNote ?? null;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from "@edr/api-common";
import type { ETradeBusinessOption } from "@edr/types";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Company } from "./company.entity";
@@ -126,6 +127,23 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
businessLicenseFiles?: BusinessLicenseFile[] | null;
/**
* Which of the TIN's eTrade business licences this profile operates as.
*
* A TIN holds many licences split by activity, so "exporter" and "freight
* forwarder" are usually two different businesses under one company. Stored
* as a snapshot rather than a bare licence number so the trade name and
* activity render without an eTrade call — that API is slow and regularly
* down, and this is display data, not a source of truth. Re-attaching
* refreshes it.
*
* NULL when nothing is attached yet, or when the company registered without
* eTrade at all (co-operative / investor licence — see
* {@link usesManualRegistration}). One business may back several profiles.
*/
@Column({ name: "etrade_business", type: "jsonb", nullable: true })
etradeBusiness?: ETradeBusinessOption | null;
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;

View File

@@ -78,6 +78,27 @@ describe('ETradeService business selection', () => {
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
});
it("takes the selected licence's trade name as the company name", () => {
const { service } = build();
const data = service.extractRegistrationData(
{
LicenceNumber: 'MT/AA/14/670/128936/2007',
TradeName: 'Pave Freight Forwarding',
} as ETradeBusinessInfo,
companyInfo(),
);
expect(data.companyName).toBe('Pave Freight Forwarding');
});
it('falls back to the registered name when the licence has no trade name', () => {
const { service } = build();
const data = service.extractRegistrationData(
{ LicenceNumber: 'x', TradeName: ' ' } as ETradeBusinessInfo,
companyInfo(),
);
expect(data.companyName).toBe('PAVE LOGISTICS AND TRADING P L C');
});
it('lists every licence for the picker, code prefixes stripped', () => {
const { service } = build();
const data = service.extractRegistrationData(

View File

@@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs";
import {
ETradeCompanyInfo,
ETradeBusinessInfo,
ETradeBusinessOption,
CompanyRegistrationData,
normalizeRegion,
} from "@edr/types";
@@ -102,10 +103,16 @@ export class ETradeService {
}
/**
* `companyInfo` carries the registered organization name (`BusinessName`);
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
* company name resolves to the legal entity rather than the trade name — and
* never to `ManagerNameEng`, which is the manager's personal name.
* `businessInfo` carries the selected licence's `TradeName`; `companyInfo`
* carries the registered organization name (`BusinessName`). The company name
* resolves to the trade name of the licence the customer picked — a TIN
* routinely trades under a name that is not its registered one, and the
* business they selected is the one they operate as here. `BusinessName` is
* the fallback, because eTrade leaves `TradeName` blank on plenty of licences.
* Never `ManagerNameEng`, which is the manager's personal name.
*
* Callers that need the legal entity (tax filings, EIMS seller details) must
* read `companyInfo.BusinessName` themselves — it is not this field.
*/
extractRegistrationData(
businessInfo: ETradeBusinessInfo,
@@ -115,7 +122,7 @@ export class ETradeService {
return {
companyName:
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
businessInfo.TradeName?.trim() || companyInfo?.BusinessName?.trim() || "",
licenceNumber: businessInfo.LicenceNumber,
statusDescription: businessInfo.StatusDescription,
dateRegistered: businessInfo.DateRegistered,
@@ -137,17 +144,57 @@ export class ETradeService {
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
managerName: primaryManager?.ManagerNameEng || "",
managerPhone: primaryManager?.RegularPhone || "",
businesses: (companyInfo?.Businesses ?? []).map((b) => ({
licenceNumber: b.LicenceNumber,
tradeName: b.TradesName?.trim() || "",
activity: (b.SubGroups ?? [])
// Some descriptions repeat the code inline ("(65611)Import trade …").
// eTrade also puts null entries in this array, so every hop is optional.
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
.filter(Boolean)
.join(", "),
renewedTo: b.RenewedTo || "",
})),
businesses: (companyInfo?.Businesses ?? []).map(toBusinessOption),
};
}
/**
* Every business licence held under a TIN, as the customer picks them.
*
* Split out from {@link extractRegistrationData} because attaching a business
* to a company profile needs the list alone — no licence detail fetch, so one
* eTrade call instead of two.
*/
async listBusinessOptions(tin: string): Promise<ETradeBusinessOption[]> {
const companyInfo = await this.getCompanyInfoByTin(tin);
return (companyInfo.Businesses ?? []).map(toBusinessOption);
}
/**
* Resolve one of the TIN's licences, or throw if eTrade does not list it.
*
* This is the trust boundary for a client-supplied licence number: a profile
* may only ever be attached to a business eTrade actually holds under that
* TIN, so the snapshot that gets stored is eTrade's own data, never the
* client's.
*/
async findBusinessOption(
tin: string,
licenceNumber: string,
): Promise<ETradeBusinessOption> {
const options = await this.listBusinessOptions(tin);
const match = options.find((b) => b.licenceNumber === licenceNumber);
if (!match) {
throw new BadRequestException(
`eTrade lists no business licence "${licenceNumber}" under TIN ${tin}.`,
);
}
return match;
}
}
function toBusinessOption(
b: ETradeCompanyInfo["Businesses"][number],
): ETradeBusinessOption {
return {
licenceNumber: b.LicenceNumber,
tradeName: b.TradesName?.trim() || "",
activity: (b.SubGroups ?? [])
// Some descriptions repeat the code inline ("(65611)Import trade …").
// eTrade also puts null entries in this array, so every hop is optional.
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
.filter(Boolean)
.join(", "),
renewedTo: b.RenewedTo || "",
};
}

View File

@@ -118,7 +118,11 @@ export class EimsSellerCacheService implements OnModuleInit {
woreda: data.woreda,
});
this.cached = {
LegalName: data.companyName || undefined,
// The *legal* entity name, not the licence's trade name that
// `data.companyName` now carries — an EIMS seller is filed under its
// registered name.
LegalName:
companyInfo?.BusinessName?.trim() || data.companyName || undefined,
Phone: data.mobilePhone || data.regularPhone || undefined,
Region: geo?.Region,
Wereda: geo?.Wereda,

View File

@@ -22,6 +22,7 @@ import { InvoiceLine } from "../billing/entities/invoice-line.entity";
import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
import {
InvoiceDocumentModel,
sameCompanyName,
InvoiceDocumentService,
} from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service";
@@ -71,6 +72,11 @@ const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
export interface InvoiceDocumentDetails {
bookingReference: string | null;
customerName: string | null;
/**
* Trade name of the eTrade licence the billed company profile operates as.
* Null when nothing is attached, or for a company with no eTrade record.
*/
customerTradeName: string | null;
inventoryReference: string | null;
inventoryInfo: string | null;
inventoryStatus: string | null;
@@ -745,6 +751,17 @@ export class WarehouseInvoiceService {
},
{ label: "Booking reference", value: invoice.bookingReference ?? null },
{ label: "Customer", value: invoice.customerName ?? null },
// Which of the TIN's eTrade businesses was billed. Omitted when it just
// repeats the customer name — see sameCompanyName.
...(invoice.customerTradeName &&
!sameCompanyName(invoice.customerTradeName, invoice.customerName)
? [
{
label: "Customer trade name",
value: invoice.customerTradeName,
},
]
: []),
{
label: "Inventory reference",
value: invoice.inventoryReference ?? null,
@@ -795,6 +812,7 @@ export class WarehouseInvoiceService {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
cp.etrade_business->>'tradeName' AS "customerTradeName",
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
inv.status AS "inventoryStatus",
inv.release_date AS "releaseDate",
@@ -812,6 +830,7 @@ export class WarehouseInvoiceService {
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.company_profiles cp ON cp.id = b.company_profile_id AND cp.deleted_at IS NULL
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
@@ -837,6 +856,7 @@ export class WarehouseInvoiceService {
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
customerTradeName: row?.customerTradeName ?? null,
inventoryReference: row?.inventoryReference ?? null,
inventoryInfo: row?.inventoryInfo ?? null,
inventoryStatus: row?.inventoryStatus ?? null,