import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { insertWithGeneratedReference } from '@edr/api-common'; import { YardCountry } from '@edr/types'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; import { ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { ContractsRepository } from './contracts.repository'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { ContractCargoScope } from './entities/contract-cargo-scope.entity'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedContracts { items: Contract[]; total: number; meta: { page: number; pageSize: number; total: number; totalPages: number; hasNextPage: boolean; hasPreviousPage: boolean; }; } const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', 'PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE', 'SIGNED_CUSTOMER', ] as const; @Injectable() export class ContractsService { constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly contractsRepository: ContractsRepository, private readonly companiesService: CompaniesService, private readonly filesService: FilesService, private readonly minioService: MinioService, ) {} /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */ private async generateReference(): Promise { const year = new Date().getFullYear(); const seq = await this.contractsRepository.maxReferenceSequence(year); return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`; } /** Whether a service type bundles customs clearance. */ private async resolveIncludesCustoms(serviceTypeId: string): Promise { const serviceType = await this.dataSource .getRepository(ServiceType) .findOne({ where: { id: serviceTypeId } }); return serviceType?.includesCustoms ?? false; } /** Validate cargo-scope rows against freight type (doc §5.4). */ private assertCargoScopeShape( freightType: string, cargoScope: CreateContractDto['cargoScope'], ): void { if (freightType === 'CONTAINER') { const sizes = cargoScope.filter((c) => ['20ft', '40ft'].includes(c.containerSize ?? ''), ); if (sizes.length === 0) { throw new BadRequestException( 'CONTAINER contracts require at least one container size (20ft/40ft) in scope', ); } } else { const bulk = cargoScope.filter((c) => c.cargoTypeId); if (bulk.length !== 1) { throw new BadRequestException( 'BULK contracts require exactly one cargo-type scope row', ); } if (cargoScope.some((c) => c.containerSize)) { throw new BadRequestException('BULK contracts must not set a container size'); } } } /** Validate route count against contract kind (doc §5.3). */ private assertRouteShape( contractKind: string, routes: CreateContractDto['routes'], ): void { if (contractKind === 'ONE_TIME' && routes.length !== 1) { throw new BadRequestException('ONE_TIME contracts require exactly one route'); } if (routes.length < 1) { throw new BadRequestException('A contract requires at least one route'); } } /** * Every route must match the contract's declared trade direction as derived * from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC = * intercity). Intercity is Ethiopian-domestic only: both yards must be in * Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches * (e.g. an export lane on an import contract) are rejected for every kind. */ private async assertRoutesMatchDirection( tradeDirection: string, routes: CreateContractDto['routes'], ): Promise { const yardIds = [ ...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])), ]; const yards = await this.dataSource .getRepository(Yard) .find({ where: yardIds.map((id) => ({ id })) }); const yardById = new Map(yards.map((y) => [y.id, y])); for (const route of routes) { const origin = yardById.get(route.originYardId); const destination = yardById.get(route.destinationYardId); if (!origin || !destination) { throw new BadRequestException('Route references a yard that does not exist'); } const derived = deriveTradeDirection(origin, destination); if (derived !== tradeDirection) { throw new BadRequestException( `Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`, ); } if ( derived === 'DOMESTIC' && (origin.country !== YardCountry.ETHIOPIA || destination.country !== YardCountry.ETHIOPIA) ) { throw new BadRequestException( `Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`, ); } } } /** Create a new contract (DRAFT) with its routes and cargo-scope rows. */ async create( dto: CreateContractDto, files: Express.Multer.File[], userId?: string, ): Promise<{ contract: Contract; warnings: string[] }> { const warnings: string[] = []; const isGovernment = dto.isGovernment === true; let companyId: string | null | undefined = dto.companyId; if (isGovernment) { if (!dto.governmentInstitution?.trim()) { throw new BadRequestException( 'governmentInstitution is required for government contracts', ); } companyId = dto.companyId ?? null; } else if (!companyId) { if (!userId) { throw new BadRequestException( 'companyId is required or must be resolvable from auth token', ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); if (company.status !== CompanyStatus.Active) { throw new ForbiddenException( "Your company is awaiting approval — you can't create contracts yet.", ); } companyId = company.id; } this.assertCargoScopeShape(dto.freightType, dto.cargoScope); this.assertRouteShape(dto.contractKind, dto.routes); await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); // Stamp the operational profile (importer/exporter) for portal scoping. let companyProfileId: string | null = null; if (!isGovernment && companyId) { let fallbackType: ProfileType | null = null; if (userId) { try { const { profile } = await this.companiesService.getCompanyInfoByUserId(userId); fallbackType = profile.activeProfileType ?? null; } catch { // No profile (e.g. staff creating on behalf) — fall back to mapping. } } companyProfileId = await this.companiesService.resolveCompanyProfileIdForBooking( companyId, dto.tradeDirection, fallbackType, ); const customerSelfBooking = !dto.companyId && !!userId; if (customerSelfBooking && companyProfileId) { await this.companiesService.assertCompanyProfileApprovedForBooking( companyProfileId, ); } } // Customs clearing is owned by the service type, not the customer. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); // Intercity never crosses a border, so a customs-including service type is // a contradiction — the wizard hides them, the API enforces it. if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) { throw new BadRequestException( 'Intercity contracts cannot use a service type that includes customs clearing', ); } // An explicit reference is caller-chosen — a collision there is a real // conflict and should surface. Auto-generated references retry past a // concurrent insert that grabbed the same sequence number. const contract = dto.reference ? await this.insertContract(dto.reference, { companyId, companyProfileId, isGovernment, includesCustoms, dto, }) : await insertWithGeneratedReference( () => this.generateReference(), (reference) => this.insertContract(reference, { companyId, companyProfileId, isGovernment, includesCustoms, dto, }), ); await this.persistRoutes(contract.id, dto.routes); await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind); if (files.length > 0) { try { await this.filesService.uploadMany(contract.id, 'contracts', files); } catch { warnings.push('File upload failed — contract was created without attached files.'); } } // Attach the company's onboarding documents (TIN, licenses, IDs) and the // profile's business-license documents to the contract by reference. The // separate "Documents" intake step was removed — the profile documents are // simply carried onto every contract automatically. await this.attachProfileDocuments(contract.id, companyId ?? null, companyProfileId); return { contract: await this.findById(contract.id), warnings }; } /** Insert one DRAFT contract row with the given reference (no children). */ private insertContract( reference: string, ctx: { companyId: string | null | undefined; companyProfileId: string | null; isGovernment: boolean; includesCustoms: boolean; dto: CreateContractDto; }, ): Promise { const { companyId, companyProfileId, isGovernment, includesCustoms, dto } = ctx; return this.contractsRepository.create({ reference, companyId: companyId ?? null, companyProfileId, isGovernment, governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, contractKind: dto.contractKind, renewalOfId: dto.renewalOfId ?? null, tradeDirection: dto.tradeDirection, freightType: dto.freightType, serviceTypeId: dto.serviceTypeId, paymentCurrency: dto.paymentCurrency, customsClearingEnabled: includesCustoms, customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null), equipmentReturn: dto.equipmentReturn ?? null, firstMilePickupAddress: dto.firstMilePickupAddress ?? null, firstMilePickupLat: dto.firstMilePickupLat ?? null, firstMilePickupLng: dto.firstMilePickupLng ?? null, lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null, lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, isHazardous: dto.isHazardous ?? false, isReefer: dto.isReefer ?? false, contractType: dto.contractType ?? null, status: 'DRAFT', clearanceStatus: 'NOT_APPLICABLE', clearanceCycleNumber: 0, } as never); } /** * Copy the company's onboarding documents (TIN certificate, commercial / * investment license, national ID, passport — resource "companies", coded by * the upload-setting fileKey) and the company profile's business-license * documents (resource "company_profiles") onto a contract by reference (no * byte re-upload). Idempotent: codes already present on the contract — user * uploads or an earlier carry — are never duplicated or overwritten, so it is * safe to run on every create and update. No-op when there is nothing to copy. */ private async attachProfileDocuments( contractId: string, companyId: string | null, companyProfileId: string | null, ): Promise { if (!companyId && !companyProfileId) return; const existingCodes = new Set( (await this.filesService.findByResource(contractId, 'contracts')).map( (r) => r.code, ), ); const docs: Array<{ code: string; name: string; url: string; size: number; mimeType?: string; }> = []; if (companyId) { // Company onboarding documents keep their fileKey codes (tin_certificate, // commercial_license, …) so the portal can match them against the // onboarding upload-setting fields. Re-uploads append rows, so keep only // the newest record per code. const companyRecords = await this.filesService.findByResource( companyId, 'companies', ); const latestByCode = new Map(); for (const r of companyRecords) { const prev = latestByCode.get(r.code); if (!prev || r.createdAt > prev.createdAt) latestByCode.set(r.code, r); } for (const r of latestByCode.values()) { if (existingCodes.has(r.code)) continue; docs.push({ code: r.code, name: r.name, url: r.url, size: r.size, mimeType: r.mimeType, }); } } if (companyProfileId) { // Business-license files are FileRecords (resource "company_profiles"); // carry the live ones by reference. Staged/pending uploads are excluded by // code. The `business_license` prefix is preserved so the portal groups // them under "Business license" instead of the clearance catch-all — the // index suffix keeps multiple licences distinct. const records = await this.filesService.findByResource( companyProfileId, 'company_profiles', ); records .filter((r) => r.code === 'business_license') .forEach((r, i) => { const code = `business_license_${i + 1}`; if (existingCodes.has(code)) return; docs.push({ code, name: r.name, url: r.url, size: r.size, mimeType: r.mimeType, }); }); } if (docs.length === 0) return; try { await this.filesService.attachExistingFiles(contractId, 'contracts', docs); } catch { // Non-fatal — the contract is still valid without the carried documents. } } private async persistRoutes( contractId: string, routes: CreateContractDto['routes'], ): Promise { const repo = this.dataSource.getRepository(ContractRoute); await repo.save( routes.map((r, i) => repo.create({ contractId, originYardId: r.originYardId, destinationYardId: r.destinationYardId, km: r.km ?? null, sortOrder: r.sortOrder ?? i, }), ), ); } private async persistCargoScope( contractId: string, cargoScope: CreateContractDto['cargoScope'], contractKind: string, ): Promise { const repo = this.dataSource.getRepository(ContractCargoScope); // A quantity cap only governs GENERAL contracts (multi-shipment draw-down). // ONE_TIME allows a single booking, so any cap on it is meaningless → null. const isGeneral = contractKind === 'GENERAL'; await repo.save( cargoScope.map((c) => repo.create({ contractId, containerSize: c.containerSize ?? null, cargoTypeId: c.cargoTypeId ?? null, cargoFreeText: c.cargoFreeText ?? null, quantityCap: isGeneral ? (c.quantityCap ?? null) : null, }), ), ); } /** Update a DRAFT / CHANGES_REQUESTED contract. */ async update( id: string, dto: UpdateContractDto, files: Express.Multer.File[], ): Promise<{ contract: Contract; warnings: string[] }> { const existing = await this.findById(id); if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { throw new BadRequestException( 'Only DRAFT or CHANGES_REQUESTED contracts can be updated', ); } const warnings: string[] = []; const freightType = dto.freightType ?? existing.freightType; const contractKind = dto.contractKind ?? existing.contractKind; if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope); if (dto.routes) this.assertRouteShape(contractKind, dto.routes); if (dto.routes) { await this.assertRoutesMatchDirection( dto.tradeDirection ?? existing.tradeDirection, dto.routes, ); } const updates: Record = { contractKind, tradeDirection: dto.tradeDirection ?? existing.tradeDirection, freightType, serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn, firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress, firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat, firstMilePickupLng: dto.firstMilePickupLng ?? existing.firstMilePickupLng, lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? existing.lastMileDeliveryAddress, lastMileDeliveryLat: dto.lastMileDeliveryLat ?? existing.lastMileDeliveryLat, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng, contractType: dto.contractType ?? existing.contractType, }; if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null; // Customs clearing always mirrors the (possibly changed) service type. const includesCustoms = await this.resolveIncludesCustoms( dto.serviceTypeId ?? existing.serviceTypeId, ); if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) { throw new BadRequestException( 'Intercity contracts cannot use a service type that includes customs clearing', ); } updates.customsClearingEnabled = includesCustoms; updates.customsClearingAgent = includesCustoms ? null : (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null); await this.contractsRepository.update(id, updates); if (dto.routes) { await this.dataSource.getRepository(ContractRoute).delete({ contractId: id }); await this.persistRoutes(id, dto.routes); } if (dto.cargoScope) { await this.dataSource.getRepository(ContractCargoScope).delete({ contractId: id }); await this.persistCargoScope(id, dto.cargoScope, existing.contractKind); } if (files.length > 0) { await this.filesService.uploadMany(id, 'contracts', files); } // Re-carry any company/profile document that is still missing from the // contract (runs after the upload so fresh replacements keep their slot). // Backfills contracts created before profile documents were carried over. await this.attachProfileDocuments( id, existing.companyId ?? null, existing.companyProfileId ?? null, ); return { contract: await this.findById(id), warnings }; } /** Parse comma-separated or repeated status query values. */ private parseStatusFilter(filter: FilterContractDto): { statuses?: string[]; status?: string; } { const allowed = new Set(CONTRACT_STATUSES); const raw = filter.statuses; const statusList = raw ? raw .split(',') .map((s) => s.trim()) .filter((s) => allowed.has(s)) : []; if (statusList.length > 0) return { statuses: statusList }; if (filter.status && allowed.has(filter.status)) return { status: filter.status }; return {}; } async findAll( filter: FilterContractDto, forceCompanyId?: string, forceCompanyProfileId?: string, ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; const statusFilter = this.parseStatusFilter(filter); return this.contractsRepository.findAllPaginated({ page, pageSize, ...statusFilter, companyId: forceCompanyId ?? filter.companyId, companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, contractKind: filter.contractKind, serviceTypeId: filter.serviceTypeId, freightType: filter.freightType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } /** Aggregate metrics and status counts for the backoffice contract list. */ async getListSummary(filter: FilterContractDto): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; const statusFilter = this.parseStatusFilter(filter); const listFilter = { ...statusFilter, companyId: filter.companyId, contractKind: filter.contractKind, serviceTypeId: filter.serviceTypeId, freightType: filter.freightType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, }; const [statusCounts, metrics] = await Promise.all([ this.contractsRepository.getStatusCounts(), this.contractsRepository.getListSummaryMetrics({ ...listFilter, page, pageSize, needsActionStatuses: NEEDS_ACTION_STATUSES, }), ]); return { metrics, statusCounts }; } /** Get a single contract by ID with relations and signed file URLs. */ async findById(id: string): Promise { const contract = await this.contractsRepository.findByIdWithRelations(id); if (!contract) { throw new NotFoundException(`Contract ${id} not found`); } if (contract.files && contract.files.length > 0) { contract.files = await Promise.all( contract.files.map(async (file: FileRecord) => { const objectName = this.minioService.getObjectNameFromUrl(file.url); const signedUrl = await this.minioService.getSignedUrl(objectName, 300); return { ...file, signedUrl } as FileRecord; }), ); } // Surface the staff "request changes" note so the portal can show the // customer what to fix. Degrade to null on lookup failure — a missing note // must never 500 a contract fetch. if (contract.status === 'CHANGES_REQUESTED') { try { const note = await this.contractsRepository.findLatestReviewNote( contract.id, 'CHANGES_REQUESTED', ); contract.latestChangeRequestNote = note?.body ?? null; } catch { contract.latestChangeRequestNote = null; } } return contract; } async findByReference(reference: string): Promise { const found = await this.contractsRepository.findByReference(reference); if (!found) { throw new NotFoundException(`Contract with reference "${reference}" not found`); } return this.findById(found.id); } /** Upload intake documents for a DRAFT contract. */ async uploadDocuments( id: string, files: Express.Multer.File[], ): Promise { const contract = await this.findById(id); if (contract.status !== 'DRAFT') { throw new BadRequestException( 'Documents can only be uploaded for DRAFT contracts', ); } await this.filesService.uploadMany(id, 'contracts', files); return this.findById(id); } async remove(id: string): Promise { const contract = await this.findById(id); if (contract.status !== 'DRAFT') { throw new BadRequestException('Only DRAFT contracts can be deleted'); } await this.contractsRepository.softDelete(id); } /** Resolve the company a customer user belongs to, for scoping their contracts. */ async resolveCustomerCompanyId(userId: string): Promise { try { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); return company?.id ?? null; } catch { return null; } } /** Authorize a customer's access to a single contract (hides as NotFound otherwise). */ async assertCustomerCanAccessContract( userId: string | undefined, contract: Contract, ): Promise { if (!userId) { throw new ForbiddenException('Authentication required'); } const companyId = await this.resolveCustomerCompanyId(userId); if (!companyId || contract.companyId !== companyId) { throw new NotFoundException(`Contract ${contract.id} not found`); } } }