import { PortCode } from '../value-objects/port-code.vo'; /** * CSV Booking Status Enum * * Represents the lifecycle of a CSV-based booking request */ export enum CsvBookingStatus { QUOTE = 'QUOTE', // Devis : reservation creee, frais de booking non regles PENDING_BANK_TRANSFER = 'PENDING_BANK_TRANSFER', // Bank transfer declared, awaiting admin validation PENDING = 'PENDING', // Awaiting carrier response ACCEPTED = 'ACCEPTED', // Carrier accepted the booking REJECTED = 'REJECTED', // Carrier rejected the booking CANCELLED = 'CANCELLED', // User cancelled the booking } /** * Statuts dans lesquels aucun paiement n'a ete encaisse : la reservation peut * alors etre supprimee. Voir `CsvBooking.isDeletable()`. */ export const DELETABLE_STATUSES: readonly CsvBookingStatus[] = [CsvBookingStatus.QUOTE]; /** * 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, // Carrier + route + transit + container — editable before payment when the // user re-runs the search and picks another rate (see editFromRate). public carrierName: string, public carrierEmail: string, public origin: PortCode, public destination: PortCode, // Cargo characteristics — editable before payment (see editDetails). public volumeCBM: number, public weightKG: number, public palletCount: number, // Pricing — recomputed when cargo details change before payment (see editDetails). public priceUSD: number, public priceEUR: number, public primaryCurrency: string, public transitDays: number, public 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, public readonly bookingNumber?: string, public commissionRate?: number, public commissionAmountEur?: number, public stripePaymentIntentId?: string, // Detailed transport cost breakdown (informational — paid to the carrier, // not collected by Xpeditis). Freight and FOB may be in different currencies. public freightTotal?: number, public freightCurrency?: string, public fobTotal?: number, public fobCurrency?: string, // Options & services selected in the search form (customs, insurance, DG, // handling…). Stored as a flat map of enabled flags. Editable before payment. public options: Record = {} ) { 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'); } // Note: at least one document is required *at creation* (enforced in the // create flow). A booking may end up with zero documents after the owner // deletes them, so reconstitution must not fail here. } /** * Apply commission to the booking * * @deprecated Commission has been replaced by a flat per-booking fee. * Use {@link applyBookingFee} instead. */ applyCommission(ratePercent: number, baseAmountEur: number): void { this.commissionRate = ratePercent; this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100; } /** * Apply the flat per-booking service fee (forfait par booking). * * Replaces the percentage-based commission: the amount is the subscription * plan's fixed booking fee, so the percentage rate is cleared. A fee <= 0 * (e.g. Platinium "sur mesure") means no automatic charge. */ applyBookingFee(feeEur: number): void { this.commissionRate = undefined; this.commissionAmountEur = feeEur > 0 ? feeEur : 0; } /** * Mark commission payment as completed → transition to PENDING * * @throws Error if booking is not in QUOTE status */ markPaymentCompleted(): void { if (this.status !== CsvBookingStatus.QUOTE) { throw new Error( `Cannot mark payment completed for booking with status ${this.status}. Only QUOTE bookings can transition.` ); } this.status = CsvBookingStatus.PENDING; } /** * Declare bank transfer → transition to PENDING_BANK_TRANSFER * Called when user confirms they have sent the bank transfer * * @throws Error if booking is not in QUOTE status */ markBankTransferDeclared(): void { if (this.status !== CsvBookingStatus.QUOTE) { throw new Error( `Cannot declare bank transfer for booking with status ${this.status}. Only QUOTE bookings can transition.` ); } this.status = CsvBookingStatus.PENDING_BANK_TRANSFER; } /** * Admin validates bank transfer → transition to PENDING * Called by admin once bank transfer has been received and verified * * @throws Error if booking is not in PENDING_BANK_TRANSFER status */ markBankTransferValidated(): void { if (this.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) { throw new Error( `Cannot validate bank transfer for booking with status ${this.status}. Only PENDING_BANK_TRANSFER bookings can transition.` ); } this.status = CsvBookingStatus.PENDING; } /** * 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; } } /** * Can this booking be deleted outright? * * Une reservation impayee n'engage personne : elle n'est pas partie chez le * transporteur et ne porte aucune trace comptable. La supprimer est donc sans * consequence, la ou une reservation payee doit rester tracable et ne peut * qu'etre annulee. * * `PENDING_BANK_TRANSFER` est volontairement exclu : le virement declare peut * etre en cours d'acheminement, et supprimer la reservation priverait * l'administration de ce qu'elle doit rapprocher a sa reception. Etendre la * regle a ce statut est une decision comptable, pas technique : il suffirait * de l'ajouter a `DELETABLE_STATUSES`. */ isDeletable(): boolean { return DELETABLE_STATUSES.includes(this.status); } /** * 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'); } if (this.status === CsvBookingStatus.CANCELLED) { throw new Error('Booking is already cancelled'); } this.status = CsvBookingStatus.CANCELLED; this.respondedAt = new Date(); } /** * Edit the cargo details of a booking before it is paid. * * Only allowed while the booking is awaiting payment (QUOTE), i.e. * before it is sent to the carrier. Carrier, route and price derive from the * selected rate and are not editable here. * * @throws Error if the booking is not in QUOTE status or values are invalid */ editDetails(details: { volumeCBM?: number; weightKG?: number; palletCount?: number; notes?: string; pricing?: { priceUSD?: number; priceEUR?: number; primaryCurrency?: string; freightTotal?: number; freightCurrency?: string; fobTotal?: number; fobCurrency?: string; }; }): void { if (this.status !== CsvBookingStatus.QUOTE) { throw new Error( `Cannot edit booking with status ${this.status}. Only QUOTE bookings can be edited.` ); } if (details.volumeCBM !== undefined) { if (details.volumeCBM <= 0) { throw new Error('Volume must be positive'); } this.volumeCBM = details.volumeCBM; } if (details.weightKG !== undefined) { if (details.weightKG <= 0) { throw new Error('Weight must be positive'); } this.weightKG = details.weightKG; } if (details.palletCount !== undefined) { if (details.palletCount < 0) { throw new Error('Pallet count cannot be negative'); } this.palletCount = details.palletCount; } if (details.notes !== undefined) { this.notes = details.notes; } // Pricing is recomputed from the current rate when cargo details change. const p = details.pricing; if (p) { if (p.priceUSD !== undefined) { if (p.priceUSD < 0) throw new Error('Price cannot be negative'); this.priceUSD = p.priceUSD; } if (p.priceEUR !== undefined) { if (p.priceEUR < 0) throw new Error('Price cannot be negative'); this.priceEUR = p.priceEUR; } if (p.primaryCurrency !== undefined) this.primaryCurrency = p.primaryCurrency; if (p.freightTotal !== undefined) this.freightTotal = p.freightTotal; if (p.freightCurrency !== undefined) this.freightCurrency = p.freightCurrency; if (p.fobTotal !== undefined) this.fobTotal = p.fobTotal; if (p.fobCurrency !== undefined) this.fobCurrency = p.fobCurrency; } } /** * Re-apply a full rate selection before payment: the user re-ran the search * and picked a rate, so carrier, route, container, transit, cargo and price * are all replaced. Only allowed while the booking is QUOTE. * * @throws Error if the booking is not QUOTE or values are invalid */ editFromRate(data: { carrierName: string; carrierEmail: string; origin: PortCode; destination: PortCode; containerType: string; transitDays: number; volumeCBM: number; weightKG: number; palletCount: number; priceUSD: number; priceEUR: number; primaryCurrency: string; freightTotal?: number; freightCurrency?: string; fobTotal?: number; fobCurrency?: string; notes?: string; options?: Record; }): void { if (this.status !== CsvBookingStatus.QUOTE) { throw new Error( `Cannot edit booking with status ${this.status}. Only QUOTE bookings can be edited.` ); } if (!data.carrierName || data.carrierName.trim().length === 0) { throw new Error('Carrier name is required'); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!data.carrierEmail || !emailRegex.test(data.carrierEmail)) { throw new Error('Invalid carrier email format'); } if (data.volumeCBM <= 0) throw new Error('Volume must be positive'); if (data.weightKG <= 0) throw new Error('Weight must be positive'); if (data.palletCount < 0) throw new Error('Pallet count cannot be negative'); if (data.transitDays <= 0) throw new Error('Transit days must be positive'); if (data.priceUSD < 0 || data.priceEUR < 0) throw new Error('Price cannot be negative'); this.carrierName = data.carrierName; this.carrierEmail = data.carrierEmail; this.origin = data.origin; this.destination = data.destination; this.containerType = data.containerType; this.transitDays = data.transitDays; this.volumeCBM = data.volumeCBM; this.weightKG = data.weightKG; this.palletCount = data.palletCount; this.priceUSD = data.priceUSD; this.priceEUR = data.priceEUR; this.primaryCurrency = data.primaryCurrency; this.freightTotal = data.freightTotal; this.freightCurrency = data.freightCurrency; this.fobTotal = data.fobTotal; this.fobCurrency = data.fobCurrency; if (data.notes !== undefined) this.notes = data.notes; if (data.options !== undefined) this.options = data.options; } /** * Un devis : la reservation est construite mais les frais de booking ne sont * pas regles, donc rien n'est encore parti chez le transporteur. */ isQuote(): boolean { return this.status === CsvBookingStatus.QUOTE; } 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(); } /** * Create a CsvBooking from persisted data (skips document validation) * * Use this when loading from database where bookings might have been created * before document requirement was enforced, or documents were lost. */ static fromPersistence( id: string, userId: string, organizationId: string, carrierName: string, carrierEmail: string, origin: PortCode, destination: PortCode, volumeCBM: number, weightKG: number, palletCount: number, priceUSD: number, priceEUR: number, primaryCurrency: string, transitDays: number, containerType: string, status: CsvBookingStatus, documents: CsvBookingDocument[], confirmationToken: string, requestedAt: Date, respondedAt?: Date, notes?: string, rejectionReason?: string, bookingNumber?: string, commissionRate?: number, commissionAmountEur?: number, stripePaymentIntentId?: string, freightTotal?: number, freightCurrency?: string, fobTotal?: number, fobCurrency?: string, options?: Record ): CsvBooking { // Create instance without calling constructor validation const booking = Object.create(CsvBooking.prototype); // Assign all properties directly booking.id = id; booking.userId = userId; booking.organizationId = organizationId; booking.carrierName = carrierName; booking.carrierEmail = carrierEmail; booking.origin = origin; booking.destination = destination; booking.volumeCBM = volumeCBM; booking.weightKG = weightKG; booking.palletCount = palletCount; booking.priceUSD = priceUSD; booking.priceEUR = priceEUR; booking.primaryCurrency = primaryCurrency; booking.transitDays = transitDays; booking.containerType = containerType; booking.status = status; booking.documents = documents || []; booking.confirmationToken = confirmationToken; booking.requestedAt = requestedAt; booking.respondedAt = respondedAt; booking.notes = notes; booking.rejectionReason = rejectionReason; booking.bookingNumber = bookingNumber; booking.commissionRate = commissionRate; booking.commissionAmountEur = commissionAmountEur; booking.stripePaymentIntentId = stripePaymentIntentId; booking.freightTotal = freightTotal; booking.freightCurrency = freightCurrency; booking.fobTotal = fobTotal; booking.fobCurrency = fobCurrency; booking.options = options ?? {}; return booking; } }