import { Injectable, NotFoundException, ConflictException, BadRequestException, ForbiddenException, } from "@nestjs/common"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository, DashboardScope, } from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; import { FilesService } from "../files/files.service"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { ETradeService } from "./services/etrade.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; 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 { UpdateProfileDto } from "./dto/update-profile.dto"; import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; import { Company, CompanyNationality, CompanyStatus, CompanyType, } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { BusinessLicenseFile, CompanyProfile, ProfileType, ProfileStatus, } from "./entities/company-profile.entity"; export interface UserIdentity { userId: string; firstName: string; lastName: string; email: string; phone: string; } @Injectable() export class CompaniesService { constructor( private readonly companiesRepo: CompaniesRepository, private readonly companyProfilesRepo: CompanyProfileRepository, private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, private readonly minioService: MinioService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, ) { } /** * Required company-information fields that must be filled before onboarding can * be submitted. The backend owns this list so the portal never has to know * which fields are mandatory — it just renders what's reported outstanding. * `get` reads the value from the company (some live in the attributes blob). */ private readonly REQUIRED_COMPANY_INFO: { key: string; label: string; get: (company: Company) => unknown; }[] = [ { key: "tinNumber", label: "Company TIN", get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null), }, { key: "companyEmail", label: "Company email", get: (c) => c.email }, { key: "companyPhone", label: "Company phone", get: (c) => c.phone }, { key: "companyAddress", label: "Company address", get: (c) => c.address }, { key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber }, { key: "contactPersonName", label: "Contact person name", get: (c) => c.attributes?.contactPersonName, }, { key: "contactPersonPhone", label: "Contact person phone", get: (c) => c.attributes?.contactPersonPhone, }, { key: "generalManagerName", label: "General manager name", get: (c) => c.attributes?.generalManagerName, }, { key: "generalManagerEmail", label: "General manager email", get: (c) => c.attributes?.generalManagerEmail, }, { key: "generalManagerPhone", label: "General manager phone", get: (c) => c.attributes?.generalManagerPhone, }, ]; /** The nationality-based document setting code for a company. */ private documentSettingCodeFor( nationality: CompanyNationality | null | undefined, ): string { return nationality === CompanyNationality.Foreign ? "company_onboarding_documents_foreign" : "company_onboarding_documents_ethiopian"; } async createCompany(dto: CreateCompanyDto): Promise { const exists = await this.companiesRepo.existsByTin(dto.tin); if (exists) { throw new ConflictException(`Company with TIN ${dto.tin} already exists`); } return this.companiesRepo.create(dto); } async createCompanyWithProfile( identity: UserIdentity, dto: CreateCompanyWithProfileDto, ): Promise<{ company: Company; profile: ExternalProfile }> { if (dto.tin) { const exists = await this.companiesRepo.existsByTin(dto.tin); if (exists) { throw new ConflictException( `Company with TIN ${dto.tin} already exists`, ); } } const existingProfile = await this.profilesRepo.findByEmail(identity.email); if (existingProfile) { throw new ConflictException( `Profile with email ${identity.email} already exists`, ); } const company = await this.companiesRepo.create({ name: dto.companyName, type: dto.companyType, tin: dto.tin ?? "", vatNumber: dto.vatNumber ?? null, fanNumber: dto.fanNumber ?? null, country: dto.companyLocation ?? "Ethiopia", address: dto.companyAddress ?? null, phone: normalizeE164(dto.companyPhone) ?? null, email: dto.companyEmail ?? null, attributes: dto.attributes ?? null, }); // Default active mode from the chosen role(s): importer wins when both are // picked, otherwise the first allowed type chosen. const allowedTypes = this.getProfileTypeForCompanyType(company.type); const chosenTypes = (dto.companyProfiles ?? []) .map((p) => p.type) .filter((t) => allowedTypes.includes(t)); const activeProfileType = chosenTypes.find((t) => t === ProfileType.importer) ?? chosenTypes[0] ?? allowedTypes[0] ?? null; const profile = await this.profilesRepo.create({ userId: identity.userId, companyId: company.id, firstName: identity.firstName, lastName: identity.lastName, email: identity.email, phone: normalizeE164(identity.phone) ?? identity.phone, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, activeProfileType, onboardingStep: "company", }); // Persist the operational role(s) chosen during onboarding. Types are // already constrained to the company type on the client; any that don't // match are skipped defensively rather than failing the whole signup. if (dto.companyProfiles?.length) { const allowedTypes = this.getProfileTypeForCompanyType(company.type); for (const input of dto.companyProfiles) { if (!allowedTypes.includes(input.type)) continue; const existing = await this.companyProfilesRepo.findByType( company.id, input.type, ); if (existing) continue; // No reference yet — these profiles await backoffice approval, which // is when the reference is minted (see setCompanyProfileStatus). await this.companyProfilesRepo.create({ companyId: company.id, type: input.type, businessLicense: input.businessLicense ?? null, status: ProfileStatus.Pending, }); } company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( company.id, ); } return { company, profile }; } async listCompanies( query: ListCompaniesQueryDto, ): Promise<{ items: Company[]; total: number }> { return this.companiesRepo.findPaginated(query); } async getCompanyStats(): Promise { return this.companiesRepo.getStats(); } /** * Begin onboarding: create a DRAFT company + the user's external profile + the * 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 * (the real one is filled on the Company Information step) and stays * status=pending / onboardingCompleted=false until the wizard finishes. */ async startOnboarding( identity: UserIdentity, companyType: CompanyType, roles: ProfileType[], nationality?: CompanyNationality, ): 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; await this.ensureCompanyProfiles(companyId, companyType, roles); if (nationality) { await this.companiesRepo.update(companyId, { nationality }); } return this.getCompanyInfoByUserId(identity.userId); } // A profile may exist for the same email under a different IAM id — block // duplicates as the final create does. const byEmail = await this.profilesRepo.findByEmail(identity.email); if (byEmail) { throw new ConflictException( `Profile with email ${identity.email} already exists`, ); } const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); const activeProfileType = chosenTypes.find((t) => t === ProfileType.importer) ?? chosenTypes[0] ?? allowedTypes[0] ?? null; const company = await this.companiesRepo.create({ name: identity.firstName ? `${identity.firstName}'s company` : "New company", type: companyType, tin: await this.generateDraftTin(), country: "Ethiopia", nationality: nationality ?? CompanyNationality.Ethiopian, status: CompanyStatus.Pending, }); await this.profilesRepo.create({ userId: identity.userId, companyId: company.id, firstName: identity.firstName, lastName: identity.lastName, email: identity.email, phone: normalizeE164(identity.phone) ?? identity.phone, isPrimaryContact: true, activeProfileType, onboardingStep: "company", onboardingCompleted: false, }); await this.ensureCompanyProfiles(company.id, companyType, chosenTypes); return this.getCompanyInfoByUserId(identity.userId); } /** Create any of the requested operational profiles that don't exist yet. */ private async ensureCompanyProfiles( companyId: string, companyType: CompanyType, roles: ProfileType[], ): Promise { 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; // No reference yet — minted on backoffice approval (setCompanyProfileStatus). await this.companyProfilesRepo.create({ companyId, type, status: ProfileStatus.Pending, }); } } /** * A unique 10-char placeholder TIN for a draft company (the column is * NOT NULL + unique). Overwritten with the real TIN on the company step. */ private async generateDraftTin(): Promise { for (let i = 0; i < 10; i++) { const candidate = "D" + Math.floor(Math.random() * 1_000_000_000) .toString() .padStart(9, "0"); if (!(await this.companiesRepo.existsByTin(candidate))) return candidate; } // Extremely unlikely; fall back to a timestamp-derived value. return ("D" + Date.now().toString()).slice(0, 10); } async findAllCompanies(): Promise { return this.companiesRepo.findAll({ order: { name: "ASC" } }); } async findCompanyById(id: string): Promise { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); return company; } async getCompanyInfoByUserId( userId: string, ): Promise<{ profile: ExternalProfile; company: Company }> { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); const company = profile.company; if (!company) throw new NotFoundException( `Company for profile ${profile.id} not found`, ); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( company.id, ); return { profile, company }; } /** * Dashboard KPIs for the portal home (MyPortalPage), aggregated from the * current user's company bookings. All figures are scoped to that company. * * Note: delivered/spend/volume all derive from the bookings table — there is * no separate data source for them. On-time delivery rate is replaced by * completion rate (delivered ÷ committed): the schema has no ETA / * promised-delivery date, so on-time cannot be computed. * * Period attribution uses booking.created_at: there is no delivery-date * column, so "delivered YTD" counts bookings created this year that reached a * delivered/completed status. */ async getDashboardSummary( userId: string, companyProfileId?: string, ): Promise { // A user without a company profile has no bookings — return an empty summary // rather than 404, so the portal home still renders. const profile = await this.profilesRepo.findByUserId(userId); const companyId = profile?.company?.id ?? profile?.companyId ?? null; if (!companyId) return this.emptyDashboardSummary(); // Company-wide by default (all services' data). An optional companyProfileId // (from the per-page service filter) narrows to one operational profile — // but only after we confirm it belongs to this user's company, since the // dashboard scope has no company guard at the repository layer. let scope: DashboardScope = { companyId }; if (companyProfileId) { const owned = await this.companyProfilesRepo.findByCompanyId(companyId); if (owned.some((p) => p.id === companyProfileId)) { scope = { companyProfileId }; } } const now = new Date(); const yearStart = new Date(now.getFullYear(), 0, 1); const prevYearStart = new Date(now.getFullYear() - 1, 0, 1); // Same point in the previous year, so YoY compares like-for-like windows. const prevYearToDate = new Date( prevYearStart.getTime() + (now.getTime() - yearStart.getTime()), ); const [ deliveredThis, committedThis, spendThisByCcy, spendPrevByCcy, tonnageThis, tonnagePrev, monthlyRows, ] = await Promise.all([ this.dashboardRepo.countDelivered(scope, yearStart, now), this.dashboardRepo.countCommitted(scope, yearStart, now), this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now), this.dashboardRepo.sumPaidSpendByCurrency( scope, prevYearStart, prevYearToDate, ), this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now), this.dashboardRepo.sumCommittedTonnage( scope, prevYearStart, prevYearToDate, ), this.dashboardRepo.monthlyCommittedTonnage( scope, this.monthsAgo(now, 5), now, ), ]); // Spend can span currencies; report the dominant one (prefer ETB on ties). const spend = this.pickCurrencyTotal(spendThisByCcy); const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0; return { deliveredCount: deliveredThis, // Share of committed bookings that reached delivered/completed. completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0, spendYtd: spend.total, spendCurrency: spend.currency, spendYtdChangePct: this.changePct(spend.total, spendPrev), freightVolume: { totalTonnes: Math.round(tonnageThis), totalValue: spend.total, currency: spend.currency, ytdChangePct: this.changePct(tonnageThis, tonnagePrev), monthly: this.buildMonthlySeries(now, monthlyRows), }, }; } private emptyDashboardSummary(): DashboardSummaryResponseDto { const now = new Date(); return { deliveredCount: 0, completionRate: 0, spendYtd: 0, spendCurrency: "ETB", spendYtdChangePct: 0, freightVolume: { totalTonnes: 0, totalValue: 0, currency: "ETB", ytdChangePct: 0, monthly: this.buildMonthlySeries(now, []), }, }; } /** First day of the month `n` months before `from`. */ private monthsAgo(from: Date, n: number): Date { return new Date(from.getFullYear(), from.getMonth() - n, 1); } /** Pick the currency with the largest total, preferring ETB on ties / when empty. */ private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number; } { if (totals.length === 0) return { currency: "ETB", total: 0 }; return totals.reduce((best, cur) => (cur.total > best.total ? cur : best)); } /** Percentage change vs a prior value, rounded; 0 when there is no prior base. */ private changePct(current: number, previous: number): number { if (previous <= 0) return 0; return Math.round(((current - previous) / previous) * 100); } /** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */ private buildMonthlySeries( now: Date, rows: { year: number; month: number; tonnes: number }[], ): { month: string; tonnes: number }[] { const labels = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes])); const series: { month: string; tonnes: number }[] = []; for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const key = `${d.getFullYear()}-${d.getMonth() + 1}`; series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0), }); } return series; } async updateCompany(id: string, dto: UpdateCompanyDto): Promise { await this.findCompanyById(id); const updated = await this.companiesRepo.update(id, dto); if (!updated) throw new NotFoundException(`Company ${id} not found`); return updated; } async updateProfile( userId: string, dto: UpdateProfileDto, ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); const companyUpdates: Record = {}; const attrUpdates: Record = { ...(company.attributes ?? {}) }; if (dto.nationality !== undefined) companyUpdates.nationality = dto.nationality; if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail; if (dto.companyPhone !== undefined) companyUpdates.phone = normalizeE164(dto.companyPhone); if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation; if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; if (dto.tin !== undefined && dto.tin !== company.tin) { // Reject a TIN already taken by a different company (the user's own draft // placeholder is fine to overwrite). const owner = await this.companiesRepo.findByTin(dto.tin); if (owner && owner.id !== company.id) { throw new ConflictException( `This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`, ); } 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; if (dto.contactPersonPosition !== undefined) attrUpdates.contactPersonPosition = dto.contactPersonPosition; if (dto.contactPersonEmail !== undefined) attrUpdates.contactPersonEmail = dto.contactPersonEmail; if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone); if (dto.contactVerifiedPhone !== undefined) attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone); if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail; if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone); if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; if (dto.poaPhone !== undefined) attrUpdates.poaPhone = normalizeE164(dto.poaPhone); if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; if (dto.licenceNumber !== undefined) companyUpdates.licenceNumber = dto.licenceNumber; if (dto.statusDescription !== undefined) companyUpdates.statusDescription = dto.statusDescription; if (dto.dateRegistered !== undefined) companyUpdates.dateRegistered = dto.dateRegistered; if (dto.renewedFrom !== undefined) companyUpdates.renewedFrom = dto.renewedFrom; if (dto.renewalDate !== undefined) companyUpdates.renewalDate = dto.renewalDate; if (dto.renewedTo !== undefined) companyUpdates.renewedTo = dto.renewedTo; if (dto.region !== undefined) companyUpdates.region = dto.region; if (dto.zone !== undefined) companyUpdates.zone = dto.zone; if (dto.woreda !== undefined) companyUpdates.woreda = dto.woreda; if (dto.kebele !== undefined) companyUpdates.kebele = dto.kebele; if (dto.houseNo !== undefined) companyUpdates.houseNo = dto.houseNo; if (dto.etradePhone !== undefined) companyUpdates.etradePhone = normalizeE164(dto.etradePhone); companyUpdates.attributes = attrUpdates; const updated = await this.companiesRepo.update(company.id, companyUpdates); if (!updated) throw new NotFoundException(`Company ${company.id} not found`); return new ProfileResponseDto(profile, updated); } async deleteCompany(id: string): Promise { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); } async createProfile(dto: CreateExternalProfileDto): Promise { await this.findCompanyById(dto.companyId); const existing = await this.profilesRepo.findByEmail(dto.email); if (existing) { throw new ConflictException( `Profile with email ${dto.email} already exists`, ); } return this.profilesRepo.create(dto); } async findProfileByUserId(userId: string): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); return profile; } async findProfilesByCompany(companyId: string): Promise { return this.profilesRepo.findByCompanyId(companyId); } private getProfileTypeForCompanyType(companyType: string): ProfileType[] { switch (companyType) { case "customer": // A customer can operate as an importer and/or exporter, and may also // add a freight-forwarder service profile under the same company. return [ ProfileType.importer, ProfileType.exporter, ProfileType.freightForwarder, ]; case "freight_forwarder": return [ProfileType.freightForwarder]; case "dj_freight_forwarder": return [ProfileType.djFreightForwarder]; case "transporter": return [ProfileType.transporter]; default: return []; } } async setCompanyProfileStatus( profileId: string, status: ProfileStatus, ): Promise { const existing = await this.companyProfilesRepo.findById(profileId); if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; if (status === ProfileStatus.Active && !existing.reference) { patch.reference = await this.companyProfilesRepo.generateReference( existing.type, ); } const updated = await this.companyProfilesRepo.update(profileId, patch); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); // Approving any profile promotes a pending company to active, so the // customer can start working as soon as their first profile is cleared. if (status === ProfileStatus.Active) { const company = await this.companiesRepo.findById(updated.companyId); if (company && company.status === CompanyStatus.Pending) { await this.companiesRepo.update(updated.companyId, { status: CompanyStatus.Active, }); } } return updated; } async createCompanyProfile( companyId: string, profileType?: ProfileType, ): Promise { const company = await this.findCompanyById(companyId); const allowedTypes = this.getProfileTypeForCompanyType(company.type); const type = profileType ?? allowedTypes[0]; if (!allowedTypes.includes(type)) { throw new BadRequestException( `Profile type "${type}" is not allowed for company type "${company.type}"`, ); } const existing = await this.companyProfilesRepo.findByType(companyId, type); if (existing) { throw new ConflictException( `Company already has a ${type} profile (${existing.reference ?? "pending approval"})`, ); } const reference = await this.companyProfilesRepo.generateReference(type); return this.companyProfilesRepo.create({ companyId, type, reference, status: ProfileStatus.Active, }); } async createDefaultProfilesForCompany( companyId: string, ): Promise { const company = await this.findCompanyById(companyId); const types = this.getProfileTypeForCompanyType(company.type); const profiles: CompanyProfile[] = []; for (const type of types) { const existing = await this.companyProfilesRepo.findByType( companyId, type, ); if (!existing) { profiles.push(await this.createCompanyProfile(companyId, type)); } } if (profiles.length === 0) { throw new BadRequestException( `Company of type "${company.type}" must have at least one operational profile`, ); } return profiles; } /** * Add operational profile(s) to the current user's company (portal settings). * Add-only and idempotent: each requested type must be allowed for the * company's type, profiles that already exist are skipped (not re-created or * rejected), and the full updated list is returned. */ async addCompanyProfilesForUser( userId: string, types: ProfileType[], ): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); const companyId = profile.company?.id ?? profile.companyId; const company = await this.findCompanyById(companyId); const allowedTypes = this.getProfileTypeForCompanyType(company.type); for (const type of types) { if (!allowedTypes.includes(type)) { throw new BadRequestException( `Profile type "${type}" is not allowed for company type "${company.type}"`, ); } const existing = await this.companyProfilesRepo.findByType( companyId, type, ); if (existing) continue; const reference = await this.companyProfilesRepo.generateReference(type); await this.companyProfilesRepo.create({ companyId, type, reference, status: ProfileStatus.Active, }); } return this.companyProfilesRepo.findByCompanyId(companyId); } /** * Create a single operational profile for the current user's company and * make it the active mode in the same call. Powers the header "Switch to * Exporter/Importer" flow when the target profile doesn't exist yet. */ async createCompanyProfileForUser( userId: string, type: ProfileType, businessLicense?: string, ): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); const companyId = profile.company?.id ?? profile.companyId; const company = await this.findCompanyById(companyId); const allowedTypes = this.getProfileTypeForCompanyType(company.type); if (!allowedTypes.includes(type)) { throw new BadRequestException( `Profile type "${type}" is not allowed for company type "${company.type}"`, ); } let created = await this.companyProfilesRepo.findByType(companyId, type); if (!created) { const reference = await this.companyProfilesRepo.generateReference(type); created = await this.companyProfilesRepo.create({ companyId, type, reference, businessLicense: businessLicense ?? null, status: ProfileStatus.Active, }); } await this.profilesRepo.update(profile.id, { activeProfileType: type }); return created; } /** * Switch the user's active operational mode. The target profile must already * exist — clients create it first via createCompanyProfileForUser. */ async setActiveMode( userId: string, type: ProfileType, ): Promise<{ profile: ExternalProfile; company: Company }> { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); const companyId = profile.company?.id ?? profile.companyId; const company = await this.findCompanyById(companyId); const allowedTypes = this.getProfileTypeForCompanyType(company.type); if (!allowedTypes.includes(type)) { throw new BadRequestException( `Profile type "${type}" is not allowed for company type "${company.type}"`, ); } const existing = await this.companyProfilesRepo.findByType(companyId, type); if (!existing) { throw new ConflictException( `No ${type} profile exists yet — create it before switching`, ); } await this.profilesRepo.update(profile.id, { activeProfileType: type }); return this.getCompanyInfoByUserId(userId); } async setOnboardingStep(userId: string, step: string): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); await this.profilesRepo.update(profile.id, { onboardingStep: step }); } /** * Server-driven onboarding requirements for the current user's company. * * The backend resolves the nationality-based document set, checks which * company documents and per-profile licenses are already uploaded, and reports * exactly what is still outstanding. The portal renders this list verbatim and * relies on `isComplete` to decide when to auto-finish — it never decides for * itself which documents apply or which fields are mandatory. */ async getOnboardingRequirements( userId: string, ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); // 1. Required company-information fields. const missingInfo = this.REQUIRED_COMPANY_INFO.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); const [setting, uploadedFiles] = await Promise.all([ this.fileUploadSettingsService .getByCode(documentSettingCode) .catch(() => null), this.filesService.findByResource(company.id, "companies"), ]); const uploadedCodes = new Set(uploadedFiles.map((f) => f.code)); const documents = (setting?.fields ?? []) .slice() .sort((a, b) => a.displayOrder - b.displayOrder) .map((f) => ({ fileKey: f.fileKey, fileLabel: f.fileLabel, helpText: f.helpText ?? null, isRequired: f.isRequired, isMultiple: f.isMultiple, maxFiles: f.maxFiles, allowedExtensions: f.allowedExtensions, maxSizeMb: f.maxSizeMb, displayOrder: f.displayOrder, uploaded: uploadedCodes.has(f.fileKey), })); const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded); // 3. Per-operational-profile business licenses. const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({ profileId: p.id, type: p.type, reference: p.reference ?? "", uploaded: (p.businessLicenseFiles?.length ?? 0) > 0, })); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), ...missingLicenses.map( (p) => `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, ), ]; // Progress spans every required item the user has to satisfy: company-info // fields, required documents and one license per operational profile. const requiredDocCount = documents.filter((d) => d.isRequired).length; const total = this.REQUIRED_COMPANY_INFO.length + requiredDocCount + licenseProfiles.length; const completed = total - (missingInfo.length + missingDocs.length + missingLicenses.length); return new OnboardingRequirementsResponseDto({ documentSettingCode, nationality: company.nationality ?? CompanyNationality.Ethiopian, companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, documents, licenseProfiles, progress: { completed, total }, isComplete: outstanding.length === 0, onboardingCompleted: profile.onboardingCompleted, outstanding, }); } /** * Submit onboarding for review. Validation is delegated entirely to * getOnboardingRequirements (the same source of truth the portal renders), so * the gate can never drift from what the UI shows. On success the company and * all its operational profiles move to PENDING — the backoffice approves each * profile before it can be used (see setCompanyProfileStatus). */ async markOnboardingComplete( userId: string, ): Promise<{ profile: ExternalProfile; company: Company }> { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); const companyId = profile.company?.id ?? profile.companyId; const requirements = await this.getOnboardingRequirements(userId); if (!requirements.isComplete) { throw new BadRequestException( requirements.outstanding[0] ?? "Your onboarding is incomplete. Please complete all required steps before submitting.", ); } // Send every operational profile in for approval; the company itself becomes // active once the backoffice approves at least one profile. const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); for (const cp of profiles) { if (cp.status !== ProfileStatus.Pending) { await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending); } } await this.profilesRepo.update(profile.id, { onboardingCompleted: true, onboardingStep: "done", }); // Awaiting backoffice approval — stays Pending until an admin activates it. await this.companiesRepo.update(companyId, { status: CompanyStatus.Pending, }); return this.getCompanyInfoByUserId(userId); } /** * Block a customer from booking under a profile that isn't approved yet. * Called from the booking-create path for self-service bookings; staff- and * government-initiated bookings bypass this. No-op when the profile can't be * found (defensive — resolution is best-effort upstream). */ async assertCompanyProfileApprovedForBooking( companyProfileId: string, ): Promise { const profile = await this.companyProfilesRepo.findById(companyProfileId); if (!profile) return; if (profile.status !== ProfileStatus.Active) { const role = profile.type.replace(/_/g, " "); throw new ForbiddenException( `Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`, ); } } /** * Authorize and resolve a company_profile that must belong to the current * user's company — used before accepting/returning its license files. */ async resolveOwnedProfile( userId: string, profileId: string, ): Promise { const { company } = await this.getCompanyInfoByUserId(userId); const owned = (company.companyProfiles ?? []).find( (p) => p.id === profileId, ); if (!owned) { throw new NotFoundException(`Profile ${profileId} not found`); } return owned; } /** * Upload business-license document(s) and store them directly on the company * profile (multi-file). Bytes go to object storage; only metadata/URLs are * persisted on the profile — intentionally not via the FileRecord file model. * New files are appended to any already present. Returns the full list. */ async uploadProfileLicenseFiles( userId: string, profileId: string, files: Express.Multer.File[], ): Promise { const profile = await this.resolveOwnedProfile(userId, profileId); const uploaded: BusinessLicenseFile[] = []; for (const file of files) { const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`; const url = await this.minioService.uploadFile( objectName, file.buffer, file.mimetype, ); uploaded.push({ name: file.originalname, url, size: file.size, mimeType: file.mimetype, }); } const next = [...(profile.businessLicenseFiles ?? []), ...uploaded]; await this.companyProfilesRepo.update(profileId, { businessLicenseFiles: next, }); return next; } /** The business-license files stored on a single company profile. */ async listProfileLicenseFiles( userId: string, profileId: string, ): Promise { const profile = await this.resolveOwnedProfile(userId, profileId); return profile.businessLicenseFiles ?? []; } /** * Onboarding documents stored on a company profile, fetched by profile id. * Internal helper (no ownership check) used when a booking reuses the active * profile's onboarding documents. Returns [] when the profile is unknown. */ async getProfileOnboardingFiles( profileId: string, ): Promise { const profile = await this.companyProfilesRepo.findById(profileId); return profile?.businessLicenseFiles ?? []; } /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → * exporter profile; for DOMESTIC or a forwarder/single-profile company (or * when the natural profile doesn't exist) it falls back to the user's active * profile, then the company's first profile. Returns null when the company * has no profiles at all. */ async resolveCompanyProfileIdForBooking( companyId: string, tradeDirection: string, fallbackType?: ProfileType | null, ): Promise { const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); if (profiles.length === 0) return null; const naturalType = tradeDirection === "IMPORT" ? ProfileType.importer : tradeDirection === "EXPORT" ? ProfileType.exporter : null; const byType = (type?: ProfileType | null) => type ? profiles.find((p) => p.type === type) : undefined; const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0]; return match?.id ?? null; } /** * Resolve the company_profile a customer's data should be scoped to, from * their persisted active mode. Returns null when nothing can be resolved * (not onboarded yet) so callers can fall back to company-level scoping. */ async resolveActiveCompanyProfileId(userId: string): Promise { try { const { profile, company } = await this.getCompanyInfoByUserId(userId); const type = profile.activeProfileType; if (!type) return null; const match = company.companyProfiles?.find((p) => p.type === type); return match?.id ?? null; } catch { return null; } } async fetchETradeData(tin: string) { const { businessInfo } = await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { throw new BadRequestException( "No business license found for this TIN. Please check the number and try again.", ); } return this.etradeService.extractRegistrationData(businessInfo); } }