From 4ed0e22f5561ae0bd924171f988ef4998ea4bf67 Mon Sep 17 00:00:00 2001 From: marshal Date: Wed, 3 Jun 2026 15:45:38 +0300 Subject: [PATCH] refactor(bookings): replace RFQ/quotation flow with submit, staff review, approval routing, and payment stubs --- .../1749200000000-BookingFlowRefactor.ts | 50 +++ .../bookings/booking-contract.service.ts | 106 +++++ .../bookings/booking-payment.service.ts | 130 ++++++ .../bookings/booking-pricing.service.ts | 248 +++++++++++ .../modules/bookings/booking-status.util.ts | 10 + .../bookings/booking-transition.service.ts | 291 +++++++++++++ .../modules/bookings/bookings.controller.ts | 394 ++++++++++++------ .../src/modules/bookings/bookings.module.ts | 13 +- .../modules/bookings/bookings.repository.ts | 115 ++++- .../src/modules/bookings/bookings.service.ts | 223 ++-------- .../dto/generate-price-response.dto.ts | 32 ++ .../bookings/dto/request-changes.dto.ts | 74 ++++ .../modules/bookings/dto/update-status.dto.ts | 41 -- .../entities/booking-review-note.entity.ts | 26 ++ .../bookings/entities/booking.entity.ts | 45 +- .../bookings/payments-webhook.controller.ts | 17 + 16 files changed, 1448 insertions(+), 367 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-status.util.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts diff --git a/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts new file mode 100644 index 000000000..162672727 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BookingFlowRefactor1749200000000 implements MigrationInterface { + name = 'BookingFlowRefactor1749200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_review_note ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + author_id UUID, + note TEXT NOT NULL, + type VARCHAR(30) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id + ON freight.booking_review_note(booking_id); + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID, + ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS contract_summary TEXT, + ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ; + `); + + await queryRunner.query(` + UPDATE freight.bookings SET status = 'SUBMITTED' + WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED'); + UPDATE freight.bookings SET status = 'REJECTED' + WHERE status = 'QUOTATION_REJECTED'; + UPDATE freight.bookings SET status = 'CANCELLED' + WHERE status = 'CANCELLED'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS locked_at, + DROP COLUMN IF EXISTS contract_summary, + DROP COLUMN IF EXISTS marketing_approved_at, + DROP COLUMN IF EXISTS marketing_approved_by_id; + `); + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts new file mode 100644 index 000000000..65c317eaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -0,0 +1,106 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Readable } from 'stream'; + +import { FilesService } from '../files/files.service'; +import { BookingsRepository } from './bookings.repository'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; + +@Injectable() +export class BookingContractService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + ) {} + + buildContractSummary(booking: Booking): string { + const direction = + booking.tradeDirection === 'IMPORT' + ? 'Import' + : booking.tradeDirection === 'EXPORT' + ? 'Export' + : booking.tradeDirection; + + const cargo = booking.cargoType; + const isBulk = cargo?.requiresDirectorApproval; + + let cargoLabel: string; + if (isBulk) { + cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`; + } else { + const lines = + booking.bookingContainers?.map((bc) => { + const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container'; + return `${bc.quantity}× ${label}`; + }) ?? []; + cargoLabel = + lines.length > 0 + ? `Container (${lines.join(', ')})` + : `Container (${cargo?.cargoTypeName ?? 'Standard'})`; + } + + return `Operation: ${direction} | Cargo Type: ${cargoLabel}`; + } + + async getSummary(bookingId: string): Promise<{ summary: string }> { + const booking = await this.requireBooking(bookingId); + const summary = booking.contractSummary ?? this.buildContractSummary(booking); + return { summary }; + } + + async generateContract(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['APPROVED']); + + const summary = this.buildContractSummary(booking); + const body = [ + 'FREIGHT CONTRACT (STUB)', + `Reference: ${booking.reference}`, + summary, + `Total: ${booking.totalAmount} ${booking.paymentCurrency}`, + `Trade: ${booking.tradeDirection}`, + ].join('\n'); + + const buffer = Buffer.from(body, 'utf-8'); + const file: Express.Multer.File = { + fieldname: 'contract', + originalname: `contract-${booking.reference}.txt`, + encoding: '7bit', + mimetype: 'text/plain', + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: '', + filename: '', + path: '', + }; + + await this.filesService.upload({ + resourceId: bookingId, + resource: 'bookings', + code: 'contract', + file, + }); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CONTRACT_READY', + contractSummary: summary, + } as never); + return updated!; + } + + async streamContract(bookingId: string) { + const record = await this.filesService.findByCode( + bookingId, + 'bookings', + 'contract', + ); + return this.filesService.streamById(record.id); + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts new file mode 100644 index 000000000..80476ead2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -0,0 +1,130 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { FilesService } from '../files/files.service'; +import { BookingsRepository } from './bookings.repository'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; + +const PROOF_MAX_BYTES = 5 * 1024 * 1024; +const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png']; + +@Injectable() +export class BookingPaymentService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + ) {} + + async generatePnr(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['FULLY_EXECUTED']); + + if (booking.paymentCurrency !== 'ETB') { + throw new BadRequestException('PNR generation is only for ETB payers'); + } + + const year = new Date().getFullYear(); + const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`; + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'PNR_GENERATED', + pnrCode, + paymentStatus: 'PNR_GENERATED', + } as never); + return updated!; + } + + async submitPaymentProof( + bookingId: string, + file: Express.Multer.File, + ): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['FULLY_EXECUTED']); + + if (booking.paymentCurrency !== 'USD') { + throw new BadRequestException('Payment proof upload is only for USD payers'); + } + + this.validateProofFile(file); + + await this.filesService.upload({ + resourceId: bookingId, + resource: 'bookings', + code: 'payment_proof', + file, + }); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'PAYMENT_VERIFICATION_IN_PROGRESS', + paymentStatus: 'VERIFICATION_IN_PROGRESS', + } as never); + return updated!; + } + + async verifyPayment(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['PAYMENT_VERIFICATION_IN_PROGRESS']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'PAID', + paymentStatus: 'PAID', + } as never); + return updated!; + } + + async handleBankCallback(pnrCode: string): Promise { + const booking = await this.bookingsRepository.findByPnrCode(pnrCode); + if (!booking) { + throw new NotFoundException(`No booking found for PNR ${pnrCode}`); + } + + if (booking.status !== 'PNR_GENERATED') { + throw new BadRequestException( + `Booking ${booking.reference} is not awaiting bank payment (status: ${booking.status})`, + ); + } + + const updated = await this.bookingsRepository.update(booking.id, { + status: 'PAID', + paymentStatus: 'PAID', + } as never); + return updated!; + } + + async getPaymentRequestLetter( + bookingId: string, + ): Promise<{ buffer: Buffer; filename: string }> { + const booking = await this.requireBooking(bookingId); + const body = [ + 'PAYMENT REQUEST LETTER (STUB)', + `Reference: ${booking.reference}`, + `Amount: ${booking.totalAmount} ${booking.paymentCurrency}`, + 'Pay at your bank and upload stamped proof.', + ].join('\n'); + return { + buffer: Buffer.from(body, 'utf-8'), + filename: `payment-request-${booking.reference}.txt`, + }; + } + + private validateProofFile(file: Express.Multer.File): void { + if (!file?.buffer?.length) { + throw new BadRequestException('Payment proof file is required'); + } + if (file.size > PROOF_MAX_BYTES) { + throw new BadRequestException('Payment proof must be 5MB or less'); + } + if (!PROOF_MIMES.includes(file.mimetype)) { + throw new BadRequestException('Payment proof must be PDF, JPG, or PNG'); + } + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findById(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts new file mode 100644 index 000000000..621fb58e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -0,0 +1,248 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; + +import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { + IRatesRepository, + RATES_REPOSITORY, +} from '../rule-engine/interfaces/rates.repository.interface'; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from '../rule-engine/interfaces/service-types.repository.interface'; +import { Rate } from '../rule-engine/entities/rate.entity'; +import { + AppliedCargoModifier, + BookingEvaluationInput, + RuleEngineService, +} from '../rule-engine/rule-engine.service'; +import { BookingsRepository } from './bookings.repository'; +import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; + +@Injectable() +export class BookingPricingService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly ruleEngineService: RuleEngineService, + private readonly containerTypesService: ContainerTypesService, + @Inject(RATES_REPOSITORY) + private readonly ratesRepo: IRatesRepository, + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly serviceTypesRepo: IServiceTypesRepository, + ) {} + + async generatePrice(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['DRAFT']); + + const evalInput = await this.buildEvalInputForBooking(booking); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + this.ruleEngineService.assertNoHardBlocks(ruleResult); + + const lineItems: PriceLineItemDto[] = []; + let total = 0; + + const baseLines = await this.computeBaseRailLines(booking, evalInput); + for (const line of baseLines) { + lineItems.push(line); + total += line.amount; + } + + for (const mod of ruleResult.appliedModifiers) { + const item: PriceLineItemDto = { + code: mod.surchargeTypeCode, + description: `Surcharge: ${mod.surchargeTypeCode}`, + amount: mod.calculatedAmount, + currency: mod.currency, + }; + lineItems.push(item); + total += mod.calculatedAmount; + } + + await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total); + + await this.bookingsRepository.update(bookingId, { + totalAmount: total, + priorityScore: ruleResult.priorityScore, + } as never); + + return { + bookingId, + totalAmount: total, + currency: booking.paymentCurrency, + lineItems, + warnings: ruleResult.warnings, + }; + } + + async buildEvalInputForBooking(booking: Booking): Promise { + const containers = await Promise.all( + (booking.bookingContainers ?? []).map(async (bc) => { + const ct = await this.containerTypesService.findById(bc.containerTypeId); + const vgm = Number(bc.vgmPerUnitTons); + const qty = bc.quantity; + return { + containerTypeId: bc.containerTypeId, + quantity: qty, + vgmPerUnitTons: vgm, + totalVgmTons: qty * vgm, + isReefer: ct.isReefer, + }; + }), + ); + return { + cargoTypeId: booking.cargoTypeId, + serviceTypeId: booking.serviceTypeId, + paymentCurrency: booking.paymentCurrency, + tradeDirection: booking.tradeDirection, + isHazardous: booking.isHazardous, + allowConsolidation: booking.allowConsolidation, + shippingLineId: booking.shippingLineId, + containers, + }; + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } + + /** Recompute priority on submit (USD + service tier). */ + async computeSubmitPriorityScore(booking: Booking): Promise { + const evalInput = await this.buildEvalInputForBooking(booking); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + let score = ruleResult.priorityScore; + + const serviceType = await this.serviceTypesRepo.findById(booking.serviceTypeId); + if (booking.paymentCurrency === 'USD' && serviceType) { + const code = (serviceType.code ?? '').toUpperCase(); + const hasForwarding = + serviceType.includesFirstMile || + serviceType.includesLastMile || + code.includes('FORWARD') || + code.includes('Y'); + const railOnly = code.includes('RAIL') && !hasForwarding; + + if (hasForwarding) score += 1000; + else if (railOnly || code.includes('X')) score += 500; + } + + return score; + } + + private async computeBaseRailLines( + booking: Booking, + evalInput: BookingEvaluationInput, + ): Promise { + const liveRates = await this.ratesRepo.findLiveRates(); + const currency = booking.paymentCurrency; + const isBulk = booking.cargoType?.requiresDirectorApproval ?? false; + + const rateType = + booking.tradeDirection === 'IMPORT' + ? isBulk + ? 'BULK_IMPORT' + : 'CONTAINER_IMPORT' + : booking.tradeDirection === 'EXPORT' + ? isBulk + ? 'BULK_EXPORT' + : 'CONTAINER_EXPORT' + : 'INTERCITY_CONTAINER'; + + const lines: PriceLineItemDto[] = []; + const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + + for (const container of evalInput.containers) { + const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency); + if (!rate) continue; + + const amount = this.amountForRate(rate, container.quantity, wagonCount); + lines.push({ + code: rateType, + description: `Base rail (${rateType})`, + amount, + currency: rate.currency, + }); + } + + if (lines.length === 0) { + const fallback = liveRates.find( + (r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE', + ); + if (fallback) { + const amount = this.amountForRate(fallback, 1, wagonCount); + lines.push({ + code: rateType, + description: `Base rail (${rateType})`, + amount, + currency: fallback.currency, + }); + } + } + + return lines; + } + + private pickRate( + rates: Rate[], + rateType: string, + containerTypeId: string, + currency: string, + ): Rate | undefined { + return ( + rates.find( + (r) => + r.rateType === rateType && + r.currency === currency && + r.containerTypeId === containerTypeId, + ) ?? + rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId) + ); + } + + private amountForRate(rate: Rate, quantity: number, wagonCount: number): number { + const value = Number(rate.rateValue); + switch (rate.rateUnit) { + case 'PER_CONTAINER': + return value * quantity; + case 'PER_WAGON': + return value * wagonCount; + case 'PER_TON': + return value * quantity; + case 'FLAT': + return value; + default: + return value * quantity; + } + } + + private async persistPriceRun( + bookingId: string, + modifiers: AppliedCargoModifier[], + _total: number, + ): Promise { + await this.bookingsRepository.clearPricingArtifacts(bookingId); + const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId); + + const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id])); + const rows = modifiers + .map((m) => { + const snapshotId = snapshotByRateId.get(m.rateId); + if (!snapshotId) return null; + return { + bookingId, + surchargeTypeId: m.surchargeTypeId, + triggerValue: m.triggerValue, + calculatedAmount: m.calculatedAmount, + rateSnapshotId: snapshotId, + }; + }) + .filter((r): r is NonNullable => r !== null); + + if (rows.length > 0) { + await this.bookingsRepository.createCargoModifiers(rows); + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts new file mode 100644 index 000000000..fe9152149 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts @@ -0,0 +1,10 @@ +import { ConflictException } from '@nestjs/common'; +import { Booking } from './entities/booking.entity'; + +export function assertBookingStatus(booking: Booking, allowed: string[]): void { + if (!allowed.includes(booking.status)) { + throw new ConflictException( + `Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts new file mode 100644 index 000000000..d40d6590b --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -0,0 +1,291 @@ +import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common'; + +import { RuleEngineService } from '../rule-engine/rule-engine.service'; +import { BookingContractService } from './booking-contract.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingsRepository } from './bookings.repository'; +import { assertBookingStatus } from './booking-status.util'; +import { Booking } from './entities/booking.entity'; +import { BookingsService } from './bookings.service'; + +@Injectable() +export class BookingTransitionService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly ruleEngineService: RuleEngineService, + private readonly pricingService: BookingPricingService, + private readonly contractService: BookingContractService, + @Inject(forwardRef(() => BookingsService)) + private readonly bookingsService: BookingsService, + ) {} + + async submit(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + + if (Number(booking.totalAmount) <= 0) { + throw new BadRequestException( + 'Generate a price before submitting (POST /bookings/:id/generate-price)', + ); + } + + const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + await this.ruleEngineService.snapshotLiveRates(bookingId); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'SUBMITTED', + priorityScore, + } as never); + return this.bookingsService.findById(updated!.id); + } + + async requestChanges( + bookingId: string, + note: string, + actorId?: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SUBMITTED']); + + await this.bookingsRepository.createReviewNote( + bookingId, + note, + 'CHANGES_REQUESTED', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CHANGES_REQUESTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async acceptIntake(bookingId: string, actorId?: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SUBMITTED']); + + await this.ruleEngineService.instantiateApprovalSteps( + bookingId, + booking.cargoTypeId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'PENDING_APPROVAL', + approvedByStaffId: actorId ?? booking.approvedByStaffId, + approvedByStaffAt: actorId ? new Date() : booking.approvedByStaffAt, + } as never); + return this.bookingsService.findById(updated!.id); + } + + async staffReject( + bookingId: string, + reason: string, + actorId?: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'REJECTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async approveStep( + bookingId: string, + stepId: string, + actorId: string, + requiredRole: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', + ]); + + const step = await this.bookingsRepository.findApprovalStepById( + bookingId, + stepId, + ); + if (!step || step.status !== 'PENDING') { + throw new BadRequestException('Approval step not found or already actioned'); + } + + const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId); + if (!next || next.id !== step.id) { + throw new BadRequestException( + 'Approval steps must be completed in order', + ); + } + + if (step.requiredRole !== requiredRole) { + throw new BadRequestException( + `Step requires role ${step.requiredRole}, not ${requiredRole}`, + ); + } + + const blocksRole = step.approvalRule?.blocksRole; + if (blocksRole && blocksRole === requiredRole) { + throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + } + + await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + + const updates: Record = {}; + const now = new Date(); + + if (requiredRole === 'LINE_STAFF') { + updates.status = 'APPROVED_PENDING_SIGNATURE'; + updates.approvedByStaffId = actorId; + updates.approvedByStaffAt = now; + } else if (requiredRole === 'DIRECTOR') { + updates.signedByDirectorId = actorId; + updates.signedByDirectorAt = now; + } else if (requiredRole === 'CEO') { + updates.signedByCeoId = actorId; + updates.signedByCeoAt = now; + } + + const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId); + if (allDone) { + updates.status = 'APPROVED'; + } + + if (Object.keys(updates).length > 0) { + await this.bookingsRepository.update(bookingId, updates as never); + } + + return this.bookingsService.findById(bookingId); + } + + async rejectStep( + bookingId: string, + stepId: string, + actorId: string, + reason: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + + const step = await this.bookingsRepository.findApprovalStepById( + bookingId, + stepId, + ); + if (!step) throw new BadRequestException('Approval step not found'); + + await this.bookingsRepository.completeApprovalStep( + step.id, + actorId, + 'REJECTED', + reason, + ); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'REJECTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async customerSign(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['CONTRACT_READY']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'SIGNED_CUSTOMER', + customerSignedAt: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async marketingApprove( + bookingId: string, + actorId?: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SIGNED_CUSTOMER']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'FULLY_EXECUTED', + fullyExecutedAt: new Date(), + marketingApprovedById: actorId ?? null, + marketingApprovedAt: new Date(), + lockedAt: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async startTransit(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['PAID']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'IN_TRANSIT', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async complete(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['IN_TRANSIT']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'COMPLETED', + endDate: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async cancel(bookingId: string, reason: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + 'DRAFT', + 'SUBMITTED', + 'CHANGES_REQUESTED', + 'PENDING_APPROVAL', + 'CONTRACT_READY', + ]); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CANCELLED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async enrichBookingResponse(booking: Booking): Promise { + const note = await this.bookingsRepository.findLatestReviewNote( + booking.id, + 'CHANGES_REQUESTED', + ); + const summary = + booking.contractSummary ?? + this.contractService.buildContractSummary(booking); + return { + ...booking, + latestChangeRequestNote: note?.note ?? null, + contractSummary: summary, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 41a1a09c4..c8572dd76 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + Header, HttpCode, Param, ParseUUIDPipe, @@ -10,10 +11,12 @@ import { Post, Query, Request, + Res, + StreamableFile, UploadedFiles, UseInterceptors, -} from "@nestjs/common"; -import { AnyFilesInterceptor } from "@nestjs/platform-express"; +} from '@nestjs/common'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, @@ -21,181 +24,332 @@ import { ApiOkResponse, ApiOperation, ApiTags, -} from "@nestjs/swagger"; +} from '@nestjs/swagger'; +import type { Response } from 'express'; -import { BookingReferenceDataService } from "./booking-reference-data.service"; -import { BookingsService } from "./bookings.service"; -import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; -import { CreateBookingDto } from "./dto/create-booking.dto"; -import { FilterBookingDto } from "./dto/filter-booking.dto"; -import { UpdateBookingDto } from "./dto/update-booking.dto"; -import { UpdateStatusDto } from "./dto/update-status.dto"; +import { BookingContractService } from './booking-contract.service'; +import { BookingPaymentService } from './booking-payment.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingTransitionService } from './booking-transition.service'; +import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingsService } from './bookings.service'; +import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; +import { CreateBookingDto } from './dto/create-booking.dto'; +import { FilterBookingDto } from './dto/filter-booking.dto'; +import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; +import { + ApproveStepDto, + CancelBookingDto, + RejectStepDto, + MarketingApproveDto, + RequestChangesDto, + StaffAcceptDto, + StaffRejectDto, +} from './dto/request-changes.dto'; +import { UpdateBookingDto } from './dto/update-booking.dto'; -@ApiTags("bookings") -@Controller("bookings") +@ApiTags('bookings') +@Controller('bookings') @ApiBearerAuth() export class BookingsController { constructor( private readonly bookingsService: BookingsService, private readonly bookingReferenceDataService: BookingReferenceDataService, + private readonly pricingService: BookingPricingService, + private readonly transitionService: BookingTransitionService, + private readonly contractService: BookingContractService, + private readonly paymentService: BookingPaymentService, ) {} - // ── 1. Create booking (multipart/form-data) ────────────────────────── @Post() @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes("multipart/form-data") - @ApiOperation({ - summary: "Create a new freight booking", - description: - "Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " + - "Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.", - }) - @ApiBody({ - description: - "Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " + - "Each uploaded file is saved as a row in the files table (resource=bookings).", - type: CreateBookingDto, - }) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) + @ApiBody({ type: CreateBookingDto }) create( @Body() dto: CreateBookingDto, @UploadedFiles() files: Express.Multer.File[], - @Request() req: any, + @Request() req: { user?: { id?: string; sub?: string } }, ) { - console.log( - "[BookingsController] Files received:", - files?.length, - files?.map((f) => ({ - fieldname: f.fieldname, - originalname: f.originalname, - size: f.size, - mimetype: f.mimetype, - })), - ); - const userId: string | undefined = req.user?.id ?? req.user?.sub; + const userId = req.user?.id ?? req.user?.sub; return this.bookingsService.create(dto, files ?? [], userId); } - // ── 2. Update draft booking (multipart/form-data) ───────────────────── - @Patch(":id") + @Patch(':id') @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes("multipart/form-data") + @ApiConsumes('multipart/form-data') @ApiOperation({ - summary: "Update a draft booking", - description: - "Only DRAFT bookings can be updated. New files are merged into existing documents.", + summary: 'Update booking', + description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', }) @ApiBody({ type: UpdateBookingDto }) update( - @Param("id", ParseUUIDPipe) id: string, + @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBookingDto, @UploadedFiles() files: Express.Multer.File[], ) { return this.bookingsService.update(id, dto, files ?? []); } - // ── 3. List bookings (paginated + filtered) ─────────────────────────── @Get() - @ApiOperation({ - summary: "List freight bookings (paginated)", - description: - "Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " + - "paymentCurrency, allowConsolidation, consolidationPaired. " + - "Sort by createdAt or priorityScore.", - }) + @ApiOperation({ summary: 'List freight bookings (paginated)' }) findAll(@Query() filter: FilterBookingDto) { return this.bookingsService.findAll(filter); } - // ── Booking form catalog (must be before :id) ───────────────────────── - @Get("reference-data") + @Get('queues/:queue') @ApiOperation({ - summary: "Booking form catalog", - description: - "Returns yards, container types (grouped by size), service types, shipping lines, " + - "and hierarchical cargo types for the booking UI in a single payload.", + summary: 'List bookings for a dashboard queue', + description: 'Queues: intake, approval, signatures, marketing, finance', }) + findQueue( + @Param('queue') queue: string, + @Query() filter: FilterBookingDto, + @Query('excludeBulk') excludeBulk?: string, + ) { + return this.bookingsService.findQueue(queue, filter, { + excludeBulk: excludeBulk === 'true', + }); + } + + @Get('reference-data') + @ApiOperation({ summary: 'Booking form catalog' }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { return this.bookingReferenceDataService.getReferenceData(); } - // ── 5. Lookup by reference (must be before :id to avoid conflict) ───── - @Get("by-reference/:reference") - @ApiOperation({ - summary: "Get a freight booking by reference number", - description: "Lookup booking by its human-readable reference string.", - }) - findByReference(@Param("reference") reference: string) { - return this.bookingsService.findByReference(reference); + @Get('by-reference/:reference') + @ApiOperation({ summary: 'Get booking by reference' }) + async findByReference(@Param('reference') reference: string) { + const booking = await this.bookingsService.findByReference(reference); + return this.transitionService.enrichBookingResponse(booking); } - // ── 4. Get single booking by ID ─────────────────────────────────────── - @Get(":id") - @ApiOperation({ summary: "Get a freight booking by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.bookingsService.findById(id); + @Get(':id') + @ApiOperation({ summary: 'Get booking by ID' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.bookingsService.findById(id); + return this.transitionService.enrichBookingResponse(booking); } - // ── 6. Soft-delete (DRAFT only) ─────────────────────────────────────── - @Delete(":id") + @Delete(':id') @HttpCode(204) - @ApiOperation({ - summary: "Soft-delete a freight booking", - description: "Only DRAFT bookings can be deleted.", - }) - remove(@Param("id", ParseUUIDPipe) id: string) { + @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) + remove(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } - // ── 7. Unified status transition ────────────────────────────────────── - @Patch(":id/status") - @ApiOperation({ - summary: "Transition booking status", - description: - "Unified endpoint for all status transitions. Actions: " + - "SUBMIT, APPROVE_STAFF, APPROVE_DIRECTOR, APPROVE_CEO, REJECT, CANCEL, ACTIVATE, EXPIRE. " + - "Approval routing: Standard → LINE_STAFF → DIRECTOR → SIGNED. " + - "Bulk/high-volume → DIRECTOR → CEO → SIGNED.", - }) - updateStatus( - @Param("id", ParseUUIDPipe) id: string, - @Body() dto: UpdateStatusDto, - ) { - return this.bookingsService.updateStatus(id, dto); + @Post(':id/generate-price') + @ApiOperation({ summary: 'Generate price preview (DRAFT only)' }) + @ApiOkResponse({ type: GeneratePriceResponseDto }) + generatePrice(@Param('id', ParseUUIDPipe) id: string) { + return this.pricingService.generatePrice(id); } - // ── 8. Request or auto-pair consolidation ───────────────────────────── - @Post(":id/consolidation") - @ApiOperation({ - summary: "Request freight consolidation", - description: - "Searches for a partner whose container quantity complements yours to fill whole wagon(s) " + - "(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.", - }) - requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { + @Post(':id/submit') + @ApiOperation({ summary: 'Customer submit booking' }) + async submit(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.submit(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/request-changes') + @ApiOperation({ summary: 'Staff return booking for customer updates' }) + async requestChanges( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestChangesDto, + ) { + const booking = await this.transitionService.requestChanges( + id, + dto.note, + dto.actorId, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/accept') + @ApiOperation({ summary: 'Staff accept intake → start approval chain' }) + async acceptIntake( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: StaffAcceptDto, + ) { + const booking = await this.transitionService.acceptIntake(id, dto.actorId); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/reject') + @ApiOperation({ summary: 'Staff final reject' }) + async staffReject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: StaffRejectDto, + ) { + const booking = await this.transitionService.staffReject( + id, + dto.reason, + dto.actorId, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/approval-steps/:stepId/approve') + @ApiOperation({ summary: 'Approve one approval step in sequence' }) + async approveStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: ApproveStepDto, + ) { + const booking = await this.transitionService.approveStep( + id, + stepId, + dto.actorId, + dto.requiredRole, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/approval-steps/:stepId/reject') + @ApiOperation({ summary: 'Reject at approval step' }) + async rejectStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: RejectStepDto, + ) { + const booking = await this.transitionService.rejectStep( + id, + stepId, + dto.actorId, + dto.reason, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/contract/generate') + @ApiOperation({ summary: 'Generate contract document' }) + async generateContract(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.contractService.generateContract(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id/contract') + @ApiOperation({ summary: 'Download contract file' }) + async downloadContract( + @Param('id', ParseUUIDPipe) id: string, + @Res({ passthrough: true }) res: Response, + ) { + const { stream, record } = await this.contractService.streamContract(id); + res.set({ + 'Content-Type': record.mimeType ?? 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${record.name}"`, + }); + return new StreamableFile(stream); + } + + @Get(':id/summary') + @ApiOperation({ summary: 'Contract summary string for dashboard' }) + getSummary(@Param('id', ParseUUIDPipe) id: string) { + return this.contractService.getSummary(id); + } + + @Post(':id/customer/sign') + @ApiOperation({ summary: 'Customer digital signature' }) + async customerSign(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.customerSign(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/marketing/approve') + @ApiOperation({ summary: 'Marketing verify and fully execute' }) + async marketingApprove( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: MarketingApproveDto, + ) { + const booking = await this.transitionService.marketingApprove( + id, + dto.actorId, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/payment/pnr') + @ApiOperation({ summary: 'Generate PNR code (ETB)' }) + async generatePnr(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.paymentService.generatePnr(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/payment/proof') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload USD payment proof' }) + async submitPaymentProof( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + const file = files?.[0]; + const booking = await this.paymentService.submitPaymentProof(id, file); + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id/payment/request-letter') + @ApiOperation({ summary: 'Download payment request letter (USD stub)' }) + @Header('Content-Type', 'text/plain') + async paymentRequestLetter( + @Param('id', ParseUUIDPipe) id: string, + @Res({ passthrough: true }) res: Response, + ) { + const { buffer, filename } = + await this.paymentService.getPaymentRequestLetter(id); + res.set('Content-Disposition', `attachment; filename="${filename}"`); + return new StreamableFile(buffer); + } + + @Post(':id/payment/verify') + @ApiOperation({ summary: 'Finance verify USD payment' }) + async verifyPayment(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.paymentService.verifyPayment(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/operations/start-transit') + @ApiOperation({ summary: 'Mark in transit' }) + async startTransit(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.startTransit(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/operations/complete') + @ApiOperation({ summary: 'Mark completed' }) + async complete(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.complete(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/cancel') + @ApiOperation({ summary: 'Cancel booking' }) + async cancel( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CancelBookingDto, + ) { + const booking = await this.transitionService.cancel(id, dto.reason); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/consolidation') + @ApiOperation({ summary: 'Request freight consolidation' }) + requestConsolidation(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } - // ── 9. Remove consolidation pairing ─────────────────────────────────── - @Delete(":id/consolidation") - @ApiOperation({ - summary: "Remove consolidation pairing", - description: - "Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.", - }) - removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { + @Delete(':id/consolidation') + @ApiOperation({ summary: 'Remove consolidation pairing' }) + removeConsolidation(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.removeConsolidation(id); } - // ── 10. Get consolidation details ───────────────────────────────────── - @Get(":id/consolidation") - @ApiOperation({ - summary: "Get consolidation details", - description: - "Returns partner booking details and split billing information.", - }) - getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { + @Get(':id/consolidation') + @ApiOperation({ summary: 'Get consolidation details' }) + getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.getConsolidationDetails(id); } - } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 9785ec270..f3b3a9360 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -5,15 +5,21 @@ import { CustomersModule } from '../customers/customers.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { BookingContractService } from './booking-contract.service'; +import { BookingPaymentService } from './booking-payment.service'; +import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingTransitionService } from './booking-transition.service'; import { BookingsController } from './bookings.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { BookingsService } from './bookings.service'; +import { PaymentsWebhookController } from './payments-webhook.controller'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingContainer } from './entities/booking-container.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; +import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; @Module({ @@ -24,18 +30,23 @@ import { Booking } from './entities/booking.entity'; BookingCargoModifier, BookingApprovalStep, BookingRateSnapshot, + BookingReviewNote, ]), FilesModule, MinioModule, CustomersModule, RuleEngineModule, ], - controllers: [BookingsController], + controllers: [BookingsController, PaymentsWebhookController], providers: [ BookingsService, BookingsRepository, ConsolidationService, BookingReferenceDataService, + BookingPricingService, + BookingTransitionService, + BookingContractService, + BookingPaymentService, ], exports: [BookingsService], }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index f329c942f..3a6155f3b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,13 +1,14 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { DataSource, FindOptionsWhere, Repository } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingContainer } from './entities/booking-container.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; +import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; import { FileRecord } from '../files/entities/file.entity'; import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; @@ -66,6 +67,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.approvalSteps', 'steps') .leftJoinAndSelect('booking.rateSnapshots', 'snapshots') .leftJoinAndSelect('booking.cargoModifiers', 'modifiers') + .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') .where('booking.id = :id', { id }) .leftJoinAndMapMany( 'booking.files', @@ -216,15 +218,35 @@ export class BookingsRepository extends BaseRepository { await this.dataSource.getRepository(BookingContainer).delete({ bookingId }); } - /** Get pending approval step for a role. */ + /** Lowest-order pending approval step (sequential enforcement). */ + async findNextPendingApprovalStep( + bookingId: string, + ): Promise { + return this.dataSource.getRepository(BookingApprovalStep).findOne({ + where: { bookingId, status: 'PENDING' }, + order: { stepOrder: 'ASC' }, + relations: ['approvalRule'], + }); + } + + async findApprovalStepById( + bookingId: string, + stepId: string, + ): Promise { + return this.dataSource.getRepository(BookingApprovalStep).findOne({ + where: { bookingId, id: stepId }, + relations: ['approvalRule'], + }); + } + + /** Get pending approval step for a role (must match next in sequence). */ async findPendingApprovalStep( bookingId: string, requiredRole: string, ): Promise { - return this.dataSource.getRepository(BookingApprovalStep).findOne({ - where: { bookingId, requiredRole, status: 'PENDING' }, - order: { stepOrder: 'ASC' }, - }); + const next = await this.findNextPendingApprovalStep(bookingId); + if (!next || next.requiredRole !== requiredRole) return null; + return next; } /** Mark an approval step complete. */ @@ -277,4 +299,85 @@ export class BookingsRepository extends BaseRepository { where: { bookingId, rateId }, }); } + + async createReviewNote( + bookingId: string, + note: string, + type: ReviewNoteType, + authorId?: string, + ): Promise { + const repo = this.dataSource.getRepository(BookingReviewNote); + return repo.save( + repo.create({ bookingId, note, type, authorId: authorId ?? null }), + ); + } + + async findLatestReviewNote( + bookingId: string, + type?: ReviewNoteType, + ): Promise { + const repo = this.dataSource.getRepository(BookingReviewNote); + return repo.findOne({ + where: type ? { bookingId, type } : { bookingId }, + order: { createdAt: 'DESC' }, + }); + } + + async clearPricingArtifacts(bookingId: string): Promise { + await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId }); + await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId }); + } + + async findByPnrCode(pnrCode: string): Promise { + return this.repository.findOne({ where: { pnrCode } }); + } + + /** Queue listing with optional bulk exclusion for LINE_STAFF. */ + async findQueue(options: { + status: string | string[]; + page?: number; + pageSize?: number; + excludeBulk?: boolean; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ items: Booking[]; total: number }> { + const page = options.page ?? 1; + const pageSize = options.pageSize ?? 20; + const statuses = Array.isArray(options.status) ? options.status : [options.status]; + + const qb = this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.customer', 'customer') + .leftJoinAndSelect('booking.cargoType', 'cargo') + .leftJoinAndSelect('booking.serviceType', 'serviceType') + .where('booking.status IN (:...statuses)', { statuses }); + + if (options.excludeBulk) { + qb.andWhere('cargo.requires_director_approval = false'); + } + + const sortField = + options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + + const [items, total] = await qb + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + async findAndCountFiltered(where: FindOptionsWhere, options: { + skip: number; + take: number; + order: Record; + }): Promise<[Booking[], number]> { + return this.repository.findAndCount({ + where, + skip: options.skip, + take: options.take, + order: options.order, + }); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 3aaa12a70..1039e60c1 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -19,7 +19,7 @@ import { ConsolidationService } from './consolidation.service'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; -import { UpdateStatusDto } from './dto/update-status.dto'; +import { CUSTOMER_EDITABLE_STATUSES } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; import { FileRecord } from '../files/entities/file.entity'; @@ -242,8 +242,10 @@ export class BookingsService { files: Express.Multer.File[], ): Promise<{ booking: Booking; warnings: string[] }> { const existing = await this.findById(id); - if (existing.status !== 'DRAFT') { - throw new BadRequestException('Only DRAFT bookings can be updated'); + if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { + throw new BadRequestException( + 'Only DRAFT or CHANGES_REQUESTED bookings can be updated', + ); } const warnings: string[] = []; @@ -390,201 +392,32 @@ export class BookingsService { await this.bookingsRepository.softDelete(id); } - /** Unified status transition handler. */ - async updateStatus(id: string, dto: UpdateStatusDto): Promise { - const booking = await this.findById(id); - const { action, actorId, reason, requiredRole } = dto; + async findQueue( + queue: string, + filter: FilterBookingDto, + options?: { excludeBulk?: boolean }, + ): Promise<{ items: Booking[]; total: number }> { + const statusMap: Record = { + intake: 'SUBMITTED', + approval: 'PENDING_APPROVAL', + signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'], + marketing: 'SIGNED_CUSTOMER', + finance: 'PAYMENT_VERIFICATION_IN_PROGRESS', + }; - switch (action) { - case 'SUBMIT': - return this.handleSubmit(booking); - case 'SEND_QUOTATION': - return this.handleSendQuotation(booking); - case 'APPROVE_QUOTATION': - return this.handleApproveQuotation(booking); - case 'REJECT_QUOTATION': - return this.handleRejectQuotation(booking, reason); - case 'APPROVE_STEP': - return this.handleApproveStep(booking, actorId, requiredRole); - case 'APPROVE': - return this.handleFullyApproved(booking); - case 'CUSTOMER_SIGN': - return this.handleCustomerSign(booking); - case 'MARK_FULLY_EXECUTED': - return this.handleFullyExecuted(booking); - case 'MARK_PAID': - return this.handleMarkPaid(booking); - case 'START_TRANSIT': - return this.handleStartTransit(booking); - case 'COMPLETE': - return this.handleComplete(booking); - case 'REJECT': - return this.handleReject(booking, actorId, reason); - case 'CANCEL': - return this.handleCancel(booking, reason); - default: - throw new BadRequestException(`Unknown action: ${action}`); - } - } - - /** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */ - private async handleSubmit(booking: Booking): Promise { - this.assertStatus(booking, ['DRAFT']); - - await this.bookingsRepository.update(booking.id, { status: 'RFQ_SUBMITTED' } as never); - await this.ruleEngineService.snapshotLiveRates(booking.id); - await this.ruleEngineService.instantiateApprovalSteps(booking.id, booking.cargoTypeId); - - const updated = await this.bookingsRepository.update(booking.id, { - status: 'PENDING_APPROVAL', - } as never); - return updated!; - } - - private async handleSendQuotation(booking: Booking): Promise { - this.assertStatus(booking, ['RFQ_SUBMITTED']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'QUOTATION_SENT', - } as never); - return updated!; - } - - private async handleApproveQuotation(booking: Booking): Promise { - this.assertStatus(booking, ['QUOTATION_SENT']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'QUOTATION_APPROVED', - } as never); - return updated!; - } - - private async handleRejectQuotation(booking: Booking, reason?: string): Promise { - this.assertStatus(booking, ['QUOTATION_SENT']); - if (!reason) throw new BadRequestException('reason is required for REJECT_QUOTATION'); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'QUOTATION_REJECTED', - } as never); - return updated!; - } - - private async handleApproveStep( - booking: Booking, - actorId?: string, - requiredRole?: string, - ): Promise { - this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']); - if (!actorId || !requiredRole) { - throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP'); + const status = statusMap[queue]; + if (!status) { + throw new BadRequestException(`Unknown queue: ${queue}`); } - const step = await this.bookingsRepository.findPendingApprovalStep( - booking.id, - requiredRole, - ); - if (!step) { - throw new BadRequestException(`No pending approval step for role ${requiredRole}`); - } - - await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); - - const allDone = await this.bookingsRepository.allApprovalStepsComplete(booking.id); - if (allDone) { - const updated = await this.bookingsRepository.update(booking.id, { - status: 'APPROVED', - } as never); - return updated!; - } - - return this.findById(booking.id); - } - - private async handleFullyApproved(booking: Booking): Promise { - this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'APPROVED', - } as never); - return updated!; - } - - private async handleCustomerSign(booking: Booking): Promise { - this.assertStatus(booking, ['APPROVED']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'SIGNED_CUSTOMER', - customerSignedAt: new Date(), - } as never); - return updated!; - } - - private async handleFullyExecuted(booking: Booking): Promise { - this.assertStatus(booking, ['SIGNED_CUSTOMER']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'FULLY_EXECUTED', - fullyExecutedAt: new Date(), - } as never); - return updated!; - } - - private async handleMarkPaid(booking: Booking): Promise { - this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'PAID', - paymentStatus: 'PAID', - } as never); - return updated!; - } - - private async handleStartTransit(booking: Booking): Promise { - this.assertStatus(booking, ['PAID']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'IN_TRANSIT', - } as never); - return updated!; - } - - private async handleComplete(booking: Booking): Promise { - this.assertStatus(booking, ['IN_TRANSIT']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'COMPLETED', - endDate: new Date(), - } as never); - return updated!; - } - - private async handleReject( - booking: Booking, - actorId?: string, - reason?: string, - ): Promise { - this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']); - if (!actorId || !reason) { - throw new BadRequestException('actorId and reason are required for REJECT'); - } - const updated = await this.bookingsRepository.update(booking.id, { - status: 'CANCELLED', - } as never); - return updated!; - } - - private async handleCancel(booking: Booking, reason?: string): Promise { - this.assertStatus(booking, [ - 'DRAFT', - 'RFQ_SUBMITTED', - 'QUOTATION_SENT', - 'QUOTATION_APPROVED', - 'PENDING_APPROVAL', - ]); - if (!reason) throw new BadRequestException('reason is required for CANCEL'); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'CANCELLED', - } as never); - return updated!; - } - - private assertStatus(booking: Booking, allowed: string[]): void { - if (!allowed.includes(booking.status)) { - throw new ConflictException( - `Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`, - ); - } + return this.bookingsRepository.findQueue({ + status, + page: filter.page, + pageSize: filter.pageSize, + excludeBulk: options?.excludeBulk ?? queue === 'approval', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); } async requestConsolidation(id: string): Promise<{ diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts new file mode 100644 index 000000000..3474bec74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PriceLineItemDto { + @ApiProperty() + code!: string; + + @ApiProperty() + description!: string; + + @ApiProperty() + amount!: number; + + @ApiProperty() + currency!: string; +} + +export class GeneratePriceResponseDto { + @ApiProperty() + bookingId!: string; + + @ApiProperty() + totalAmount!: number; + + @ApiProperty() + currency!: string; + + @ApiProperty({ type: [PriceLineItemDto] }) + lineItems!: PriceLineItemDto[]; + + @ApiProperty({ type: [String] }) + warnings!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts new file mode 100644 index 000000000..8e77b51fc --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -0,0 +1,74 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; + +export class RequestChangesDto { + @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) + @IsString() + @MinLength(1) + note!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + actorId?: string; +} + +export class StaffAcceptDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + actorId?: string; +} + +export class MarketingApproveDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + actorId?: string; +} + +export class StaffRejectDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + actorId?: string; +} + +export class ApproveStepDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + actorId!: string; + + @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) + @IsString() + requiredRole!: string; +} + +export class RejectStepDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + actorId!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} + +export class CancelBookingDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} + +export class BankCallbackDto { + @ApiProperty() + @IsString() + pnrCode!: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts deleted file mode 100644 index a2d6c192d..000000000 --- a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; - -const STATUS_ACTIONS = [ - 'SUBMIT', - 'SEND_QUOTATION', - 'APPROVE_QUOTATION', - 'REJECT_QUOTATION', - 'APPROVE_STEP', - 'APPROVE', - 'CUSTOMER_SIGN', - 'MARK_FULLY_EXECUTED', - 'MARK_PAID', - 'START_TRANSIT', - 'COMPLETE', - 'REJECT', - 'CANCEL', -] as const; - -export { STATUS_ACTIONS }; - -export class UpdateStatusDto { - @ApiProperty({ enum: STATUS_ACTIONS }) - @IsIn([...STATUS_ACTIONS]) - action!: string; - - @ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' }) - @IsOptional() - @IsUUID() - actorId?: string; - - @ApiPropertyOptional({ description: 'Required role for APPROVE_STEP (LINE_STAFF, DIRECTOR, CEO)' }) - @IsOptional() - @IsString() - requiredRole?: string; - - @ApiPropertyOptional({ description: 'Required for REJECT, REJECT_QUOTATION, CANCEL' }) - @IsOptional() - @IsString() - reason?: string; -} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts new file mode 100644 index 000000000..af39a469c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; + +export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const; +export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'booking_review_note' }) +@Index(['bookingId']) +export class BookingReviewNote extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.reviewNotes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'author_id', type: 'uuid', nullable: true }) + authorId?: string | null; + + @Column({ name: 'note', type: 'text' }) + note!: string; + + @Column({ name: 'type', type: 'varchar', length: 30 }) + type!: ReviewNoteType; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 7875babbf..6a4140006 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -11,25 +11,47 @@ import { BookingApprovalStep } from './booking-approval-step.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; +import { BookingReviewNote } from './booking-review-note.entity'; export const BOOKING_STATUSES = [ 'DRAFT', - 'RFQ_SUBMITTED', - 'QUOTATION_SENT', - 'QUOTATION_APPROVED', - 'QUOTATION_REJECTED', + 'SUBMITTED', + 'CHANGES_REQUESTED', 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', 'APPROVED', + 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED', + 'PNR_GENERATED', + 'PAYMENT_VERIFICATION_IN_PROGRESS', 'PAID', 'IN_TRANSIT', 'COMPLETED', + 'REJECTED', 'CANCELLED', 'PENDING_CONSOLIDATION', 'CONSOLIDATED', ] as const; +export type BookingStatus = (typeof BOOKING_STATUSES)[number]; + +export const PAYMENT_STATUSES = [ + 'PENDING', + 'PNR_GENERATED', + 'VERIFICATION_IN_PROGRESS', + 'PAID', + 'FAILED', +] as const; + +export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]; + +/** Statuses where the customer may edit booking fields. */ +export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [ + 'DRAFT', + 'CHANGES_REQUESTED', +]; + @Entity({ schema: 'freight', name: 'bookings' }) export class Booking extends BaseEntity { @Column({ name: 'reference', type: 'varchar', length: 64, unique: true }) @@ -169,6 +191,18 @@ export class Booking extends BaseEntity { @Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true }) fullyExecutedAt?: Date | null; + @Column({ name: 'marketing_approved_by_id', type: 'uuid', nullable: true }) + marketingApprovedById?: string | null; + + @Column({ name: 'marketing_approved_at', type: 'timestamptz', nullable: true }) + marketingApprovedAt?: Date | null; + + @Column({ name: 'contract_summary', type: 'text', nullable: true }) + contractSummary?: string | null; + + @Column({ name: 'locked_at', type: 'timestamptz', nullable: true }) + lockedAt?: Date | null; + @Column({ name: 'priority_score', type: 'int', default: 0 }) priorityScore!: number; @@ -194,6 +228,9 @@ export class Booking extends BaseEntity { @OneToMany(() => BookingRateSnapshot, (s) => s.booking) rateSnapshots?: BookingRateSnapshot[]; + @OneToMany(() => BookingReviewNote, (n) => n.booking) + reviewNotes?: BookingReviewNote[]; + @OneToMany(() => FileRecord, (file) => file.resourceId, { createForeignKeyConstraints: false, }) diff --git a/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts b/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts new file mode 100644 index 000000000..d0f60f8df --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts @@ -0,0 +1,17 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { BookingPaymentService } from './booking-payment.service'; +import { BankCallbackDto } from './dto/request-changes.dto'; + +@ApiTags('payments') +@Controller('webhooks/payments') +export class PaymentsWebhookController { + constructor(private readonly paymentService: BookingPaymentService) {} + + @Post('bank') + @ApiOperation({ summary: 'Bank payment callback (stub)' }) + bankCallback(@Body() dto: BankCallbackDto) { + return this.paymentService.handleBankCallback(dto.pnrCode); + } +}