import { PortCode } from '../value-objects/port-code.vo'; /** * CSV Booking Status Enum * * Represents the lifecycle of a CSV-based booking request */ export enum CsvBookingStatus { PENDING = 'PENDING', // Awaiting carrier response ACCEPTED = 'ACCEPTED', // Carrier accepted the booking REJECTED = 'REJECTED', // Carrier rejected the booking CANCELLED = 'CANCELLED', // User cancelled the booking } /** * Document Interface * * Represents a document attached to a booking */ export interface CsvBookingDocument { id: string; type: DocumentType; fileName: string; filePath: string; mimeType: string; size: number; uploadedAt: Date; } /** * Document Type Enum * * Types of documents that can be attached to a booking */ export enum DocumentType { BILL_OF_LADING = 'BILL_OF_LADING', PACKING_LIST = 'PACKING_LIST', COMMERCIAL_INVOICE = 'COMMERCIAL_INVOICE', CERTIFICATE_OF_ORIGIN = 'CERTIFICATE_OF_ORIGIN', OTHER = 'OTHER', } /** * CSV Booking Entity * * Domain entity representing a shipping booking request from CSV rate search. * This is a simplified booking workflow for CSV-based rates where the user * selects a rate and sends a booking request to the carrier with documents. * * Business Rules: * - Booking can only be accepted/rejected when status is PENDING * - Once accepted/rejected, status cannot be changed * - Booking expires after 7 days if not responded to * - At least one document is required for booking creation * - Confirmation token is used for email accept/reject links * - Only carrier can accept/reject via email link * - User can cancel pending bookings */ export class CsvBooking { constructor( public readonly id: string, public readonly userId: string, public readonly organizationId: string, public readonly carrierName: string, public readonly carrierEmail: string, public readonly origin: PortCode, public readonly destination: PortCode, public readonly volumeCBM: number, public readonly weightKG: number, public readonly palletCount: number, public readonly priceUSD: number, public readonly priceEUR: number, public readonly primaryCurrency: string, public readonly transitDays: number, public readonly containerType: string, public status: CsvBookingStatus, public readonly documents: CsvBookingDocument[], public readonly confirmationToken: string, public readonly requestedAt: Date, public respondedAt?: Date, public notes?: string, public rejectionReason?: string ) { this.validate(); } /** * Validate booking data */ private validate(): void { if (!this.id || this.id.trim().length === 0) { throw new Error('Booking ID is required'); } if (!this.userId || this.userId.trim().length === 0) { throw new Error('User ID is required'); } if (!this.organizationId || this.organizationId.trim().length === 0) { throw new Error('Organization ID is required'); } if (!this.carrierName || this.carrierName.trim().length === 0) { throw new Error('Carrier name is required'); } if (!this.carrierEmail || this.carrierEmail.trim().length === 0) { throw new Error('Carrier email is required'); } // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(this.carrierEmail)) { throw new Error('Invalid carrier email format'); } if (this.volumeCBM <= 0) { throw new Error('Volume must be positive'); } if (this.weightKG <= 0) { throw new Error('Weight must be positive'); } if (this.palletCount < 0) { throw new Error('Pallet count cannot be negative'); } if (this.priceUSD < 0 || this.priceEUR < 0) { throw new Error('Price cannot be negative'); } if (this.transitDays <= 0) { throw new Error('Transit days must be positive'); } if (!this.confirmationToken || this.confirmationToken.trim().length === 0) { throw new Error('Confirmation token is required'); } } /** * Accept the booking * * @throws Error if booking is not in PENDING status */ accept(): void { if (this.status !== CsvBookingStatus.PENDING) { throw new Error( `Cannot accept booking with status ${this.status}. Only PENDING bookings can be accepted.` ); } if (this.isExpired()) { throw new Error('Cannot accept expired booking'); } this.status = CsvBookingStatus.ACCEPTED; this.respondedAt = new Date(); } /** * Reject the booking * * @param reason Optional reason for rejection * @throws Error if booking is not in PENDING status */ reject(reason?: string): void { if (this.status !== CsvBookingStatus.PENDING) { throw new Error( `Cannot reject booking with status ${this.status}. Only PENDING bookings can be rejected.` ); } if (this.isExpired()) { throw new Error('Cannot reject expired booking (already expired)'); } this.status = CsvBookingStatus.REJECTED; this.respondedAt = new Date(); if (reason) { this.rejectionReason = reason; } } /** * Cancel the booking (by user) * * @throws Error if booking is already accepted/rejected */ cancel(): void { if (this.status === CsvBookingStatus.ACCEPTED) { throw new Error('Cannot cancel accepted booking. Contact carrier to cancel.'); } if (this.status === CsvBookingStatus.REJECTED) { throw new Error('Cannot cancel rejected booking'); } this.status = CsvBookingStatus.CANCELLED; this.respondedAt = new Date(); } /** * Check if booking has expired (7 days without response) * * @returns true if booking is older than 7 days and still pending */ isExpired(): boolean { if (this.status !== CsvBookingStatus.PENDING) { return false; } const expirationDate = new Date(this.requestedAt); expirationDate.setDate(expirationDate.getDate() + 7); return new Date() > expirationDate; } /** * Check if booking is still pending (awaiting response) */ isPending(): boolean { return this.status === CsvBookingStatus.PENDING && !this.isExpired(); } /** * Check if booking was accepted */ isAccepted(): boolean { return this.status === CsvBookingStatus.ACCEPTED; } /** * Check if booking was rejected */ isRejected(): boolean { return this.status === CsvBookingStatus.REJECTED; } /** * Check if booking was cancelled */ isCancelled(): boolean { return this.status === CsvBookingStatus.CANCELLED; } /** * Get route description (origin → destination) */ getRouteDescription(): string { return `${this.origin.getValue()} → ${this.destination.getValue()}`; } /** * Get booking summary */ getSummary(): string { return `CSV Booking ${this.id}: ${this.carrierName} - ${this.getRouteDescription()} (${this.status})`; } /** * Get price in specified currency */ getPriceInCurrency(currency: 'USD' | 'EUR'): number { return currency === 'USD' ? this.priceUSD : this.priceEUR; } /** * Get days until expiration (negative if expired) */ getDaysUntilExpiration(): number { if (this.status !== CsvBookingStatus.PENDING) { return 0; } const expirationDate = new Date(this.requestedAt); expirationDate.setDate(expirationDate.getDate() + 7); const now = new Date(); const diffTime = expirationDate.getTime() - now.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return diffDays; } /** * Check if booking has a specific document type */ hasDocumentType(type: DocumentType): boolean { return this.documents.some(doc => doc.type === type); } /** * Get documents by type */ getDocumentsByType(type: DocumentType): CsvBookingDocument[] { return this.documents.filter(doc => doc.type === type); } /** * Check if all required documents are present */ hasAllRequiredDocuments(): boolean { const requiredTypes = [ DocumentType.BILL_OF_LADING, DocumentType.PACKING_LIST, DocumentType.COMMERCIAL_INVOICE, ]; return requiredTypes.every(type => this.hasDocumentType(type)); } /** * Get response time in hours (if responded) */ getResponseTimeHours(): number | null { if (!this.respondedAt) { return null; } const diffTime = this.respondedAt.getTime() - this.requestedAt.getTime(); const diffHours = diffTime / (1000 * 60 * 60); return Math.round(diffHours * 100) / 100; // Round to 2 decimals } toString(): string { return this.getSummary(); } }