import { BadRequestException, ConflictException, Injectable, Logger, } from '@nestjs/common'; import { Readable } from 'stream'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder'; import { ContractRendererService } from '../../contracts/contract-renderer.service'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractViewModel } from '../../contracts/contract-view-model.builder'; import { MinioService } from '../minio/minio.service'; import { FileRecord } from '../files/entities/file.entity'; import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; import { ContractPricingService } from './contract-pricing.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService } from './contracts.service'; import { contractClearanceSettingCode } from './contract-clearance.util'; import { Contract } from './entities/contract.entity'; import { ContractSignerRole } from './entities/contract-signature.entity'; import { SignContractDto } from './dto/sign-contract.dto'; /** * Dropdown-settings code holding the admin-configured contract validity options * (each option's `value` is a day count). The staff accept dialog reads the same * code, so accept can only use a configured duration. See the seed migration * `SeedContractValidityPeriods`. */ const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods'; /** Status-machine guard mirroring booking-status.util. */ function assertContractStatus(contract: Contract, allowed: string[]): void { if (!allowed.includes(contract.status)) { throw new ConflictException( `Cannot perform this action on status "${contract.status}". Allowed: ${allowed.join(', ')}`, ); } } @Injectable() export class ContractTransitionService { private readonly logger = new Logger(ContractTransitionService.name); constructor( private readonly contractsRepository: ContractsRepository, private readonly contractsService: ContractsService, private readonly pricingService: ContractPricingService, private readonly approvalRulesService: ApprovalRulesService, private readonly cargoTypesService: CargoTypesService, private readonly dropdownSettingsService: DropdownSettingsService, private readonly filesService: FilesService, private readonly signaturesService: SignaturesService, private readonly milestoneService: ClearanceMilestoneService, private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder, private readonly renderer: ContractRendererService, private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ async submit(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['DRAFT', 'CHANGES_REQUESTED']); await this.pricingService.generatePrice(contractId); await this.pricingService.freezeRateSnapshots(contractId); await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); return this.contractsService.findById(contractId); } /** Confirm a price change before submit (mirrors booking confirm-submit). */ async confirmSubmit(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PRICE_CHANGED_PENDING_CONFIRM']); await this.pricingService.generatePrice(contractId); await this.pricingService.freezeRateSnapshots(contractId); await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); return this.contractsService.findById(contractId); } /** * Line staff accepts intake: set the validity window from validityDays and * instantiate the approval steps from approval_rules → PENDING_APPROVAL. */ async staffAccept( contractId: string, actorId: string, validityDays: number, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['SUBMITTED']); if (!Number.isInteger(validityDays) || validityDays < 1) { throw new BadRequestException( 'A contract validity (in days) is required to accept this contract.', ); } await this.assertValidityDaysConfigured(validityDays); const validFrom = new Date(); const validUntil = new Date(validFrom); validUntil.setDate(validUntil.getDate() + validityDays); await this.instantiateApprovalSteps(contract); await this.contractsRepository.update(contractId, { status: 'PENDING_APPROVAL', approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); return this.contractsService.findById(contractId); } /** * Ensure the chosen validity (days) is one of the admin-configured options in * the `contract_validity_periods` dropdown setting. If the setting is missing * or has no options yet, fall back to the DTO range check (already applied) so * acceptance is never hard-blocked before an admin configures the list. */ private async assertValidityDaysConfigured(validityDays: number): Promise { let setting; try { setting = await this.dropdownSettingsService.getByCode( CONTRACT_VALIDITY_PERIODS_CODE, ); } catch { // Not configured yet — keep the flow working with the DTO range only. return; } const allowed = (setting.children ?? []) .map((o) => Number(o.value)) .filter((n) => Number.isFinite(n)); if (allowed.length === 0) return; if (!allowed.includes(validityDays)) { throw new BadRequestException( `Validity ${validityDays} days is not a configured option. Allowed: ${allowed .sort((a, b) => a - b) .join(', ')} days.`, ); } } /** * Build contract approval steps from the system approval_rules chain (US-06: * container → line staff + director; bulk → directors + CEO). Mirrors the * booking transition's instantiateApprovalSteps but writes contract steps. */ private async instantiateApprovalSteps(contract: Contract): Promise { if ((contract.approvalSteps?.length ?? 0) > 0) return; const cargoTypeId = (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null; // US-06 routing: bulk always needs director approval; container needs it only // when its cargo type flags it. Resolve the chain via the same approval_rules // source of truth the booking flow uses (no booking row is created here). let requiresDirectorApproval = contract.freightType === 'BULK'; if (cargoTypeId) { const cargoType = await this.cargoTypesService.findById(cargoTypeId); if (cargoType?.requiresDirectorApproval) { requiresDirectorApproval = true; } } const chain = await this.approvalRulesService.findChain(requiresDirectorApproval); if (chain.length === 0) { throw new BadRequestException( `Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`, ); } for (const rule of chain) { await this.contractsRepository.createApprovalStep({ contractId: contract.id, stepOrder: rule.stepOrder, requiredRole: rule.requiredRole, blocksRole: rule.blocksRole ?? null, status: 'PENDING', }); } } async requestChanges( contractId: string, note: string, actorId: string, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['SUBMITTED']); await this.contractsRepository.createReviewNote( contractId, note, 'CHANGES_REQUESTED', actorId, 'STAFF', ); await this.contractsRepository.update(contractId, { status: 'CHANGES_REQUESTED', } as never); return this.contractsService.findById(contractId); } async reject(contractId: string, reason: string, actorId: string): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']); await this.contractsRepository.createReviewNote( contractId, reason, 'REJECTION', actorId, 'STAFF', ); await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); return this.contractsService.findById(contractId); } /** Approve one approval step in sequence; → APPROVED when all complete. */ async approveStep( contractId: string, stepId: string, actorId: string, requiredRole: string, authUser?: TCurrentUser, ): Promise { if (authUser) { assertCanApproveBookingStep(authUser, requiredRole); } const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step || step.status !== 'PENDING') { throw new BadRequestException('Approval step not found or already actioned'); } const next = await this.contractsRepository.findNextPendingApprovalStep(contractId); if (!next || next.id !== step.id) { throw new BadRequestException('Approval steps must be completed in order'); } if (step.requiredRole !== requiredRole) { throw new BadRequestException( `Step requires role ${step.requiredRole}, not ${requiredRole}`, ); } if (step.blocksRole && step.blocksRole === requiredRole) { throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); } await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); // Record who acted on this step, but DO NOT advance the contract status here — // approving one step (e.g. LINE_STAFF) must not finalize the chain while later // steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once // every step in the chain is complete; until then the contract stays in // PENDING_APPROVAL so the next required role can act. const updates: Record = {}; const now = new Date(); if (requiredRole === 'LINE_STAFF') { updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; } else if (requiredRole === 'DIRECTOR') { updates.signedByDirectorId = actorId; updates.signedByDirectorAt = now; } else if (requiredRole === 'CEO') { updates.signedByCeoId = actorId; updates.signedByCeoAt = now; } const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId); updates.status = allDone ? 'APPROVED' : 'PENDING_APPROVAL'; if (Object.keys(updates).length > 0) { await this.contractsRepository.update(contractId, updates as never); } return this.contractsService.findById(contractId); } /** * Render the contract PDF from the Contract aggregate, store it via FilesService, * stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/ * Chromium) is best-effort and must NOT block the contract from becoming ready — * the document is (re)rendered lazily on view/download once Chromium is available. */ async generateContract(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']); const { view } = await this.documentViewModelBuilder.build(contractId); try { await this.upsertContractPdf(contractId, contract.reference, view); } catch (err) { this.logger.warn( `Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`, ); } await this.contractsRepository.update(contractId, { status: 'CONTRACT_READY', contractTemplateKey: view.templateKey, contractGeneratedAt: new Date(), } as never); return this.contractsService.findById(contractId); } /** * Build the contract PDF view-model and rendered HTML for portal/backoffice * signing. Sourced entirely from the Contract aggregate (unit-rate schedule, no * totals). Returns the view-model, the rendered HTML and the signature rows. */ async getContractDocumentView(contractId: string): Promise<{ view: ContractViewModel; html: string; signatures: ContractViewModel['signatures']; }> { const { view } = await this.documentViewModelBuilder.build(contractId); await this.inlineSignatureImages(view.signatures); const html = this.renderer.render(view); return { view, html, signatures: view.signatures }; } /** * Rebuild the stored `contract` PDF from the current aggregate (now including * the latest signatures) so the downloaded/viewed file matches the live HTML * view. Best-effort — a Chromium hiccup must never fail the signing * transaction; the doc still re-renders live on /contract/view. */ private async regenerateContractPdf( contractId: string, reference: string, ): Promise { try { const { view } = await this.documentViewModelBuilder.build(contractId); await this.upsertContractPdf(contractId, reference, view); } catch (err) { this.logger.warn( `Signed contract PDF regen deferred for ${reference}: ${err}. It re-renders live on view.`, ); } } /** Render the contract PDF and upsert it as the `contract` file on the contract. */ private async upsertContractPdf( contractId: string, reference: string, view: ContractViewModel, ): Promise { await this.inlineSignatureImages(view.signatures); const html = this.renderer.render(view); const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html); const file: Express.Multer.File = { fieldname: 'contract', originalname: `contract-${reference}.pdf`, encoding: '7bit', mimetype: 'application/pdf', size: pdfBuffer.length, buffer: pdfBuffer, stream: Readable.from(pdfBuffer), destination: '', filename: '', path: '', }; return this.filesService.upsertByCode({ resourceId: contractId, resource: 'contracts', code: 'contract', file, }); } /** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */ private async inlineSignatureImages( signatures: Array<{ signatureImageUrl?: string | null }>, ): Promise { for (const sig of signatures) { if (!sig.signatureImageUrl) continue; try { if (sig.signatureImageUrl.startsWith('data:')) continue; const objectName = this.minioService.getObjectNameFromUrl( sig.signatureImageUrl, ); const stream = await this.minioService.getFileStream(objectName); const buffer = await this.streamToBuffer(stream); sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`; } catch { /* keep original url */ } } } private streamToBuffer(stream: Readable): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; stream.on('data', (chunk: Buffer | string) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); stream.on('error', reject); stream.on('end', () => resolve(Buffer.concat(chunks))); }); } /** Apply a digital signature row (mirrors booking-contract.service). */ private async applySignature( contract: Contract, dto: SignContractDto, options: { signerUserId?: string }, ): Promise { const role = dto.role as ContractSignerRole; // Resolve the signature image. The client may send a freshly-drawn image, or // omit it to reuse the signer's saved profile signature. Fall back to the // saved one whenever no image is supplied. let imageBase64 = dto.signatureImageBase64; let signerDisplayName = dto.signerDisplayName; if (!imageBase64 && options.signerUserId) { const saved = await this.signaturesService.getForUser(options.signerUserId); if (saved?.signatureImageUrl) { imageBase64 = saved.signatureImageUrl; signerDisplayName = signerDisplayName || saved.signerDisplayName; } } if (!imageBase64) { throw new BadRequestException( 'No signature provided and no saved signature found on the profile.', ); } const raw = imageBase64.includes(',') ? imageBase64.split(',')[1]! : imageBase64; const buffer = Buffer.from(raw, 'base64'); const sigFile: Express.Multer.File = { fieldname: `signature_${role.toLowerCase()}`, originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`, encoding: '7bit', mimetype: 'image/png', size: buffer.length, buffer, stream: Readable.from(buffer), destination: '', filename: '', path: '', }; const fileRecord = await this.filesService.upsertByCode({ resourceId: contract.id, resource: 'contracts', code: `signature_${role.toLowerCase()}`, file: sigFile, }); await this.contractsRepository.saveSignature({ contractId: contract.id, role, signerDisplayName, signedAt: new Date(), signatureFileId: fileRecord.id, consentText: dto.consentText ?? null, }); // Only (re)save the reusable profile signature when the signer drew a NEW // image. Reusing the saved signature must not rewrite it with itself. if (options.signerUserId && dto.signatureImageBase64) { try { await this.signaturesService.upsertForUser({ userId: options.signerUserId, signerDisplayName, signatureImageBase64: dto.signatureImageBase64, }); } catch (err) { this.logger.warn( `Could not save reusable signature for user ${options.signerUserId}: ${err}`, ); } } } /** Customer signs the ready contract → SIGNED_CUSTOMER. */ async sign( contractId: string, dto: SignContractDto, options: { signerUserId?: string }, ): Promise { const contract = await this.contractsService.findById(contractId); if (dto.role === 'CUSTOMER') { assertContractStatus(contract, ['CONTRACT_READY']); const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER'); if (existing) { throw new BadRequestException('Customer has already signed this contract'); } await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', customerSignedAt: new Date(), } as never); await this.regenerateContractPdf(contractId, contract.reference); return this.contractsService.findById(contractId); } return this.counterSign(contractId, dto, options); } /** * Staff/Director/CEO counter-sign → branch on the execution path. A customs * border (IMPORT/EXPORT) always requires a clearance gate before any shipment; * who reviews differs: * - Path B (customs bundled): customer uploads GL-input docs, GL reviews, GL * uploads output, then GL creates the booking. * - Path A (no customs): the customer clears the cargo himself and uploads his * own clearance proof; Operations reviews it; then the CUSTOMER books. * DOMESTIC/intercity has no border, so it goes straight to executed. */ async counterSign( contractId: string, dto: SignContractDto, options: { signerUserId?: string }, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['SIGNED_CUSTOMER']); await this.applySignature(contract, dto, options); const now = new Date(); const updates: Record = { fullyExecutedAt: now, lockedAt: now, }; // A clearance gate applies whenever a clearance doc set resolves — Path B // (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC // resolves to null on both paths and skips straight to executed. const clearanceCode = contractClearanceSettingCode( contract.tradeDirection, contract.freightType, contract.customsClearingEnabled ?? false, ); // GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract // level: there is no contract clearance cycle. The contract just becomes // active; the customer then files shipment requests and GL books + clears // each one. ONE_TIME customs and Path A self-clearance keep the contract // cycle below. const isGeneralCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); if (clearanceCode && !isGeneralCustoms) { // Open a clearance cycle, seed the pre-booking milestones, and route the // customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the // distinction is enforced at the review/finalize endpoints, not here. const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1; const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; updates.clearanceStatus = 'AWAITING_DOCUMENTS'; updates.clearanceCycleNumber = cycleNumber; } else { // No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which // clears per booking). Ready for shipment requests / direct booking. updates.status = contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; updates.clearanceStatus = 'NOT_APPLICABLE'; } await this.contractsRepository.update(contractId, updates as never); await this.regenerateContractPdf(contractId, contract.reference); return this.contractsService.findById(contractId); } /** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */ async renew(contractId: string, userId?: string): Promise { const source = await this.contractsService.findById(contractId); const reference = await this.generateRenewalReference(); const renewal = await this.contractsRepository.create({ reference, companyId: source.companyId, companyProfileId: source.companyProfileId, isGovernment: source.isGovernment, governmentInstitution: source.governmentInstitution, contractKind: source.contractKind, renewalOfId: source.id, tradeDirection: source.tradeDirection, freightType: source.freightType, serviceTypeId: source.serviceTypeId, paymentCurrency: source.paymentCurrency, customsClearingEnabled: source.customsClearingEnabled, customsClearingAgent: source.customsClearingAgent, equipmentReturn: source.equipmentReturn, firstMilePickupAddress: source.firstMilePickupAddress, firstMilePickupLat: source.firstMilePickupLat, firstMilePickupLng: source.firstMilePickupLng, lastMileDeliveryAddress: source.lastMileDeliveryAddress, lastMileDeliveryLat: source.lastMileDeliveryLat, lastMileDeliveryLng: source.lastMileDeliveryLng, isHazardous: source.isHazardous, isReefer: source.isReefer, contractType: source.contractType, versionNumber: (source.versionNumber ?? 1) + 1, status: 'RENEWAL_DRAFT', clearanceStatus: 'NOT_APPLICABLE', clearanceCycleNumber: 0, } as never); void userId; return this.contractsService.findById(renewal.id); } private async generateRenewalReference(): Promise { const year = new Date().getFullYear(); const count = await this.contractsRepository.countByYear(year); return `CTR-${year}-${String(count + 1).padStart(5, '0')}`; } }