feat(companies): onboard co-operative unions and farms

They hold a TIN but no business licence, so there is no eTrade record to look
their registration up in. A checkbox on the first wizard step marks them, and
everything that assumed a trade licence bends around it:

- The company step replaces the eTrade lookup with typed registration details
  — name, region, zone, woreda, kebele, house number — required exactly because
  they are now on screen. applyEtradeSourcedFields skips the lookup rather than
  failing it, so what the customer sends is what is stored.
- No freight-forwarder role. Forwarding is licensed work, so the option is not
  offered, and the API refuses it at start-onboarding and at every later
  role-add rather than letting approval fail on a document they cannot produce.
- No per-role business-licence upload, client-side or in the completion gate.
- Their own document set (company_onboarding_documents_cooperative) merges on
  top of the nationality one, admin-managed like every other set. Nationality
  wins a fileKey collision so no slot renders twice, and the DARS paper is not
  injected into it — the set it merges onto already carries one.
- The owner is typed in full; with no eTrade manager on file the licence
  comparison reports "nothing to compare against", which backoffice now
  explains rather than leaving as a bare dash.

Stored as an attributes flag, not a column: everything it changes is
behavioural, and nothing queries or joins on it.
This commit is contained in:
Nathnael
2026-08-11 12:54:51 +00:00
parent 72164b0b8e
commit d6e349f329
40 changed files with 1188 additions and 118 deletions

View File

@@ -266,6 +266,7 @@ export class CompaniesController {
dto.companyType,
dto.roles,
dto.nationality,
dto.cooperative,
);
return new CompanyInfoResponseDto(profile, company);
}

View File

@@ -26,7 +26,13 @@ function makeService(existing: ExistingProfile[]) {
})),
softDelete: jest.fn(async () => undefined),
};
const companiesRepo = { update: jest.fn(async () => null) };
// `findById` is only consulted when the co-operative flag is in play (adding
// a forwarder role, or setting the flag itself) — a plain company row is the
// right answer for every case here.
const companiesRepo = {
update: jest.fn(async () => null),
findById: jest.fn(async () => ({ id: "company-1", attributes: {} })),
};
const profilesRepo = {
findByUserId: jest.fn(async () => ({
id: "external-1",

View File

@@ -24,6 +24,7 @@ import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import {
COOPERATIVE_ONBOARDING_CODE,
POA_DELEGATION_FILE_KEY,
POA_DELEGATION_LABEL,
POA_DELEGATION_PENDING_CODE,
@@ -60,6 +61,8 @@ import {
CompanyNationality,
CompanyStatus,
CompanyType,
COOPERATIVE_KEY,
isCooperative,
} from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
@@ -262,6 +265,7 @@ export class CompaniesService {
: "company_onboarding_documents_ethiopian";
}
async createCompany(dto: CreateCompanyDto): Promise<Company> {
const exists = await this.companiesRepo.existsByTin(dto.tin);
if (exists) {
@@ -368,19 +372,41 @@ export class CompaniesService {
companyType: CompanyType,
roles: ProfileType[],
nationality?: CompanyNationality,
cooperative?: boolean,
): Promise<{ profile: ExternalProfile; company: Company }> {
// Already started — reuse the existing draft, just ensure roles exist and
// keep the nationality up to date if it was (re)selected.
const existing = await this.profilesRepo.findByUserId(identity.userId);
if (existing) {
const companyId = existing.company?.id ?? existing.companyId;
// Only load the row when the answer actually depends on it: to merge the
// flag into `attributes`, or to read a stored one the caller didn't send.
const needsCompany =
cooperative !== undefined ||
roles.includes(ProfileType.freightForwarder);
const current = needsCompany
? await this.companiesRepo.findById(companyId)
: null;
this.assertRolesAllowedForCooperative(
cooperative ?? isCooperative(current),
roles,
);
await this.syncCompanyProfiles(companyId, companyType, roles);
if (nationality) {
await this.companiesRepo.update(companyId, { nationality });
const updates: Partial<Company> = {};
if (nationality) updates.nationality = nationality;
if (cooperative !== undefined) {
updates.attributes = {
...(current?.attributes ?? {}),
[COOPERATIVE_KEY]: cooperative,
};
}
if (Object.keys(updates).length > 0) {
await this.companiesRepo.update(companyId, updates);
}
return this.getCompanyInfoByUserId(identity.userId);
}
this.assertRolesAllowedForCooperative(cooperative === true, roles);
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
@@ -393,6 +419,7 @@ export class CompaniesService {
country: "Ethiopia",
nationality: nationality ?? CompanyNationality.Ethiopian,
status: CompanyStatus.Pending,
...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}),
});
await this.profilesRepo.create({
@@ -410,6 +437,27 @@ export class CompaniesService {
return this.getCompanyInfoByUserId(identity.userId);
}
/**
* A co-operative union or farm cannot hold the freight-forwarder role.
*
* Forwarding is licensed work — the forwarder signs on other companies'
* behalf, which is why the role carries a mandatory Power of Attorney and a
* DARS delegation paper. A co-op is here precisely because it has no business
* licence, so the role is refused at the door rather than left to fail later
* at approval with a document it can never produce.
*/
private assertRolesAllowedForCooperative(
cooperative: boolean,
roles: ProfileType[],
): void {
if (!cooperative) return;
if (roles.includes(ProfileType.freightForwarder)) {
throw new BadRequestException(
"A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.",
);
}
}
/**
* Reconcile the company's operational profiles with the roles the user has
* selected: create the missing ones, drop the ones they deselected.
@@ -1887,6 +1935,7 @@ export class CompaniesService {
// 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.assertRolesAllowedForCooperative(isCooperative(company), [type]);
const asForwarder = this.withProfileType(company, type);
this.assertIdentityVerified(asForwarder);
await this.assertPoaDelegationSatisfied(
@@ -1933,6 +1982,7 @@ export class CompaniesService {
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created && type === ProfileType.freightForwarder) {
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
const asForwarder = this.withProfileType(company, type);
this.assertIdentityVerified(asForwarder);
await this.assertPoaDelegationSatisfied(
@@ -1986,18 +2036,35 @@ export class CompaniesService {
.filter((f) => !f.get(company))
.map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded.
// 2. Nationality-based company documents + which are already uploaded. A
// co-operative adds its own set on top: it provides everything its
// nationality demands, plus the papers standing in for the business licence
// it does not hold.
const cooperative = isCooperative(company);
const documentSettingCode = this.documentSettingCodeFor(
company.nationality,
);
const [setting, uploadedFiles] = await Promise.all([
const [setting, coopSetting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService
.getByCode(documentSettingCode)
.catch(() => null),
cooperative
? this.fileUploadSettingsService
.getByCode(COOPERATIVE_ONBOARDING_CODE)
.catch(() => null)
: Promise.resolve(null),
this.filesService.findByResource(company.id, "companies"),
]);
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
const documents = (setting?.fields ?? [])
// The co-op set is admin-managed and could name a fileKey the nationality
// set already carries; the nationality field wins so the same slot is never
// rendered (or required) twice.
const baseFields = setting?.fields ?? [];
const baseKeys = new Set(baseFields.map((f) => f.fileKey));
const documents = [
...baseFields,
...(coopSetting?.fields ?? []).filter((f) => !baseKeys.has(f.fileKey)),
]
.slice()
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((f) => ({
@@ -2029,7 +2096,13 @@ export class CompaniesService {
};
}),
);
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
// A co-operative holds no business licence — that is the whole reason it
// skips the eTrade lookup — so the per-role licence is not owed. Its own
// document set (merged above) is what stands in for it. The profiles are
// still reported so the portal can show them; only the requirement lifts.
const missingLicenses = cooperative
? []
: licenseProfiles.filter((p) => !p.uploaded);
// 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
@@ -2115,7 +2188,7 @@ export class CompaniesService {
const total =
requiredInfo.length +
requiredDocCount +
licenseProfiles.length +
(cooperative ? 0 : licenseProfiles.length) +
poaItemCount +
// The declaration and the verification it selects.
2;
@@ -2130,7 +2203,11 @@ export class CompaniesService {
return new OnboardingRequirementsResponseDto({
documentSettingCode,
cooperativeDocumentSettingCode: cooperative
? COOPERATIVE_ONBOARDING_CODE
: null,
nationality: company.nationality ?? CompanyNationality.Ethiopian,
cooperative,
companyInfo: {
complete: missingInfo.length === 0,
missingFields: missingInfo,
@@ -3369,6 +3446,13 @@ export class CompaniesService {
company: Company,
dto: UpdateProfileDto & { etradeManager?: { name: string; phone: string } },
): Promise<void> {
// A co-operative union or farm has a TIN but no business licence, so eTrade holds no
// record to check these against — the customer types the company name and
// the registered address themselves, and what they send IS the data. The
// check is skipped rather than failed: running the lookup would 400 every
// save with "no registration found for this TIN".
if (isCooperative(company)) return;
const touched = ETRADE_SOURCED_FIELDS.some(
(key) => key !== "tin" && dto[key] !== undefined,
);

View File

@@ -68,7 +68,19 @@ export interface OnboardingPoaState {
export class OnboardingRequirementsResponseDto {
/** Resolved document setting code (by nationality) the docs were drawn from. */
documentSettingCode: string;
/**
* The co-operative document set, merged on top of the nationality one — null
* for every other company. `documents` below already carries the merged
* result; this is only so the portal can fetch the same extra fields when it
* renders the pickers from the file-settings endpoint.
*/
cooperativeDocumentSettingCode: string | null;
nationality: string;
/**
* The company trades as a co-operative: no business licence, so no eTrade
* lookup, no per-role licence upload, and no freight-forwarder role.
*/
cooperative: boolean;
/** Required company-information fields and whether each is filled. */
companyInfo: {
@@ -106,7 +118,9 @@ export class OnboardingRequirementsResponseDto {
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
this.documentSettingCode = init.documentSettingCode;
this.cooperativeDocumentSettingCode = init.cooperativeDocumentSettingCode;
this.nationality = init.nationality;
this.cooperative = init.cooperative;
this.companyInfo = init.companyInfo;
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;

View File

@@ -2,7 +2,7 @@ import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
} from "./complete-identity-verification.dto";
import { Company } from "../entities/company.entity";
import { Company, isCooperative } from "../entities/company.entity";
import { ExternalProfile } from "../entities/external-profile.entity";
import {
ChangeRequestStatus,
@@ -15,6 +15,12 @@ export class ProfileResponseDto {
companyName: string;
companyType: string;
nationality: string | null;
/**
* The company trades as a co-operative: it has a TIN but no business licence,
* so the company step collects the registration by hand instead of fetching
* it from eTrade.
*/
cooperative: boolean;
companyLocation: string;
companyAddress: string | null;
tinNumber: string;
@@ -86,6 +92,7 @@ export class ProfileResponseDto {
this.companyName = company.name;
this.companyType = company.type;
this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[];

View File

@@ -3,6 +3,7 @@ import {
CompanyType,
CompanyStatus,
CompanyNationality,
isCooperative,
} from '../entities/company.entity';
import {
CompanyProfile,
@@ -55,6 +56,12 @@ export class ResponseCompanyDto {
type: CompanyType;
status: CompanyStatus;
nationality?: CompanyNationality | null;
/**
* The company trades as a co-operative: no business licence, so its
* registration was typed rather than fetched from eTrade and there is no
* eTrade manager to check the owner against.
*/
cooperative: boolean;
tin: string;
vatNumber?: string | null;
fanNumber?: string | null;
@@ -110,6 +117,7 @@ export class ResponseCompanyDto {
this.type = company.type;
this.status = company.status;
this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.fanNumber = company.fanNumber;

View File

@@ -1,4 +1,10 @@
import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator";
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsEnum,
IsOptional,
} from "class-validator";
import { CompanyNationality, CompanyType } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
@@ -14,4 +20,15 @@ export class StartOnboardingDto {
@IsOptional()
@IsEnum(CompanyNationality)
nationality?: CompanyNationality;
/**
* The company trades as a co-operative: it holds a TIN but no business
* licence, so there is no eTrade record to fetch its registration from.
* Chosen on the same step as the nationality and the roles, because it
* decides all three of what the next step asks for, which documents apply,
* and which roles are even available (a co-op cannot freight-forward).
*/
@IsOptional()
@IsBoolean()
cooperative?: boolean;
}

View File

@@ -32,6 +32,25 @@ export enum CompanyNationality {
Foreign = "foreign",
}
/**
* `attributes` key marking a co-operative union or farm.
*
* Such a company has a TIN but no business licence, so there is no eTrade record to
* look its registration up in — the company name, registered address and the
* owner are all typed instead of fetched, and the eTrade authenticity check is
* skipped rather than failed. It is a flag rather than a column because
* everything it changes is behavioural (which lookup runs, which documents
* apply, which roles are offered); nothing queries or joins on it.
*/
export const COOPERATIVE_KEY = "cooperative";
/** Is this a co-operative union or farm (a TIN, but no business licence)? */
export function isCooperative(
company: Pick<Company, "attributes"> | null | undefined,
): boolean {
return company?.attributes?.[COOPERATIVE_KEY] === true;
}
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])