Implement intercity document handling and rejection notes for contracts

This commit is contained in:
Marshal
2026-07-21 10:20:36 +00:00
226 changed files with 13854 additions and 2973 deletions

View File

@@ -13,7 +13,10 @@ import { FilesService } from '../files/files.service';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import {
ClearanceMilestone,
type RiskAssignmentRecord,
} from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
@@ -85,6 +88,8 @@ export interface BookingClearanceView {
/** Customs risk level assigned by GL ET (import; visible to the customer). */
riskLevel?: string | null;
riskAssignedAt?: string | null;
/** Every risk decision, oldest first; the last entry is the current level. */
riskHistory?: RiskAssignmentRecord[];
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
@@ -282,6 +287,12 @@ export class BookingClearanceService {
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
? riskMilestone.triggeredAt.toISOString()
: null,
// Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are
// the current one; this is the trail behind it.
riskHistory:
riskMilestone?.status === 'COMPLETED'
? (riskMilestone.metadata?.riskHistory ?? [])
: [],
secondDuty,
importReleaseGranted:
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',

View File

@@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => {
expect(saved.status).toBe('COMPLETED');
expect(saved.metadata?.riskLevel).toBe('YELLOW');
});
/**
* The level is customer-visible and stays correctable until duty is advised,
* so a changed level must leave a trail rather than overwrite the last one.
*/
describe('risk history', () => {
it('records the first assignment with no previous level', async () => {
const { service } = makeService('COMPLETED');
const saved = await service.assignRisk('b-1', 'RED', 'user-1', 'initial rating', 'Abebe K.');
expect(saved.metadata?.riskHistory).toHaveLength(1);
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
level: 'RED',
assignedByUserId: 'user-1',
assignedBy: 'Abebe K.',
note: 'initial rating',
});
expect(saved.metadata?.riskHistory?.[0]).not.toHaveProperty('previousLevel');
});
it('keeps the earlier decision when the level is reassigned', async () => {
const { service } = makeService('COMPLETED');
await service.assignRisk('b-1', 'RED', 'user-1', undefined, 'Abebe K.');
const saved = await service.assignRisk('b-1', 'GREEN', 'user-2', 'downgraded', 'Sara M.');
expect(saved.metadata?.riskLevel).toBe('GREEN');
expect(saved.metadata?.riskHistory).toHaveLength(2);
// The original RED decision survives, with who made it.
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
level: 'RED',
assignedBy: 'Abebe K.',
});
expect(saved.metadata?.riskHistory?.[1]).toMatchObject({
level: 'GREEN',
previousLevel: 'RED',
assignedByUserId: 'user-2',
assignedBy: 'Sara M.',
note: 'downgraded',
});
});
it('keeps the whole chain across several reassignments, oldest first', async () => {
const { service } = makeService('COMPLETED');
await service.assignRisk('b-1', 'GREEN');
await service.assignRisk('b-1', 'YELLOW');
const saved = await service.assignRisk('b-1', 'RED');
expect(saved.metadata?.riskHistory?.map((e) => e.level)).toEqual([
'GREEN',
'YELLOW',
'RED',
]);
});
it('does not record a repeat of the level already assigned', async () => {
const { service } = makeService('COMPLETED');
await service.assignRisk('b-1', 'GREEN');
const saved = await service.assignRisk('b-1', 'GREEN');
expect(saved.metadata?.riskHistory).toHaveLength(1);
});
it('always leaves riskLevel equal to the last history entry', async () => {
const { service } = makeService('COMPLETED');
await service.assignRisk('b-1', 'RED');
const saved = await service.assignRisk('b-1', 'YELLOW');
const history = saved.metadata?.riskHistory ?? [];
expect(saved.metadata?.riskLevel).toBe(history[history.length - 1]?.level);
});
});
});

View File

@@ -210,15 +210,50 @@ export class ClearanceMilestoneService {
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
* catalog order T1_CLOSED → RISK_ASSIGNED.
*
* The level stays correctable until duty is advised off it, so each assignment
* is appended to `riskHistory` instead of silently replacing the last one — a
* customer-visible level that changes needs a trail of who changed it and when.
*/
async assignRisk(
bookingId: string,
riskLevel: CustomsRiskLevel,
userId?: string,
note?: string,
actor?: string,
): Promise<ClearanceMilestone> {
await this.assertT1Closed(bookingId);
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
const existing = await this.repo.findOne({
where: { bookingId, milestoneCode: 'RISK_ASSIGNED' },
});
const previousLevel = existing?.metadata?.riskLevel;
const history = existing?.metadata?.riskHistory ?? [];
// A repeat of the level already assigned is not a decision — recording it
// would pad the trail with entries that changed nothing.
const entries =
previousLevel === riskLevel
? history
: [
...history,
{
level: riskLevel,
...(previousLevel ? { previousLevel } : {}),
assignedAt: new Date().toISOString(),
assignedByUserId: userId ?? null,
assignedBy: actor ?? null,
note: note ?? null,
},
];
return this.completeWithMetadata(
bookingId,
'RISK_ASSIGNED',
{ riskLevel, riskHistory: entries },
userId,
note,
);
}
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */

View File

@@ -27,6 +27,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // workflowService
{} as never, // invoiceService
{} as never, // clearanceFeeService
{ createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService

View File

@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
{} as never, // workflowService
invoiceService as never,
{} as never, // clearanceFeeService
{ createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService

View File

@@ -18,6 +18,7 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
@@ -97,6 +98,7 @@ export class ContractBookingService {
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly clearanceFeeService: ClearanceFeeService,
private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
@@ -353,6 +355,12 @@ export class ContractBookingService {
const withContainers = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
// Tell staff the booking exists. Placed after the zero-price rollback (which
// hard-deletes the row) and before the consolidation gate, so it fires
// exactly once whether the booking parks for a partner or finalizes inline.
this.bookingNotifier.createdToStaff(withContainers ?? booking);
const intendedStatus =
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
@@ -482,6 +490,7 @@ export class ContractBookingService {
);
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
this.bookingNotifier.createdToStaff(result ?? booking);
return { booking: result ?? booking, warnings: [] };
}
@@ -569,7 +578,10 @@ export class ContractBookingService {
await this.clearanceFeeService.issueForBooking(booking, contract);
}
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
const created =
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
this.bookingNotifier.createdToStaff(created);
return created;
}
/**

View File

@@ -18,7 +18,10 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import {
ClearanceMilestone,
type RiskAssignmentRecord,
} from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
@@ -102,6 +105,8 @@ export interface ContractClearanceView {
/** Customs risk level assigned by GL ET (import; visible to the customer). */
riskLevel?: string | null;
riskAssignedAt?: string | null;
/** Every risk decision, oldest first; the last entry is the current level. */
riskHistory?: RiskAssignmentRecord[];
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
@@ -365,6 +370,11 @@ export class ContractClearanceService {
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
? riskMilestone.triggeredAt.toISOString()
: null,
// Every risk decision, oldest first — see booking-clearance.service.
riskHistory:
riskMilestone?.status === 'COMPLETED'
? (riskMilestone.metadata?.riskHistory ?? [])
: [],
secondDuty,
importReleaseGranted:
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',

View File

@@ -102,6 +102,27 @@ function maskPhone(phone: string): string {
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)) {
@@ -139,34 +160,39 @@ export class ContractTransitionService {
) {}
/**
* The phone the signing OTP is sent to and verified against: the signer's own
* IAM account number.
* 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 — a caller-supplied number would let an attacker point the code
* at their own phone. Ownership is already gated separately by
* 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 resolveSignerPhone(signerUserId?: string): Promise<string> {
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 number if it ever changes.
// 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 }> =
const rows: Array<{ phone_number: string | null; email: string | null }> =
await this.dataSource.query(
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
`SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`,
[signerUserId],
);
const phone = rows[0]?.phone_number?.trim();
if (!phone) {
const email = rows[0]?.email?.trim();
if (!phone && !email) {
throw new BadRequestException(
'Your account has no registered phone number. Add one in Settings → Account before signing.',
'Your account has no registered phone number or email. Add one in Settings → Account before signing.',
);
}
return phone;
return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) };
}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -992,11 +1018,11 @@ export class ContractTransitionService {
}
/**
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
* same number {@link sign} verifies against. The client never picks the number
* (that is the H12(b) trust property): it only asks us to send, and we resolve
* the phone from the authenticated user id. Returns a masked hint so the UI can
* say where the code went without exposing the full number.
* 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,
@@ -1011,9 +1037,9 @@ export class ContractTransitionService {
);
assertContractStatus(contract, ['CONTRACT_READY']);
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
await this.otpService.sendOtp({ phone: signerPhone });
return { sentTo: maskPhone(signerPhone) };
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
await this.otpService.sendOtp(signerContacts);
return { sentTo: maskSignerContacts(signerContacts) };
}
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
@@ -1040,17 +1066,17 @@ export class ContractTransitionService {
}
// 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
// phone, resolved server-side from the authenticated user id — never a
// caller-supplied number, which an attacker could point at their own
// phone. 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 numbers.
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
// 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({ phone: signerPhone }, dto.otp);
await this.otpService.verifyOtpForAction(signerContacts, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',

View File

@@ -31,6 +31,7 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -990,13 +991,16 @@ export class ContractsController {
assignRisk(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignRiskDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.milestoneService.assignRisk(
bookingId,
dto.riskLevel,
resolveAuthUserId(user),
dto.note,
// Risk history is read by people, so resolve the name now — the id alone
// would render as a UUID in the trail.
actorLabel(user),
);
}

View File

@@ -12,14 +12,35 @@ export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
/**
* One customs risk decision. Risk stays correctable until duty is advised off
* it, and the level is customer-visible, so every assignment is kept rather than
* overwritten — a disputed level needs to show what was set, by whom, and when.
*/
export interface RiskAssignmentRecord {
level: CustomsRiskLevel;
/** The level this replaced; absent on the first assignment. */
previousLevel?: CustomsRiskLevel;
assignedAt: string;
assignedByUserId?: string | null;
/** Display name resolved at assignment time, so the trail never shows a UUID. */
assignedBy?: string | null;
note?: string | null;
}
/**
* Structured payload some milestones carry beyond a plain note (doc §11.3):
* - RISK_ASSIGNED → `riskLevel`
* - RISK_ASSIGNED → `riskLevel` (current) + `riskHistory` (every assignment)
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
* Stored on the milestone so the timeline can render the value inline.
*/
export interface MilestoneMetadata {
riskLevel?: CustomsRiskLevel;
/**
* Append-only, oldest first. `riskLevel` is the current value and always
* equals the last entry's `level`.
*/
riskHistory?: RiskAssignmentRecord[];
dutyAmount?: number;
dutyCurrency?: string;
declarationSerial?: string;