mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1222 lines
48 KiB
TypeScript
1222 lines
48 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
Logger,
|
|
ServiceUnavailableException,
|
|
} from '@nestjs/common';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
import { DataSource } from 'typeorm';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { Readable } from 'stream';
|
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
|
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 {
|
|
assertCanApproveContractStep,
|
|
canEditContractStep,
|
|
} from '../../common/freight-permission.util';
|
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
|
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 { OtpService } from '../otp/otp.service';
|
|
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
|
import { ContractPricingService } from './contract-pricing.service';
|
|
import { ClearanceFeeService } from './clearance-fee.service';
|
|
import { ContractNotifierService } from './contract-notifier.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,
|
|
ContractDocumentArticle,
|
|
ContractDocumentSnapshot,
|
|
ContractDocumentSnapshotInput,
|
|
} from './entities/contract.entity';
|
|
import { ContractSignerRole } from './entities/contract-signature.entity';
|
|
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
|
import { SignContractDto } from './dto/sign-contract.dto';
|
|
|
|
/** The editable contract-document draft returned for the accept/edit dialog. */
|
|
export interface ContractDocumentDraft {
|
|
documentTitle: string | null;
|
|
whereasClauses: string[];
|
|
articles: ContractDocumentArticle[];
|
|
code: string | null;
|
|
name: string | null;
|
|
/** True when THIS caller may not edit — the inverse of `editableByMe`. */
|
|
locked: boolean;
|
|
/** Whether the requesting user is the approver whose turn it is to edit. */
|
|
editableByMe: boolean;
|
|
/** Role holding editing rights right now, for "locked because…" messaging. */
|
|
nextApproverRole: string | null;
|
|
generatedAt: Date | null;
|
|
status: string;
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
|
|
/**
|
|
* Approval chains are configured in IAM position types, so a step's role no
|
|
* longer maps onto the contract's fixed approver columns. These sets keep those
|
|
* legacy columns populated for the roles that still correspond to one — both the
|
|
* original role strings on historical rows and the position types that replaced
|
|
* them. Steps outside these sets are recorded only in `contract_approval_steps`,
|
|
* which is the source of truth.
|
|
*/
|
|
const LEGACY_STAFF_ROLES = new Set([
|
|
'LINE_STAFF',
|
|
'employee',
|
|
'teamLeader',
|
|
'officeHead',
|
|
'recordOfficer',
|
|
]);
|
|
const LEGACY_DIRECTOR_ROLES = new Set([
|
|
'DIRECTOR',
|
|
'director',
|
|
'operation-director',
|
|
]);
|
|
const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']);
|
|
|
|
/**
|
|
* Mask a phone for display — keep the last 4 digits, star the rest
|
|
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
|
|
* code went without echoing the company's full registered number back to the UI.
|
|
*/
|
|
function maskPhone(phone: string): string {
|
|
const trimmed = phone.trim();
|
|
if (trimmed.length <= 4) return trimmed;
|
|
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
|
}
|
|
|
|
/** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */
|
|
function maskEmail(email: string): string {
|
|
const [local, domain] = email.trim().split('@');
|
|
if (!domain) return email.trim();
|
|
return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
|
}
|
|
|
|
/**
|
|
* Where the signing code went, for the "we sent a code to …" line in the UI.
|
|
* Both contacts are listed when both were used — a signer who only watches their
|
|
* handset otherwise has no idea the email carries the same code.
|
|
*/
|
|
function maskSignerContacts(contacts: { phone?: string; email?: string }): string {
|
|
return [
|
|
contacts.email ? maskEmail(contacts.email) : null,
|
|
contacts.phone ? maskPhone(contacts.phone) : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' and ');
|
|
}
|
|
|
|
/** 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 documentHistory: ContractDocumentHistoryService,
|
|
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,
|
|
private readonly otpService: OtpService,
|
|
private readonly notifier: ContractNotifierService,
|
|
private readonly contractTemplates: ContractTemplatesService,
|
|
private readonly clearanceFeeService: ClearanceFeeService,
|
|
@InjectDataSource()
|
|
private readonly dataSource: DataSource,
|
|
) {}
|
|
|
|
/**
|
|
* The contacts the signing OTP is sent to and verified against: the signer's
|
|
* own IAM account phone AND email. One code goes to both and either delivery
|
|
* verifies it, so a signer whose SMS is delayed can still complete from their
|
|
* inbox instead of abandoning a ready contract.
|
|
*
|
|
* H12(b): resolved server-side from the authenticated user id, never from the
|
|
* request body — caller-supplied contacts would let an attacker point the code
|
|
* at their own phone or mailbox. Ownership is already gated separately by
|
|
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
|
* signature to the *person* signing rather than to a company landline that may
|
|
* be shared, stale, or imported from eTrade.
|
|
*/
|
|
private async resolveSignerContacts(
|
|
signerUserId?: string,
|
|
): Promise<{ phone?: string; email?: string }> {
|
|
if (!signerUserId) {
|
|
// Unreachable in practice (the ownership gate rejects a missing user
|
|
// first), but never fall back to another account if it ever changes.
|
|
throw new BadRequestException('Authentication required to sign');
|
|
}
|
|
const rows: Array<{ phone_number: string | null; email: string | null }> =
|
|
await this.dataSource.query(
|
|
`SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`,
|
|
[signerUserId],
|
|
);
|
|
const phone = rows[0]?.phone_number?.trim();
|
|
const email = rows[0]?.email?.trim();
|
|
if (!phone && !email) {
|
|
throw new BadRequestException(
|
|
'Your account has no registered phone number or email. Add one in Settings → Account before signing.',
|
|
);
|
|
}
|
|
return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) };
|
|
}
|
|
|
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
|
async submit(contractId: string): Promise<Contract> {
|
|
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);
|
|
const updated = await this.contractsService.findById(contractId);
|
|
this.notifier.submittedToStaff(updated);
|
|
return updated;
|
|
}
|
|
|
|
/** Confirm a price change before submit (mirrors booking confirm-submit). */
|
|
async confirmSubmit(contractId: string): Promise<Contract> {
|
|
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);
|
|
const updated = await this.contractsService.findById(contractId);
|
|
this.notifier.submittedToStaff(updated);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
|
): Promise<Contract> {
|
|
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);
|
|
|
|
// Freeze the contract document for THIS contract only. Staff may have edited
|
|
// the articles in the accept dialog; otherwise the live template is captured
|
|
// as-is so later template edits never change an in-flight contract. The
|
|
// shared six templates are never written here.
|
|
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
|
|
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'PENDING_APPROVAL',
|
|
approvedByStaffId: actorId,
|
|
approvedByStaffAt: validFrom,
|
|
contractValidityDays: validityDays,
|
|
contractValidFrom: validFrom,
|
|
contractValidUntil: validUntil,
|
|
documentSnapshot: snapshot,
|
|
} as never);
|
|
const updated = await this.contractsService.findById(contractId);
|
|
this.notifier.accepted(updated);
|
|
return updated;
|
|
}
|
|
|
|
// ── Per-contract document snapshot (US: edit articles for one contract) ─────
|
|
|
|
/**
|
|
* The editable document draft for the accept/edit dialog: the frozen snapshot
|
|
* if one exists, else the live active template resolved for this contract's
|
|
* direction/freight pair. `locked` flips true once the document may no longer
|
|
* be edited (an approver has acted, or the contract has left the pre-approval
|
|
* window).
|
|
*/
|
|
async getContractDocumentDraft(
|
|
contractId: string,
|
|
user?: TCurrentUser | null,
|
|
): Promise<ContractDocumentDraft> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
const snapshot =
|
|
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
|
(await this.resolveDocumentSnapshot(contract));
|
|
const editableByMe = await this.documentIsEditableBy(contract, user);
|
|
return {
|
|
documentTitle: snapshot?.documentTitle ?? null,
|
|
whereasClauses: snapshot?.whereasClauses ?? [],
|
|
articles: snapshot?.articles ?? [],
|
|
code: snapshot?.code ?? null,
|
|
name: snapshot?.name ?? null,
|
|
locked: !editableByMe,
|
|
editableByMe,
|
|
nextApproverRole: await this.nextApproverRole(contract),
|
|
generatedAt: contract.contractGeneratedAt ?? null,
|
|
status: contract.status,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Replace this contract's document articles from the editor. Per-contract
|
|
* only — it writes the contract's own snapshot and never the shared templates.
|
|
* Allowed while the document is still editable (PENDING_APPROVAL, no approver
|
|
* has acted).
|
|
*/
|
|
async updateContractDocument(
|
|
contractId: string,
|
|
input: ContractDocumentSnapshotInput,
|
|
user?: TCurrentUser | null,
|
|
actorId?: string,
|
|
): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['PENDING_APPROVAL']);
|
|
await this.assertDocumentEditable(contract, user);
|
|
|
|
const current =
|
|
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
|
(await this.resolveDocumentSnapshot(contract));
|
|
const merged: ContractDocumentSnapshotInput = {
|
|
code: current?.code ?? null,
|
|
name: input.name ?? current?.name ?? null,
|
|
documentTitle: input.documentTitle ?? current?.documentTitle ?? null,
|
|
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
|
|
articles: input.articles ?? current?.articles ?? [],
|
|
};
|
|
const next = this.normalizeSnapshot(merged);
|
|
await this.contractsRepository.update(contractId, {
|
|
documentSnapshot: next,
|
|
} as never);
|
|
|
|
// Audit the edit after it lands. Recording history must never break the
|
|
// edit itself, so the history service swallows its own failures.
|
|
const step = await this.contractsRepository.findNextPendingApprovalStep(
|
|
contractId,
|
|
);
|
|
await this.documentHistory.record({
|
|
contractId,
|
|
before: current,
|
|
after: next,
|
|
actorId: actorId ?? null,
|
|
actorRole: step?.requiredRole ?? null,
|
|
stepId: step?.id ?? null,
|
|
});
|
|
|
|
return this.contractsService.findById(contractId);
|
|
}
|
|
|
|
/**
|
|
* Build the per-contract document snapshot. Prefer the staff's edited articles
|
|
* from the dialog; otherwise freeze the active template matching the
|
|
* contract's direction/freight. Returns null when no active template exists
|
|
* (the renderer then falls back to the built-in generic layout at render time).
|
|
*/
|
|
private async resolveDocumentSnapshot(
|
|
contract: Contract,
|
|
provided?: ContractDocumentSnapshotInput | null,
|
|
): Promise<ContractDocumentSnapshot | null> {
|
|
if (provided && (provided.articles?.length ?? 0) > 0) {
|
|
return this.normalizeSnapshot(provided);
|
|
}
|
|
const active = await this.contractTemplates.findActiveForContract(
|
|
contract.tradeDirection,
|
|
contract.freightType,
|
|
);
|
|
if (!active) return null;
|
|
return {
|
|
code: active.code,
|
|
name: active.name,
|
|
documentTitle: active.documentTitle,
|
|
whereasClauses: active.whereasClauses ?? [],
|
|
articles: this.normalizeArticles(active.articles ?? []),
|
|
};
|
|
}
|
|
|
|
private normalizeSnapshot(
|
|
input: ContractDocumentSnapshotInput,
|
|
): ContractDocumentSnapshot {
|
|
return {
|
|
code: input.code ?? null,
|
|
name: input.name ?? null,
|
|
documentTitle: input.documentTitle ?? null,
|
|
whereasClauses: Array.isArray(input.whereasClauses)
|
|
? input.whereasClauses
|
|
.map((c) => String(c))
|
|
.filter((c) => c.trim().length > 0)
|
|
: [],
|
|
articles: this.normalizeArticles(input.articles ?? []),
|
|
};
|
|
}
|
|
|
|
/** Re-key ids and renumber order sequentially, dropping empty-title rows. */
|
|
private normalizeArticles(
|
|
articles: Array<{ id?: string; title?: string; body?: string; order?: number }>,
|
|
): ContractDocumentArticle[] {
|
|
return articles
|
|
.filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0)
|
|
.map((a, index) => ({
|
|
id: a.id ?? randomUUID(),
|
|
title: (a.title ?? '').trim(),
|
|
body: a.body ?? '',
|
|
order: index + 1,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* The contract document stays editable for the whole approval chain, but only
|
|
* by the approver whose turn it is: whoever can action the next pending step.
|
|
* Approving therefore hands editing rights to the next approver in the chain.
|
|
*
|
|
* Edits never reset approvals already given — earlier approvers stay approved.
|
|
*/
|
|
private async documentIsEditableBy(
|
|
contract: Contract,
|
|
user?: TCurrentUser | null,
|
|
): Promise<boolean> {
|
|
if (contract.status === 'SUBMITTED') return true;
|
|
if (contract.status !== 'PENDING_APPROVAL') return false;
|
|
|
|
const next = await this.contractsRepository.findNextPendingApprovalStep(
|
|
contract.id,
|
|
);
|
|
if (!next) return false;
|
|
if (!user) return false;
|
|
|
|
// Strict match: ONLY the approver whose turn it is (the next pending step's
|
|
// role) may edit. Using the looser approve gate here let any approver who
|
|
// held a contract-approve permission keep the edit button after acting —
|
|
// approval must hand edit rights to the next approver, not share them.
|
|
return canEditContractStep(user, next.requiredRole);
|
|
}
|
|
|
|
/** The role that currently holds editing rights, for UI messaging. */
|
|
private async nextApproverRole(contract: Contract): Promise<string | null> {
|
|
if (contract.status !== 'PENDING_APPROVAL') return null;
|
|
const next = await this.contractsRepository.findNextPendingApprovalStep(
|
|
contract.id,
|
|
);
|
|
return next?.requiredRole ?? null;
|
|
}
|
|
|
|
private async assertDocumentEditable(
|
|
contract: Contract,
|
|
user?: TCurrentUser | null,
|
|
): Promise<void> {
|
|
if (await this.documentIsEditableBy(contract, user)) return;
|
|
|
|
const role = await this.nextApproverRole(contract);
|
|
throw new ConflictException(
|
|
role
|
|
? `The contract document can only be edited by the current approver (${role}).`
|
|
: 'The contract document is locked — the contract has advanced beyond approval.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
if ((contract.approvalSteps?.length ?? 0) > 0) return;
|
|
|
|
const cargoTypeId =
|
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
|
|
|
|
// Resolve the chain from the cargo type flag only.
|
|
let requiresDirectorApproval = false;
|
|
if (cargoTypeId) {
|
|
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
|
|
requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false;
|
|
}
|
|
|
|
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<Contract> {
|
|
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);
|
|
const updated = await this.contractsService.findById(contractId);
|
|
this.notifier.changesRequested(updated, note);
|
|
return updated;
|
|
}
|
|
|
|
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
|
|
|
await this.contractsRepository.createReviewNote(
|
|
contractId,
|
|
reason,
|
|
'REJECTION',
|
|
actorId,
|
|
'STAFF',
|
|
);
|
|
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
|
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
|
await this.clearanceFeeService.expireForContract(contractId);
|
|
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'REJECTED',
|
|
} as never);
|
|
const updated = await this.contractsService.findById(contractId);
|
|
this.notifier.rejected(updated, reason);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Reject one approval step (line staff / director / CEO). The rejecting
|
|
* approver must supply a reason, and picks where the rejection lands:
|
|
*
|
|
* - **To the customer** (`returnToStepId` omitted — the only option for the
|
|
* first approver): terminal. The whole contract moves to REJECTED with a
|
|
* REJECTION review note visible to the customer, who must resubmit.
|
|
* - **To an earlier approver** (`returnToStepId` = an already-APPROVED
|
|
* earlier step): internal send-back. That step and everything after it
|
|
* reset to PENDING and the chain re-runs from there; the contract stays
|
|
* PENDING_APPROVAL and the customer never sees it. E.g. the director can
|
|
* return a contract to line staff, who fix it and approve again, after
|
|
* which every later stage re-approves in order.
|
|
*/
|
|
async rejectStep(
|
|
contractId: string,
|
|
stepId: string,
|
|
actorId: string,
|
|
reason: string,
|
|
returnToStepId?: string,
|
|
): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
|
|
|
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
|
if (!step) throw new BadRequestException('Approval step not found');
|
|
|
|
// Only the approver whose turn it is may reject — same ordering rule as
|
|
// approveStep. Without this, an already-actioned or future step could be
|
|
// "rejected" and wipe chain state it never owned.
|
|
const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
|
|
if (!next || next.id !== step.id) {
|
|
throw new BadRequestException(
|
|
'Only the current pending approval step can be rejected',
|
|
);
|
|
}
|
|
|
|
if (returnToStepId) {
|
|
return this.sendBackToStep(contract, step, actorId, reason, returnToStepId);
|
|
}
|
|
|
|
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason);
|
|
|
|
await this.contractsRepository.createReviewNote(
|
|
contractId,
|
|
reason,
|
|
'REJECTION',
|
|
actorId,
|
|
'STAFF',
|
|
);
|
|
|
|
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
|
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
|
await this.clearanceFeeService.expireForContract(contractId);
|
|
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'REJECTED',
|
|
} as never);
|
|
const updated = await this.contractsService.findById(contractId);
|
|
this.notifier.rejected(updated, reason);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Internal send-back branch of rejectStep: return the contract to an earlier,
|
|
* already-approved stage of the chain instead of rejecting it outright.
|
|
* Deliberately NOT the terminal path: no clearance-fee expiry (the contract
|
|
* is still alive) and no customer-facing REJECTION note — the trail is a
|
|
* staff note plus a backoffice inbox ping.
|
|
*/
|
|
private async sendBackToStep(
|
|
contract: Contract,
|
|
rejectingStep: ContractApprovalStep,
|
|
actorId: string,
|
|
reason: string,
|
|
returnToStepId: string,
|
|
): Promise<Contract> {
|
|
const target = await this.contractsRepository.findApprovalStepById(
|
|
contract.id,
|
|
returnToStepId,
|
|
);
|
|
if (!target) throw new BadRequestException('Return-to approval step not found');
|
|
if (target.stepOrder >= rejectingStep.stepOrder) {
|
|
throw new BadRequestException(
|
|
'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId',
|
|
);
|
|
}
|
|
if (target.status !== 'APPROVED') {
|
|
throw new BadRequestException(
|
|
`Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`,
|
|
);
|
|
}
|
|
|
|
// Staff-visible trail. Written before the reset so the reason survives the
|
|
// wipe of per-step notes.
|
|
await this.contractsRepository.createReviewNote(
|
|
contract.id,
|
|
`Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`,
|
|
'STAFF_NOTE',
|
|
actorId,
|
|
'STAFF',
|
|
);
|
|
|
|
// Chain re-runs from the target stage: it and every later step (including
|
|
// the rejecting one) go back to PENDING. Legacy approved-by columns are
|
|
// left stale on purpose — approval steps are the source of truth and the
|
|
// columns get re-stamped on re-approval.
|
|
await this.contractsRepository.resetApprovalStepsFrom(
|
|
contract.id,
|
|
target.stepOrder,
|
|
);
|
|
|
|
// A send-back can only happen mid-chain, so the contract must remain (or
|
|
// return to) PENDING_APPROVAL — relevant when rejecting from
|
|
// APPROVED_PENDING_SIGNATURE.
|
|
await this.contractsRepository.update(contract.id, {
|
|
status: 'PENDING_APPROVAL',
|
|
} as never);
|
|
|
|
const updated = await this.contractsService.findById(contract.id);
|
|
this.notifier.sentBackToStep(updated, target.requiredRole, reason);
|
|
return updated;
|
|
}
|
|
|
|
/** Approve one approval step in sequence; → APPROVED when all complete. */
|
|
async approveStep(
|
|
contractId: string,
|
|
stepId: string,
|
|
actorId: string,
|
|
authUser?: TCurrentUser,
|
|
): Promise<Contract> {
|
|
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');
|
|
}
|
|
|
|
// The role is the step's own — never the caller's claim about themselves.
|
|
const requiredRole = step.requiredRole;
|
|
if (authUser) {
|
|
assertCanApproveContractStep(authUser, requiredRole);
|
|
}
|
|
|
|
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 must not finalize the chain while later steps 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 approver can act.
|
|
//
|
|
// `contract_approval_steps` is the source of truth for who approved what — a
|
|
// chain is an arbitrary sequence of position types and cannot be represented
|
|
// by fixed columns. The legacy columns below are still stamped, best-effort,
|
|
// for the three roles that map onto them so older readers keep working.
|
|
const updates: Record<string, unknown> = {};
|
|
const now = new Date();
|
|
if (LEGACY_STAFF_ROLES.has(requiredRole)) {
|
|
updates.approvedByStaffId = actorId;
|
|
updates.approvedByStaffAt = now;
|
|
} else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) {
|
|
updates.signedByDirectorId = actorId;
|
|
updates.signedByDirectorAt = now;
|
|
} else if (LEGACY_CEO_ROLES.has(requiredRole)) {
|
|
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);
|
|
}
|
|
const updated = await this.contractsService.findById(contractId);
|
|
if (allDone) {
|
|
this.notifier.approved(updated);
|
|
// Final approval is what produces the contract PDF — until now there was
|
|
// only a live preview. The approval steps are already committed, so a
|
|
// render failure must not roll them back; surface it instead of swallowing
|
|
// it, since an APPROVED contract with no document needs operator action.
|
|
try {
|
|
return await this.finalizeApprovedContract(contractId);
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`Contract PDF generation failed after final approval for ${updated.reference}: ${err}`,
|
|
);
|
|
throw new ServiceUnavailableException(
|
|
'All approvals were recorded, but generating the contract PDF failed. ' +
|
|
'Retry generation from the contract page.',
|
|
);
|
|
}
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Retry path for a contract that finished approval but whose PDF failed to
|
|
* render (Chromium unavailable, etc.). The normal flow generates the document
|
|
* automatically on the final approval — there is no manual generate step
|
|
* before that, only the live preview.
|
|
*/
|
|
async generateContract(contractId: string): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
|
await this.renderContractDocument(contract);
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'CONTRACT_READY',
|
|
} as never);
|
|
return this.contractsService.findById(contractId);
|
|
}
|
|
|
|
/**
|
|
* Render the contract PDF from the Contract aggregate (snapshot-driven), store
|
|
* it via FilesService, and stamp the template key + generated timestamp. Never
|
|
* changes status. Rendering is best-effort — a Chromium hiccup defers the file
|
|
* (it re-renders on view/download) but the timestamp is still stamped.
|
|
*/
|
|
private async renderContractDocument(
|
|
contract: Contract,
|
|
options: { strict?: boolean } = {},
|
|
): Promise<void> {
|
|
const { view } = await this.documentViewModelBuilder.build(contract.id);
|
|
try {
|
|
await this.upsertContractPdf(contract.id, contract.reference, view);
|
|
} catch (err) {
|
|
// Strict callers (final approval) need to know the PDF is missing — it is
|
|
// the artifact of the completed chain, not a cache that can refill later.
|
|
if (options.strict) throw 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(contract.id, {
|
|
contractTemplateKey: view.templateKey,
|
|
contractGeneratedAt: new Date(),
|
|
} as never);
|
|
}
|
|
|
|
/**
|
|
* Every approval step landed → generate the contract PDF, then CONTRACT_READY.
|
|
* This is the only point at which the document is produced: approvers review a
|
|
* live preview, and the final approval is what turns it into a PDF. Renders
|
|
* unconditionally so the file reflects every edit made during the chain.
|
|
*/
|
|
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
await this.renderContractDocument(contract, { strict: true });
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'CONTRACT_READY',
|
|
} 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 };
|
|
}
|
|
|
|
/** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */
|
|
async streamContractPdf(contractId: string) {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
const { view } = await this.documentViewModelBuilder.build(contractId);
|
|
const record = await this.upsertContractPdf(contractId, contract.reference, view);
|
|
return this.filesService.streamById(record.id);
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<FileRecord> {
|
|
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<void> {
|
|
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<Buffer> {
|
|
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<void> {
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send the sudo-mode signing OTP to the SIGNER's own registered phone and
|
|
* email — the same contacts {@link sign} verifies against. The client never
|
|
* picks them (that is the H12(b) trust property): it only asks us to send, and
|
|
* we resolve them from the authenticated user id. Returns a masked hint so the
|
|
* UI can say where the code went without exposing the full values.
|
|
*/
|
|
async sendSigningOtp(
|
|
contractId: string,
|
|
options: { signerUserId?: string },
|
|
): Promise<{ sentTo: string }> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
// Same ownership gate as signing — only the owning company's customer may
|
|
// trigger a code for this contract.
|
|
await this.contractsService.assertCustomerCanAccessContract(
|
|
options.signerUserId,
|
|
contract,
|
|
);
|
|
assertContractStatus(contract, ['CONTRACT_READY']);
|
|
|
|
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
|
await this.otpService.sendOtp(signerContacts);
|
|
return { sentTo: maskSignerContacts(signerContacts) };
|
|
}
|
|
|
|
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
|
async sign(
|
|
contractId: string,
|
|
dto: SignContractDto,
|
|
options: { signerUserId?: string },
|
|
): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
|
|
if (dto.role === 'CUSTOMER') {
|
|
// H12(a): only the owning company's customer may sign — assert ownership
|
|
// before anything else (hidden as NotFound otherwise). A signing customer
|
|
// has no permission key, so this is the gate that binds the sign to the
|
|
// contract's company.
|
|
await this.contractsService.assertCustomerCanAccessContract(
|
|
options.signerUserId,
|
|
contract,
|
|
);
|
|
assertContractStatus(contract, ['CONTRACT_READY']);
|
|
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
|
if (existing) {
|
|
throw new BadRequestException('Customer has already signed this contract');
|
|
}
|
|
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
|
// signature is applied. H12(b): verify against the SIGNER's own registered
|
|
// contacts, resolved server-side from the authenticated user id — never
|
|
// caller-supplied ones, which an attacker could point at their own phone
|
|
// or mailbox. Ownership is already asserted above, so this proves the
|
|
// specific person holding the account is present, not merely that someone
|
|
// reached a shared company line. Must resolve identically to
|
|
// sendSigningOtp, or send and verify would target different contacts.
|
|
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
|
if (!dto.otp) {
|
|
throw new BadRequestException('OTP verification is required to sign the contract');
|
|
}
|
|
await this.otpService.verifyOtpForAction(signerContacts, dto.otp);
|
|
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);
|
|
const updated = await this.contractsService.findById(contractId);
|
|
this.notifier.customerSignedToStaff(updated);
|
|
return updated;
|
|
}
|
|
|
|
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<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
|
|
|
|
await this.applySignature(contract, dto, options);
|
|
|
|
const now = new Date();
|
|
const updates: Record<string, unknown> = {
|
|
fullyExecutedAt: now,
|
|
lockedAt: now,
|
|
};
|
|
|
|
// A clearance gate applies whenever a clearance doc set resolves — Path B
|
|
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
|
|
// intercity document set (DOMESTIC, ops-reviewed like Path A).
|
|
const clearanceCode = contractClearanceSettingCode(
|
|
contract.tradeDirection,
|
|
contract.freightType,
|
|
contract.customsClearingEnabled ?? false,
|
|
);
|
|
|
|
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
|
|
// both paths. Customs (Path B): the customer files shipment requests, GL
|
|
// books each one and the booking carries its own clearance. Self-clearance
|
|
// (Path A): the customer books, then uploads the clearance docs on that
|
|
// booking for Operations to review. Only ONE_TIME contracts keep the
|
|
// contract-level cycle below.
|
|
const isGeneral = contract.contractKind === 'GENERAL';
|
|
|
|
if (clearanceCode && !isGeneral) {
|
|
// 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);
|
|
// Path B prepay gate: the customs clearance service fee is invoiced here
|
|
// and must settle before the document step opens (the paid event advances
|
|
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
|
|
if (await this.clearanceFeeService.gateApplies(contract)) {
|
|
await this.clearanceFeeService.issueForContract(contract);
|
|
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
|
|
updates.clearanceStatus = 'AWAITING_PAYMENT';
|
|
} else {
|
|
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
|
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
|
}
|
|
updates.clearanceCycleNumber = cycleNumber;
|
|
} else {
|
|
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
|
// (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);
|
|
const updated = await this.contractsService.findById(contractId);
|
|
this.notifier.signedActive(updated);
|
|
return updated;
|
|
}
|
|
|
|
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
|
async renew(contractId: string, userId?: string): Promise<Contract> {
|
|
const source = await this.contractsService.findById(contractId);
|
|
|
|
// Retry past a concurrent insert that grabbed the same CTR sequence number.
|
|
const renewal = await insertWithGeneratedReference(
|
|
() => this.generateRenewalReference(),
|
|
(reference) =>
|
|
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<string> {
|
|
const year = new Date().getFullYear();
|
|
const seq = await this.contractsRepository.maxReferenceSequence(year);
|
|
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
|
|
}
|
|
}
|