Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
Marshal b926a3116e feat: enhance train scheduling and contract management features
- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage.
- Implemented API endpoints for recording station work and managing wagon detach requests.
- Updated contract templates to include Ethiopian customs handling options.
- Enhanced shipment forms to collect customs clearing agent details for without-customs bookings.
- Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts.
- Improved validation for customs clearing agent information in shipment forms.
- Updated various components and services to accommodate new features and ensure data integrity.
2026-08-25 21:44:21 +00:00

1503 lines
58 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, logCtx } 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,
assertFreightPermission,
canEditContractStep,
HAZARDOUS_APPROVAL_ROLES,
} from '../../common/freight-permission.util';
import {
FREIGHT_PERMS,
forFreightType,
} from '../../seed/freight-permissions.registry';
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.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 { StampSettingsService } from '../stamp-settings/stamp-settings.service';
import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
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 ');
}
/**
* Where the backoffice may freeze a contract: every step from the customer's
* signature onward, up to (but not including) the terminal states. Suspending
* an unsigned contract is meaningless — staff reject or request changes there.
*/
export const SUSPENDABLE_CONTRACT_STATUSES = [
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
] as const;
/** 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 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 stampSettings: StampSettingsService,
@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',
submittedAt: new Date(),
} 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',
submittedAt: new Date(),
} 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,
user?: TCurrentUser | null,
window?: { validFrom?: string | null; validUntil?: string | null },
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
// The route guard passes on either arm; the contract's freight type decides
// which one is actually required (accept bulk ≠ accept container).
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType),
);
assertContractStatus(contract, ['SUBMITTED']);
if (!Number.isInteger(validityDays) || validityDays < 1) {
throw new BadRequestException(
'A contract validity (in days) is required to accept this contract.',
);
}
// Staff picked an explicit window in the accept dialog — honour it verbatim
// (any start, any end). Only the legacy days-only payload is still held to
// the admin-configured period list.
const picked = window?.validFrom && window?.validUntil;
if (!picked) await this.assertValidityDaysConfigured(validityDays);
const validFrom = picked ? new Date(window!.validFrom!) : new Date();
const validUntil = picked ? new Date(window!.validUntil!) : new Date(validFrom);
if (!picked) validUntil.setDate(validUntil.getDate() + validityDays);
if (validUntil.getTime() <= validFrom.getTime()) {
throw new BadRequestException(
'The contract validity end date must be after the start date.',
);
}
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);
// Audit whatever staff changed in the accept dialog. The baseline is the
// template this contract would otherwise have frozen as-is, so an untouched
// accept diffs to nothing and records no revision.
if (documentSnapshot) {
const baseline = await this.resolveDocumentSnapshot(contract);
await this.documentHistory.record({
contractId,
before: baseline,
after: snapshot,
actorId,
actorRole: 'Reviewing staff',
});
}
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,
contract.customsClearingEnabled,
// Bulk templates are keyed by the contract's cargo type.
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
contract.serviceType?.includesEthiopianCustomsOnly,
);
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}.`,
);
}
// Dangerous goods clear two dedicated hazardous desks BEFORE the commercial
// chain — if either refuses, the contract never reaches the approvers who
// would price and sign it. Steps are renumbered sequentially so the prefix
// and the configured chain form one ordered list.
const roles: Array<{ requiredRole: string; blocksRole: string | null }> = [
...(contract.isHazardous ? [...HAZARDOUS_APPROVAL_ROLES] : []).map(
(requiredRole) => ({ requiredRole, blocksRole: null }),
),
...chain.map((rule) => ({
requiredRole: rule.requiredRole,
blocksRole: rule.blocksRole ?? null,
})),
];
for (const [index, role] of roles.entries()) {
await this.contractsRepository.createApprovalStep({
contractId: contract.id,
stepOrder: index + 1,
requiredRole: role.requiredRole,
blocksRole: role.blocksRole,
status: 'PENDING',
});
}
}
async requestChanges(
contractId: string,
note: string,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType),
);
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,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType),
);
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
await this.contractsRepository.createReviewNote(
contractId,
reason,
'REJECTION',
actorId,
'STAFF',
);
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',
);
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: the contract is still alive and there
* is 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/stamp URLs with inline data URIs so they render in
* the PDF — Chromium cannot fetch the private bucket.
*/
private async inlineSignatureImages(
signatures: Array<{
signatureImageUrl?: string | null;
stampImageUrl?: string | null;
}>,
): Promise<void> {
for (const sig of signatures) {
sig.signatureImageUrl = await this.inlineImageUrl(sig.signatureImageUrl);
sig.stampImageUrl = await this.inlineImageUrl(sig.stampImageUrl);
}
}
/** MinIO URL → data URI. Returns the input unchanged if absent or on failure. */
private async inlineImageUrl(
url?: string | null,
): Promise<string | null | undefined> {
if (!url || url.startsWith('data:')) return url;
try {
const objectName = this.minioService.getObjectNameFromUrl(url);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
const extension = objectName.split('.').pop()?.toLowerCase();
const mime =
extension === 'jpg' || extension === 'jpeg'
? 'image/jpeg'
: extension === 'webp'
? 'image/webp'
: 'image/png';
return `data:${mime};base64,${buffer.toString('base64')}`;
} catch {
return 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)));
});
}
/**
* base64 (data URL or raw) → image FileRecord stored on the contract under
* `code`. Drawn signatures are always PNG; an uploaded stamp may be JPEG or
* WebP, so the type is read off the data-URL prefix rather than assumed —
* the stored extension is what {@link inlineImageUrl} reads it back as.
*/
private async uploadSignatureAsset(
contract: Contract,
code: string,
imageBase64: string,
): Promise<FileRecord> {
const mimetype =
/^data:(image\/[a-z+]+);base64,/i.exec(imageBase64)?.[1]?.toLowerCase() ??
'image/png';
const extension = mimetype === 'image/jpeg' ? 'jpg' : mimetype.split('/')[1];
const raw = imageBase64.includes(',')
? imageBase64.split(',')[1]!
: imageBase64;
const buffer = Buffer.from(raw, 'base64');
const file: Express.Multer.File = {
fieldname: code,
originalname: `${code.replace(/_/g, '-')}-${contract.reference}.${extension}`,
encoding: '7bit',
mimetype,
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
return this.filesService.upsertByCode({
resourceId: contract.id,
resource: 'contracts',
code,
file,
});
}
/** 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;
// Both sign() and counterSign() land here — who signed what, and whether the
// ink came from the request or the signer's saved profile signature.
logCtx(
{
contractId: contract.id,
reference: contract.reference,
role,
signerUserId: options.signerUserId,
usedDrawnImage: Boolean(dto.signatureImageBase64),
},
{ path: "contractSignatures", mode: "push" },
);
// 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.',
);
}
// The company stamp is a separate image from the drawn signature. Both
// parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
// are internal approval signatures, not party seals, so they stay exempt.
//
// The two parties source their seal differently: the customer uploads their
// own company stamp, while EDR always seals with the ONE global stamp
// (StampSettingsService) — staff never upload or pick a stamp.
if (role === 'CUSTOMER' && !dto.stampImageBase64) {
throw new BadRequestException(
'A company stamp is required to sign this contract.',
);
}
// STAFF seals by REFERENCE to the one global stamp file — no per-contract
// copy of the image. Safe because StampSettingsService.setStamp/clearStamp
// never delete a replaced stamp file: the referenced id keeps rendering
// the exact seal that was current at signing, even after the global stamp
// is later replaced. The customer's stamp is their own upload and is still
// stored per contract.
let stampFileId: string | null = null;
if (role === 'STAFF') {
stampFileId = (await this.stampSettings.get()).stampFileId ?? null;
if (!stampFileId) {
throw new BadRequestException(
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
);
}
} else if (dto.stampImageBase64) {
const stampRecord = await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
);
stampFileId = stampRecord.id;
}
const fileRecord = await this.uploadSignatureAsset(
contract,
`signature_${role.toLowerCase()}`,
imageBase64,
);
await this.contractsRepository.saveSignature({
contractId: contract.id,
role,
signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
stampFileId,
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,
);
// SIGNED_CUSTOMER is allowed only for the re-sign-to-add-a-stamp case that
// {@link sign} permits — otherwise the code would be useless on arrival.
const existing = await this.contractsRepository.findSignature(
contractId,
'CUSTOMER',
);
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
assertContractStatus(
contract,
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['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,
);
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
// Signing is one-shot, with one exception: a contract signed before the
// company stamp was required has to be sealed before EDR can counter-sign
// it, so the customer may sign again purely to attach the missing stamp.
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
assertContractStatus(
contract,
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
);
if (existing && !addingMissingStamp) {
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']);
// Both parties' stamps must be on file before the contract executes. The
// EDR stamp is enforced by applySignature below; the customer's is checked
// here so a contract signed before stamps existed can't slip through.
const customerSignature = await this.contractsRepository.findSignature(
contractId,
'CUSTOMER',
);
if (!customerSignature?.stampFileId) {
throw new BadRequestException(
'The customer stamp is missing on this contract — it cannot be counter-signed until the customer signs again with their company stamp.',
);
}
await this.applySignature(contract, dto, options);
const now = new Date();
const updates: Record<string, unknown> = {
fullyExecutedAt: now,
lockedAt: now,
};
// Clearance ALWAYS runs per booking — both contract kinds, both paths, and
// intercity. A signed contract carries no clearance cycle and collects no
// documents: the shipment instance created after signature does. Customs
// (Path B): the customer initiates the booking (GENERAL: via a shipment
// request) and uploads on it, GL reviews and completes it. Self-clearance
// (Path A) and intercity: the customer initiates/books and Operations
// reviews the booking documents.
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. */
/**
* Backoffice freeze, available at every step from the customer signature
* onward. The pre-suspension status is stashed so {@link resume} can put the
* contract back exactly where it was — a suspension you cannot lift is just a
* cancellation under another name.
*
* While SUSPENDED nothing moves: no new bookings or shipment requests
* (ContractBookingService / BookingRequestService), and no writes to the
* contract's existing bookings (BookingsRepository.update).
*/
async suspend(
contractId: string,
reason: string,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
assertContractStatus(contract, [...SUSPENDABLE_CONTRACT_STATUSES]);
await this.contractsRepository.createReviewNote(
contractId,
reason,
'SUSPENSION',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'SUSPENDED',
statusBeforeSuspension: contract.status,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.suspended(updated, reason);
return updated;
}
/** Lift a suspension — the contract returns to the status it was frozen at. */
async resume(
contractId: string,
note: string | undefined,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
assertContractStatus(contract, ['SUSPENDED']);
// Legacy safety net: a row suspended before the column existed has nothing
// to restore. CONTRACT_ACTIVE is the post-signature resting state for both
// contract kinds, so it is the only sane default.
const restored = contract.statusBeforeSuspension ?? 'CONTRACT_ACTIVE';
if (note?.trim()) {
await this.contractsRepository.createReviewNote(
contractId,
note.trim(),
'SUSPENSION_LIFTED',
actorId,
'STAFF',
);
}
await this.contractsRepository.update(contractId, {
status: restored,
statusBeforeSuspension: null,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.suspensionLifted(updated, note ?? null);
return updated;
}
/**
* Customer cancels their own contract so they can request a fresh one for the
* same lane — the duplicate-contract guard treats CANCELLED as released.
* Blocked while any booking on the contract is still live: cancelling a
* contract with cargo in motion would strand it.
*/
async cancelByCustomer(
contractId: string,
reason: string | undefined,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
throw new ConflictException(
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
);
}
if (contract.status === 'SUSPENDED') {
throw new ConflictException(
'This contract is suspended by EDR — contact us to lift the suspension first.',
);
}
const active = await this.contractsRepository.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
'Cancel or complete them before cancelling the contract.',
);
}
const body = reason?.trim() || 'Cancelled by the customer.';
await this.contractsRepository.createReviewNote(
contractId,
body,
'CANCELLATION',
userId,
'CUSTOMER',
);
await this.contractsRepository.update(contractId, {
status: 'CANCELLED',
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.cancelledByCustomer(updated, body);
return updated;
}
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')}`;
}
}