mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
fix issue
This commit is contained in:
@@ -78,12 +78,21 @@ export class ClearanceFeeService {
|
||||
* the pre-fee flow instead of dead-ending.
|
||||
*/
|
||||
async gateApplies(contract: Contract): Promise<boolean> {
|
||||
if (!contract.customsClearingEnabled || !contract.companyId) return false;
|
||||
if ((await this.feeAmountOrNull(contract)) !== null) return true;
|
||||
this.logger.warn(
|
||||
`Contract ${contract.reference} has customs enabled but no frozen clearance fee — skipping the prepay gate (legacy contract).`,
|
||||
);
|
||||
return false;
|
||||
// Customs disabled → the prepay gate genuinely does not apply.
|
||||
if (!contract.customsClearingEnabled) return false;
|
||||
// No company to bill (government / unlinked) → the gate cannot raise an
|
||||
// invoice, so it stays out of the flow (same rule the booking invoice uses).
|
||||
if (!contract.companyId) return false;
|
||||
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
|
||||
// silently waive the gate — that ships clearance for free. Hard-fail exactly
|
||||
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
|
||||
// missing fee blocks counter-sign / shipment instead of bypassing payment.
|
||||
if ((await this.feeAmountOrNull(contract)) === null) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
|
||||
|
||||
@@ -793,17 +793,34 @@ export class ContractTransitionService {
|
||||
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 (SMS'd to the customer's phone)
|
||||
// must be verified before the signature is applied.
|
||||
if (!dto.otpPhone || !dto.otp) {
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
// signature is applied. H12(b): verify against the CONTRACT COMPANY's
|
||||
// registered phone — never the caller-supplied dto.otpPhone, which an
|
||||
// attacker could point at their own phone to sign someone else's
|
||||
// contract. The OTP is issued to the company's registered number.
|
||||
const companyPhone = contract.company?.phone?.trim();
|
||||
if (!companyPhone) {
|
||||
throw new BadRequestException(
|
||||
'The contract company has no registered phone on file to verify the signing OTP against',
|
||||
);
|
||||
}
|
||||
if (!dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
|
||||
await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
|
||||
@@ -524,12 +524,18 @@ export class ContractsController {
|
||||
|
||||
@Post(':id/renew')
|
||||
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
|
||||
renew(
|
||||
async renew(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() _dto: RenewContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.renew(id, user?.id ?? user?.sub);
|
||||
// H12(c): a customer may only renew a contract their company owns. Staff
|
||||
// with bookings.view bypass, mirroring getContractView/downloadContractDocument.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.transitionService.renew(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
|
||||
@@ -544,10 +550,17 @@ export class ContractsController {
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
|
||||
uploadClearanceDocuments(
|
||||
async uploadClearanceDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
// H12(c): only the owning company's customer may upload clearance docs.
|
||||
// Staff with bookings.view bypass, mirroring the other contract handlers.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.clearanceService.uploadDocuments(id, files ?? []);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const;
|
||||
// Canonical UPPERCASE — everything downstream (booking gating, pricing
|
||||
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
|
||||
// against 'WITH_RETURN'/'WITHOUT_RETURN'. Lowercase input is normalized.
|
||||
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||
|
||||
export {
|
||||
CONTRACT_KINDS,
|
||||
@@ -161,6 +164,9 @@ export class CreateContractDto {
|
||||
|
||||
@ApiPropertyOptional({ enum: EQUIPMENT_RETURNS })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.toUpperCase() : value,
|
||||
)
|
||||
@IsIn([...EQUIPMENT_RETURNS])
|
||||
equipmentReturn?: string;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user