xpeditis2.0/apps/backend/src/domain/entities/csv-booking.entity.ts
David c5f823f8b7 feat(bookings): persist Options & Services (customs/insurance/DG/handling)
Les options du formulaire de recherche etaient collectees mais jamais stockees.
Ajout d'une colonne jsonb options sur csv_bookings (+ migration), stockee a la
creation et a l'edition (editFromRate), renvoyee dans la reponse, et re-pre-
remplie a la reprise (search-advanced <- URL <- booking.options).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:12:18 +02:00

650 lines
20 KiB
TypeScript

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_PAYMENT = 'PENDING_PAYMENT', // Awaiting commission payment
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
}
/**
* 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<string, boolean> = {}
) {
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 PENDING_PAYMENT status
*/
markPaymentCompleted(): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot mark payment completed for booking with status ${this.status}. Only PENDING_PAYMENT 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 PENDING_PAYMENT status
*/
markBankTransferDeclared(): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot declare bank transfer for booking with status ${this.status}. Only PENDING_PAYMENT 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;
}
}
/**
* 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 (PENDING_PAYMENT), 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 PENDING_PAYMENT 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.PENDING_PAYMENT) {
throw new Error(
`Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT 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 PENDING_PAYMENT.
*
* @throws Error if the booking is not PENDING_PAYMENT 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<string, boolean>;
}): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT 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;
}
/**
* Check if booking has expired (7 days without response)
*
* @returns true if booking is older than 7 days and still pending
*/
isPendingPayment(): boolean {
return this.status === CsvBookingStatus.PENDING_PAYMENT;
}
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<string, boolean>
): 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;
}
}