mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
View manual (offline) payment channel settings
Confirm offline (bank transfer) invoice payment
This commit is contained in:
@@ -27,7 +27,10 @@ import { BookingPricingService } from './booking-pricing.service';
|
||||
import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { clearanceCodesForBooking } from './clearance.util';
|
||||
import {
|
||||
clearanceCodesForBooking,
|
||||
clearanceDocumentsOpen,
|
||||
} from './clearance.util';
|
||||
import {
|
||||
buildClearanceDocHistory,
|
||||
type ClearanceDocEvent,
|
||||
@@ -639,6 +642,7 @@ export class BookingTransitionService {
|
||||
history: ClearanceDocEvent[];
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
documentsOpen: boolean;
|
||||
phase?: string | null;
|
||||
milestones?: unknown[];
|
||||
nextAction?: unknown;
|
||||
@@ -758,6 +762,7 @@ export class BookingTransitionService {
|
||||
outputCode,
|
||||
documents,
|
||||
allApproved,
|
||||
documentsOpen: clearanceDocumentsOpen(booking),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -800,10 +805,14 @@ export class BookingTransitionService {
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
]);
|
||||
// Documents stay open until the shipment is paid — a customs shipment keeps
|
||||
// collecting paperwork (amended invoices, port documents) well past
|
||||
// clearance finalization. See {@link clearanceDocumentsOpen}.
|
||||
if (!clearanceDocumentsOpen(booking)) {
|
||||
throw new ConflictException(
|
||||
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
||||
);
|
||||
}
|
||||
const { inputCode } = clearanceCodesForBooking(booking);
|
||||
if (!inputCode) {
|
||||
throw new BadRequestException(
|
||||
@@ -841,19 +850,29 @@ export class BookingTransitionService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "DOCUMENTS_UNDER_REVIEW",
|
||||
} as never);
|
||||
// Only the pre-finalization submission drives the booking into review.
|
||||
// A later addition (an amended invoice while the shipment is already
|
||||
// scheduled) must never rewind the status or reopen the phased workflow —
|
||||
// it lands as a new PENDING document for GL to approve where it stands.
|
||||
const inDocumentPhase =
|
||||
booking.status === "AWAITING_DOCUMENTS" ||
|
||||
booking.status === "DOCUMENTS_UNDER_REVIEW";
|
||||
|
||||
if (this.isPhasedCustoms(booking)) {
|
||||
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||
bookingId,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
if (inDocumentPhase) {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
status: "DOCUMENTS_UNDER_REVIEW",
|
||||
} as never);
|
||||
|
||||
if (this.isPhasedCustoms(booking)) {
|
||||
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||
bookingId,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
const fileKeys = files.map((f) => f.fieldname);
|
||||
@@ -918,7 +937,14 @@ export class BookingTransitionService {
|
||||
note?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||
// GL keeps reviewing for as long as the customer can still submit — the
|
||||
// two sides share one predicate so they can never drift apart. Documents
|
||||
// added after clearance was finalized still need approving/querying.
|
||||
if (!clearanceDocumentsOpen(booking)) {
|
||||
throw new ConflictException(
|
||||
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
||||
);
|
||||
}
|
||||
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
||||
|
||||
const existing =
|
||||
@@ -935,15 +961,6 @@ export class BookingTransitionService {
|
||||
"A note is required when querying a document",
|
||||
);
|
||||
}
|
||||
if (
|
||||
status === 'QUERIED' &&
|
||||
this.isPhasedCustoms(booking) &&
|
||||
booking.preClearanceFinalizedAt
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Customer documents cannot be queried after pre-clearance is finalized.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.setDocumentReviewStatus(
|
||||
bookingId,
|
||||
@@ -970,7 +987,10 @@ export class BookingTransitionService {
|
||||
"CHANGES_REQUESTED",
|
||||
staffId,
|
||||
);
|
||||
if (this.isPhasedCustoms(booking)) {
|
||||
// Reopening the review phase only makes sense while clearance is still
|
||||
// being decided. Querying a document that arrived afterwards must not
|
||||
// drag a finalized shipment back into the GL review phase.
|
||||
if (this.isPhasedCustoms(booking) && !booking.preClearanceFinalizedAt) {
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
@@ -982,7 +1002,10 @@ export class BookingTransitionService {
|
||||
if (status === "QUERIED") {
|
||||
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
||||
}
|
||||
if (this.isPhasedCustoms(updated)) {
|
||||
// Same reasoning as the query branch: advance the workflow only while
|
||||
// clearance is still open. Approving a late-added document leaves an
|
||||
// already-finalized shipment's phase exactly where it is.
|
||||
if (this.isPhasedCustoms(updated) && !updated.preClearanceFinalizedAt) {
|
||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||
if (allApproved) {
|
||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { clearanceDocumentsOpen } from './clearance.util';
|
||||
|
||||
/**
|
||||
* The customer may attach clearance documents — and GL may review them — right
|
||||
* up to payment, not merely until clearance is finalized. Both the upload and
|
||||
* the review endpoint gate on this one predicate, so a drift here silently
|
||||
* desynchronizes the two sides.
|
||||
*/
|
||||
const booking = (patch: Partial<Booking>): Booking =>
|
||||
({ status: 'CLEARANCE_READY', paymentStatus: 'PENDING', ...patch }) as Booking;
|
||||
|
||||
describe('clearanceDocumentsOpen', () => {
|
||||
it('stays open across the whole pre-payment flow', () => {
|
||||
for (const status of [
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
]) {
|
||||
expect(clearanceDocumentsOpen(booking({ status }))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes once the shipment is paid or finished', () => {
|
||||
for (const status of ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED']) {
|
||||
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes on a dead booking', () => {
|
||||
for (const status of ['REJECTED', 'CANCELLED', 'EXPIRED']) {
|
||||
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes when payment settled before the status caught up', () => {
|
||||
expect(
|
||||
clearanceDocumentsOpen(
|
||||
booking({ status: 'PNR_GENERATED', paymentStatus: 'PAID' }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -113,3 +113,36 @@ export function clearanceCodesForBooking(booking: Booking): {
|
||||
includesCustoms,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Statuses after which clearance documents are closed: the shipment is paid
|
||||
* and moving. Everything before that — review, clearance ready, operation
|
||||
* request, batch selection, PNR, payment verification — still accepts new
|
||||
* customer documents and still lets GL review them.
|
||||
*/
|
||||
const CLEARANCE_DOCS_CLOSED_STATUSES = new Set<string>([
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
'EXPIRED',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True while the customer may still attach clearance documents and GL may
|
||||
* still approve or query them.
|
||||
*
|
||||
* Clearance finalization is NOT the cut-off: a customs shipment keeps
|
||||
* collecting paperwork (amended invoices, revised packing lists, port
|
||||
* documents) right up to the final invoice being settled. Both the customer's
|
||||
* upload endpoint and GL's review endpoint gate on this one predicate, so the
|
||||
* two sides can never drift apart.
|
||||
*/
|
||||
export function clearanceDocumentsOpen(booking: Booking): boolean {
|
||||
if (CLEARANCE_DOCS_CLOSED_STATUSES.has(booking.status)) return false;
|
||||
// Payment settled ahead of the status transition (webhook ordering).
|
||||
if (booking.paymentStatus === 'PAID') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type ClearanceDocEvent,
|
||||
} from '../bookings/clearance-doc-history.util';
|
||||
import { ClearanceEventService } from '../bookings/clearance-event.service';
|
||||
import { clearanceDocumentsOpen } from '../bookings/clearance.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
@@ -64,6 +65,7 @@ export interface BookingClearanceView {
|
||||
history: ClearanceDocEvent[];
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
documentsOpen: boolean;
|
||||
phase?: string | null;
|
||||
milestones?: Array<{
|
||||
id: string;
|
||||
@@ -354,6 +356,7 @@ export class BookingClearanceService {
|
||||
outputCode,
|
||||
documents,
|
||||
allApproved,
|
||||
documentsOpen: clearanceDocumentsOpen(booking),
|
||||
phase,
|
||||
milestones: milestones.map((m) => ({
|
||||
id: m.id,
|
||||
@@ -1161,7 +1164,16 @@ export class BookingClearanceService {
|
||||
for (const b of candidates) {
|
||||
if (!this.isPhasedCustomsBooking(b)) continue;
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
|
||||
if (!belongsOnEtClearanceQueue(milestones)) continue;
|
||||
// Surfaced on the queue row: every required document is approved even
|
||||
// though the booking status stays DOCUMENTS_UNDER_REVIEW until finalize.
|
||||
(b as Booking & { allDocsApproved?: boolean }).allDocsApproved =
|
||||
milestones.some(
|
||||
(m) =>
|
||||
m.milestoneCode === 'DOCUMENTS_APPROVED' &&
|
||||
(m.status === 'COMPLETED' || m.status === 'SKIPPED'),
|
||||
);
|
||||
filtered.push(b);
|
||||
}
|
||||
const rows = await this.attachContractSummary(filtered);
|
||||
return this.narrowToYardScope(rows, user);
|
||||
|
||||
Reference in New Issue
Block a user