xpeditis2.0/apps/backend/src/application/services/csv-booking.service.ts
David ec0173483a
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m3s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
fix language
2026-04-21 18:04:02 +02:00

1370 lines
46 KiB
TypeScript

import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
Inject,
UnauthorizedException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2';
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
import { PortCode } from '@domain/value-objects/port-code.vo';
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import {
NotificationRepository,
NOTIFICATION_REPOSITORY,
} from '@domain/ports/out/notification.repository';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
import {
Notification,
NotificationType,
NotificationPriority,
} from '@domain/entities/notification.entity';
import {
CreateCsvBookingDto,
CsvBookingResponseDto,
CsvBookingDocumentDto,
CsvBookingListResponseDto,
CsvBookingStatsDto,
} from '../dto/csv-booking.dto';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
import { SubscriptionService } from './subscription.service';
/**
* CSV Booking Document (simple class for domain)
*/
class CsvBookingDocumentImpl {
constructor(
public readonly id: string,
public readonly type: DocumentType,
public readonly fileName: string,
public readonly filePath: string,
public readonly mimeType: string,
public readonly size: number,
public readonly uploadedAt: Date
) {}
}
/**
* CSV Booking Service
*
* Handles business logic for CSV-based booking requests
*/
@Injectable()
export class CsvBookingService {
private readonly logger = new Logger(CsvBookingService.name);
constructor(
private readonly csvBookingRepository: TypeOrmCsvBookingRepository,
@Inject(NOTIFICATION_REPOSITORY)
private readonly notificationRepository: NotificationRepository,
@Inject(EMAIL_PORT)
private readonly emailAdapter: EmailPort,
@Inject(STORAGE_PORT)
private readonly storageAdapter: StoragePort,
@Inject(STRIPE_PORT)
private readonly stripeAdapter: StripePort,
private readonly subscriptionService: SubscriptionService,
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository
) {}
/**
* Generate a unique booking number
* Format: XPD-YYYY-XXXXXX (e.g., XPD-2025-A3B7K9)
*/
private generateBookingNumber(): string {
const year = new Date().getFullYear();
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity
let code = '';
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return `XPD-${year}-${code}`;
}
/**
* Extract the password from booking number (last 6 characters)
*/
private extractPasswordFromBookingNumber(bookingNumber: string): string {
return bookingNumber.split('-').pop() || bookingNumber.slice(-6);
}
/**
* Create a new CSV booking request
*/
async createBooking(
dto: CreateCsvBookingDto,
files: Express.Multer.File[],
userId: string,
organizationId: string
): Promise<CsvBookingResponseDto> {
this.logger.log(`Creating CSV booking for user ${userId}`);
// Validate minimum document requirement
if (!files || files.length === 0) {
throw new BadRequestException('At least one document is required');
}
// Generate unique confirmation token and booking number
const confirmationToken = uuidv4();
const bookingId = uuidv4();
const bookingNumber = this.generateBookingNumber();
const documentPassword = this.extractPasswordFromBookingNumber(bookingNumber);
// Hash the password for storage
const passwordHash = await argon2.hash(documentPassword);
// Upload documents to S3
const documents = await this.uploadDocuments(files, bookingId);
// Calculate commission based on organization's subscription plan
let commissionRate = 5; // default Bronze
let commissionAmountEur = 0;
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
commissionRate = subscription.plan.commissionRatePercent;
} catch (error: any) {
this.logger.error(`Failed to get subscription for commission: ${error?.message}`);
}
commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100;
// Create domain entity in PENDING_PAYMENT status (no email sent yet)
const booking = new CsvBooking(
bookingId,
userId,
organizationId,
dto.carrierName,
dto.carrierEmail,
PortCode.create(dto.origin),
PortCode.create(dto.destination),
dto.volumeCBM,
dto.weightKG,
dto.palletCount,
dto.priceUSD,
dto.priceEUR,
dto.primaryCurrency,
dto.transitDays,
dto.containerType,
CsvBookingStatus.PENDING_PAYMENT,
documents,
confirmationToken,
new Date(),
undefined,
dto.notes,
undefined,
bookingNumber,
commissionRate,
commissionAmountEur
);
// Save to database
const savedBooking = await this.csvBookingRepository.create(booking);
// Update ORM entity with booking number and password hash
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
if (ormBooking) {
ormBooking.bookingNumber = bookingNumber;
ormBooking.passwordHash = passwordHash;
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(
`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}`
);
// NO email sent to carrier yet - will be sent after commission payment
// NO notification yet - will be created after payment confirmation
return this.toResponseDto(savedBooking);
}
/**
* Create a Stripe Checkout session for commission payment
*/
async createCommissionPayment(
bookingId: string,
userId: string,
userEmail: string,
frontendUrl: string
): Promise<{ sessionUrl: string; sessionId: string; commissionAmountEur: number }> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new BadRequestException(
`Booking is not awaiting payment. Current status: ${booking.status}`
);
}
const commissionAmountEur = booking.commissionAmountEur || 0;
if (commissionAmountEur <= 0) {
throw new BadRequestException('Commission amount is invalid');
}
const amountCents = Math.round(commissionAmountEur * 100);
const result = await this.stripeAdapter.createCommissionCheckout({
bookingId: booking.id,
amountCents,
currency: 'eur',
customerEmail: userEmail,
organizationId: booking.organizationId,
bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()}${booking.destination.getValue()}`,
successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`,
});
this.logger.log(
`Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR`
);
return {
sessionUrl: result.sessionUrl,
sessionId: result.sessionId,
commissionAmountEur,
};
}
/**
* Confirm commission payment and activate booking
* Called after Stripe redirect with session_id
*/
async confirmCommissionPayment(
bookingId: string,
sessionId: string,
userId: string
): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
// Already confirmed - return current state
if (booking.status === CsvBookingStatus.PENDING) {
return this.toResponseDto(booking);
}
throw new BadRequestException(
`Booking is not awaiting payment. Current status: ${booking.status}`
);
}
// Verify payment with Stripe
const session = await this.stripeAdapter.getCheckoutSession(sessionId);
if (!session || session.status !== 'complete') {
throw new BadRequestException('Payment has not been completed');
}
// Verify the session is for this booking
if (session.metadata?.bookingId !== bookingId) {
throw new BadRequestException('Payment session does not match this booking');
}
// Transition to PENDING
booking.markPaymentCompleted();
booking.stripePaymentIntentId = sessionId;
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${bookingId} payment confirmed, status now PENDING`);
// Get ORM entity for booking number
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
// NOW send email to carrier
try {
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId: booking.id,
bookingNumber: bookingNumber || '',
documentPassword: documentPassword || '',
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
priceUSD: booking.priceUSD,
priceEUR: booking.priceEUR,
primaryCurrency: booking.primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
documents: booking.documents.map(doc => ({
type: doc.type,
fileName: doc.fileName,
})),
confirmationToken: booking.confirmationToken,
notes: booking.notes,
});
this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
}
// Create notification for user
try {
const notification = Notification.create({
id: uuidv4(),
userId: booking.userId,
organizationId: booking.organizationId,
type: NotificationType.CSV_BOOKING_REQUEST_SENT,
priority: NotificationPriority.MEDIUM,
title: 'Booking Request Sent',
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`,
metadata: { bookingId: booking.id, carrierName: booking.carrierName },
});
await this.notificationRepository.save(notification);
} catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
}
return this.toResponseDto(updatedBooking);
}
/**
* Declare bank transfer — user confirms they have sent the wire transfer
* Transitions booking from PENDING_PAYMENT → PENDING_BANK_TRANSFER
* Sends an email notification to all ADMIN users
*/
async declareBankTransfer(bookingId: string, userId: string): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new BadRequestException(
`Booking is not awaiting payment. Current status: ${booking.status}`
);
}
// Get booking number before update
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
const bookingNumber = ormBooking?.bookingNumber || bookingId.slice(0, 8).toUpperCase();
booking.markBankTransferDeclared();
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(
`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`
);
// Send email to all ADMIN users
try {
const allUsers = await this.userRepository.findAll();
const adminEmails = allUsers.filter(u => u.role === 'ADMIN' && u.isActive).map(u => u.email);
if (adminEmails.length > 0) {
const commissionAmount = booking.commissionAmountEur
? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
booking.commissionAmountEur
)
: 'N/A';
await this.emailAdapter.send({
to: adminEmails,
subject: `[XPEDITIS] Virement à valider — ${bookingNumber}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #10183A;">Nouveau virement à valider</h2>
<p>Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Numéro de booking</td>
<td style="padding: 8px 12px;">${bookingNumber}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Transporteur</td>
<td style="padding: 8px 12px;">${booking.carrierName}</td>
</tr>
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Trajet</td>
<td style="padding: 8px 12px;">${booking.getRouteDescription()}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Montant commission</td>
<td style="padding: 8px 12px; color: #10183A; font-weight: bold;">${commissionAmount}</td>
</tr>
</table>
<p>Rendez-vous dans la <strong>console d'administration</strong> pour valider ce virement et activer le booking.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}/dashboard/admin/bookings"
style="display: inline-block; background: #10183A; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 8px;">
Voir les bookings en attente
</a>
</div>
`,
});
this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`);
}
} catch (error: any) {
this.logger.error(`Failed to send admin notification email: ${error?.message}`, error?.stack);
}
// In-app notification for the user
try {
const notification = Notification.create({
id: uuidv4(),
userId: booking.userId,
organizationId: booking.organizationId,
type: NotificationType.BOOKING_UPDATED,
priority: NotificationPriority.MEDIUM,
title: 'Virement déclaré',
message: `Votre virement pour le booking ${bookingNumber} a été enregistré. Un administrateur va vérifier la réception et activer votre booking.`,
metadata: { bookingId: booking.id },
});
await this.notificationRepository.save(notification);
} catch (error: any) {
this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack);
}
return this.toResponseDto(updatedBooking);
}
/**
* Resend carrier email for a booking (admin action)
* Works regardless of payment status — useful for retrying failed emails or testing without Stripe.
*/
async resendCarrierEmail(bookingId: string): Promise<void> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId: booking.id,
bookingNumber: bookingNumber || '',
documentPassword: documentPassword || '',
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
priceUSD: booking.priceUSD,
priceEUR: booking.priceEUR,
primaryCurrency: booking.primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
documents: booking.documents.map(doc => ({
type: doc.type,
fileName: doc.fileName,
})),
confirmationToken: booking.confirmationToken,
notes: booking.notes,
});
this.logger.log(
`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`
);
}
/**
* Admin validates bank transfer — confirms receipt and activates booking
* Transitions booking from PENDING_BANK_TRANSFER → PENDING then sends email to carrier
*/
async validateBankTransfer(bookingId: string): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) {
throw new BadRequestException(
`Booking is not awaiting bank transfer validation. Current status: ${booking.status}`
);
}
booking.markBankTransferValidated();
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${bookingId} bank transfer validated by admin, status now PENDING`);
// Get booking number for email
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
// Send email to carrier
try {
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId: booking.id,
bookingNumber: bookingNumber || '',
documentPassword: documentPassword || '',
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
priceUSD: booking.priceUSD,
priceEUR: booking.priceEUR,
primaryCurrency: booking.primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
documents: booking.documents.map(doc => ({
type: doc.type,
fileName: doc.fileName,
})),
confirmationToken: booking.confirmationToken,
notes: booking.notes,
});
this.logger.log(
`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`
);
} catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
}
// In-app notification for the user
try {
const notification = Notification.create({
id: uuidv4(),
userId: booking.userId,
organizationId: booking.organizationId,
type: NotificationType.BOOKING_CONFIRMED,
priority: NotificationPriority.HIGH,
title: 'Virement validé — Booking activé',
message: `Votre virement pour le booking ${bookingNumber || booking.id.slice(0, 8)} a été confirmé. Votre demande auprès de ${booking.carrierName} a été transmise au transporteur.`,
metadata: { bookingId: booking.id },
});
await this.notificationRepository.save(notification);
} catch (error: any) {
this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack);
}
return this.toResponseDto(updatedBooking);
}
/**
* Get booking by ID
* Accessible by: booking owner OR assigned carrier
*/
async getBookingById(
id: string,
userId: string,
carrierId?: string
): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findById(id);
if (!booking) {
throw new NotFoundException(`Booking with ID ${id} not found`);
}
// Get ORM booking to access carrierId
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id },
});
// Verify user owns this booking OR is the assigned carrier
const isOwner = booking.userId === userId;
const isAssignedCarrier = carrierId && ormBooking?.carrierId === carrierId;
if (!isOwner && !isAssignedCarrier) {
throw new NotFoundException(`Booking with ID ${id} not found`);
}
return this.toResponseDto(booking);
}
/**
* Get booking by confirmation token (public endpoint)
*/
async getBookingByToken(token: string): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) {
throw new NotFoundException(`Booking with token ${token} not found`);
}
return this.toResponseDto(booking);
}
/**
* Verify password and get booking documents for carrier (public endpoint)
* Only accessible for ACCEPTED bookings with correct password
*/
async getDocumentsForCarrier(
token: string,
password?: string
): Promise<CarrierDocumentsResponseDto> {
this.logger.log(`Getting documents for carrier with token: ${token}`);
// Get ORM entity to access passwordHash
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
if (!ormBooking) {
throw new NotFoundException('Réservation introuvable');
}
// Only allow access for ACCEPTED bookings
if (ormBooking.status !== 'ACCEPTED') {
throw new BadRequestException("Cette réservation n'a pas encore été acceptée");
}
// Check if password protection is enabled for this booking
if (ormBooking.passwordHash) {
if (!password) {
throw new UnauthorizedException('Mot de passe requis pour accéder aux documents');
}
const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password);
if (!isPasswordValid) {
throw new UnauthorizedException('Mot de passe incorrect');
}
}
// Get domain booking for business logic
const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) {
throw new NotFoundException('Réservation introuvable');
}
// Generate signed URLs for all documents
const documentsWithUrls = await Promise.all(
booking.documents.map(async doc => {
const signedUrl = await this.generateSignedUrlForDocument(doc.filePath);
return {
id: doc.id,
type: doc.type,
fileName: doc.fileName,
mimeType: doc.mimeType,
size: doc.size,
downloadUrl: signedUrl,
};
})
);
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
return {
booking: {
id: booking.id,
bookingNumber: ormBooking.bookingNumber || undefined,
carrierName: booking.carrierName,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
routeDescription: booking.getRouteDescription(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
price: booking.getPriceInCurrency(primaryCurrency),
currency: primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
acceptedAt: booking.respondedAt!,
},
documents: documentsWithUrls,
};
}
/**
* Check if a booking requires password for document access
*/
async checkDocumentAccessRequirements(
token: string
): Promise<{ requiresPassword: boolean; bookingNumber?: string; status: string }> {
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
if (!ormBooking) {
throw new NotFoundException('Réservation introuvable');
}
return {
requiresPassword: !!ormBooking.passwordHash,
bookingNumber: ormBooking.bookingNumber || undefined,
status: ormBooking.status,
};
}
/**
* Generate signed URL for a document file path
*/
private async generateSignedUrlForDocument(filePath: string): Promise<string> {
const bucket = 'xpeditis-documents';
// Extract key from the file path
let key = filePath;
if (filePath.includes('xpeditis-documents/')) {
key = filePath.split('xpeditis-documents/')[1];
} else if (filePath.startsWith('http')) {
const url = new URL(filePath);
key = url.pathname.replace(/^\//, '');
if (key.startsWith('xpeditis-documents/')) {
key = key.replace('xpeditis-documents/', '');
}
}
// Generate signed URL with 1 hour expiration
const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600);
return signedUrl;
}
/**
* Accept a booking request
*/
async acceptBooking(token: string): Promise<CsvBookingResponseDto> {
this.logger.log(`Accepting booking with token: ${token}`);
const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) {
throw new NotFoundException('Booking not found');
}
// Get ORM entity for bookingNumber
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
// Accept the booking (domain logic validates status)
booking.accept();
// Apply commission based on organization's subscription plan
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(
booking.organizationId
);
const commissionRate = subscription.plan.commissionRatePercent;
const baseAmountEur = booking.priceEUR;
booking.applyCommission(commissionRate, baseAmountEur);
this.logger.log(
`Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}`
);
} catch (error: any) {
this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack);
}
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${booking.id} accepted`);
// Extract password from booking number for the email
const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
// Send document access email to carrier
try {
await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, {
carrierName: booking.carrierName,
bookingId: booking.id,
bookingNumber: bookingNumber || undefined,
documentPassword: documentPassword,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
documentCount: booking.documents.length,
confirmationToken: booking.confirmationToken,
});
this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send document access email: ${error?.message}`, error?.stack);
}
// Create notification for user
try {
const notification = Notification.create({
id: uuidv4(),
userId: booking.userId,
organizationId: booking.organizationId,
type: NotificationType.CSV_BOOKING_ACCEPTED,
priority: NotificationPriority.HIGH,
title: 'Booking Request Accepted',
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been accepted!`,
metadata: { bookingId: booking.id, carrierName: booking.carrierName },
});
await this.notificationRepository.save(notification);
} catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
}
return this.toResponseDto(updatedBooking);
}
/**
* Reject a booking request
*/
async rejectBooking(token: string, reason?: string): Promise<CsvBookingResponseDto> {
this.logger.log(`Rejecting booking with token: ${token}`);
const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) {
throw new NotFoundException('Booking not found');
}
// Reject the booking (domain logic validates status)
booking.reject(reason);
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${booking.id} rejected`);
// Create notification for user
try {
const notification = Notification.create({
id: uuidv4(),
userId: booking.userId,
organizationId: booking.organizationId,
type: NotificationType.CSV_BOOKING_REJECTED,
priority: NotificationPriority.HIGH,
title: 'Booking Request Rejected',
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} was rejected. ${reason ? `Reason: ${reason}` : ''}`,
metadata: {
bookingId: booking.id,
carrierName: booking.carrierName,
rejectionReason: reason,
},
});
await this.notificationRepository.save(notification);
} catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
}
return this.toResponseDto(updatedBooking);
}
/**
* Cancel a booking (user action)
*/
async cancelBooking(id: string, userId: string): Promise<CsvBookingResponseDto> {
this.logger.log(`Cancelling booking ${id} by user ${userId}`);
const booking = await this.csvBookingRepository.findById(id);
if (!booking) {
throw new NotFoundException('Booking not found');
}
// Verify user owns this booking
if (booking.userId !== userId) {
throw new NotFoundException('Booking not found');
}
// Cancel the booking (domain logic validates status)
booking.cancel();
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${id} cancelled`);
return this.toResponseDto(updatedBooking);
}
/**
* Get bookings for a user (paginated)
*/
async getUserBookings(
userId: string,
page: number = 1,
limit: number = 10
): Promise<CsvBookingListResponseDto> {
const bookings = await this.csvBookingRepository.findByUserId(userId);
// Simple pagination (in-memory)
const start = (page - 1) * limit;
const end = start + limit;
const paginatedBookings = bookings.slice(start, end);
return {
bookings: paginatedBookings.map(b => this.toResponseDto(b)),
total: bookings.length,
page,
limit,
totalPages: Math.ceil(bookings.length / limit),
};
}
/**
* Get bookings for an organization (paginated)
*/
async getOrganizationBookings(
organizationId: string,
page: number = 1,
limit: number = 10
): Promise<CsvBookingListResponseDto> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
// Simple pagination (in-memory)
const start = (page - 1) * limit;
const end = start + limit;
const paginatedBookings = bookings.slice(start, end);
return {
bookings: paginatedBookings.map(b => this.toResponseDto(b)),
total: bookings.length,
page,
limit,
totalPages: Math.ceil(bookings.length / limit),
};
}
/**
* Get booking statistics for user
*/
async getUserStats(userId: string): Promise<CsvBookingStatsDto> {
const stats = await this.csvBookingRepository.countByStatusForUser(userId);
return {
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0,
cancelled: stats[CsvBookingStatus.CANCELLED] || 0,
total: Object.values(stats).reduce((sum, count) => sum + count, 0),
};
}
/**
* Get booking statistics for organization
*/
async getOrganizationStats(organizationId: string): Promise<CsvBookingStatsDto> {
const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId);
return {
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0,
cancelled: stats[CsvBookingStatus.CANCELLED] || 0,
total: Object.values(stats).reduce((sum, count) => sum + count, 0),
};
}
/**
* Upload documents to S3 and create document entities
*/
private async uploadDocuments(
files: Express.Multer.File[],
bookingId: string
): Promise<CsvBookingDocumentImpl[]> {
const bucket = 'xpeditis-documents'; // You can make this configurable
const documents: CsvBookingDocumentImpl[] = [];
for (const file of files) {
const documentId = uuidv4();
const fileKey = `csv-bookings/${bookingId}/${documentId}-${file.originalname}`;
// Upload to S3
const uploadResult = await this.storageAdapter.upload({
bucket,
key: fileKey,
body: file.buffer,
contentType: file.mimetype,
});
// Determine document type from filename or default to OTHER
const documentType = this.inferDocumentType(file.originalname);
const document = new CsvBookingDocumentImpl(
documentId,
documentType,
file.originalname,
uploadResult.url,
file.mimetype,
file.size,
new Date()
);
documents.push(document);
}
this.logger.log(`Uploaded ${documents.length} documents for booking ${bookingId}`);
return documents;
}
/**
* Link a booking to a carrier profile
*/
async linkBookingToCarrier(bookingId: string, carrierId: string): Promise<void> {
this.logger.log(`Linking booking ${bookingId} to carrier ${carrierId}`);
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking not found: ${bookingId}`);
}
// Update the booking with carrier ID (using the ORM repository directly)
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
if (ormBooking) {
ormBooking.carrierId = carrierId;
await this.csvBookingRepository['repository'].save(ormBooking);
this.logger.log(`Successfully linked booking ${bookingId} to carrier ${carrierId}`);
}
}
/**
* Add documents to an existing booking
*/
async addDocuments(
bookingId: string,
files: Express.Multer.File[],
userId: string
): Promise<{ success: boolean; message: string; documentsAdded: number }> {
this.logger.log(`Adding ${files.length} documents to booking ${bookingId}`);
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Verify user owns this booking
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings
if (
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
booking.status !== CsvBookingStatus.PENDING &&
booking.status !== CsvBookingStatus.ACCEPTED
) {
throw new BadRequestException(
'Cannot add documents to a booking that is rejected or cancelled'
);
}
// Upload new documents
const newDocuments = await this.uploadDocuments(files, bookingId);
// Add documents to booking
const updatedDocuments = [...booking.documents, ...newDocuments];
// Update booking in database using ORM repository directly
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
if (ormBooking) {
ormBooking.documents = updatedDocuments.map(doc => ({
id: doc.id,
type: doc.type,
fileName: doc.fileName,
filePath: doc.filePath,
mimeType: doc.mimeType,
size: doc.size,
uploadedAt: doc.uploadedAt,
}));
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`);
// If booking is ACCEPTED, notify carrier about new documents
if (booking.status === CsvBookingStatus.ACCEPTED) {
try {
await this.emailAdapter.sendNewDocumentsNotification(booking.carrierEmail, {
carrierName: booking.carrierName,
bookingId: booking.id,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
newDocumentsCount: newDocuments.length,
totalDocumentsCount: updatedDocuments.length,
confirmationToken: booking.confirmationToken,
});
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(
`Failed to send new documents notification: ${error?.message}`,
error?.stack
);
}
}
return {
success: true,
message: 'Documents added successfully',
documentsAdded: newDocuments.length,
};
}
/**
* Delete a document from a booking
*/
async deleteDocument(
bookingId: string,
documentId: string,
userId: string
): Promise<{ success: boolean; message: string }> {
this.logger.log(`Deleting document ${documentId} from booking ${bookingId}`);
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Verify user owns this booking
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Verify booking is still pending or awaiting payment
if (
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
booking.status !== CsvBookingStatus.PENDING
) {
throw new BadRequestException('Cannot delete documents from a booking that is not pending');
}
// Find the document
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
if (documentIndex === -1) {
throw new NotFoundException(`Document with ID ${documentId} not found`);
}
// Ensure at least one document remains
if (booking.documents.length <= 1) {
throw new BadRequestException(
'Cannot delete the last document. At least one document is required.'
);
}
// Get the document to delete (for potential S3 cleanup - currently kept for audit)
const _documentToDelete = booking.documents[documentIndex];
// Remove document from array
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
// Update booking in database using ORM repository directly
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
if (ormBooking) {
ormBooking.documents = updatedDocuments.map(doc => ({
id: doc.id,
type: doc.type,
fileName: doc.fileName,
filePath: doc.filePath,
mimeType: doc.mimeType,
size: doc.size,
uploadedAt: doc.uploadedAt,
}));
await this.csvBookingRepository['repository'].save(ormBooking);
}
// Optionally delete from S3 (commented out for safety - keep files for audit)
// try {
// await this.storageAdapter.delete({
// bucket: 'xpeditis-documents',
// key: documentToDelete.filePath,
// });
// } catch (error) {
// this.logger.warn(`Failed to delete file from S3: ${documentToDelete.filePath}`);
// }
this.logger.log(`Deleted document ${documentId} from booking ${bookingId}`);
return {
success: true,
message: 'Document deleted successfully',
};
}
/**
* Replace a document in an existing booking
*/
async replaceDocument(
bookingId: string,
documentId: string,
file: Express.Multer.File,
userId: string
): Promise<{ success: boolean; message: string; newDocument: any }> {
this.logger.log(`Replacing document ${documentId} in booking ${bookingId}`);
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Verify user owns this booking
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Find the document to replace
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
if (documentIndex === -1) {
throw new NotFoundException(`Document with ID ${documentId} not found`);
}
// Upload the new document
const newDocuments = await this.uploadDocuments([file], bookingId);
const newDocument = newDocuments[0];
// Replace the document in the array
const updatedDocuments = [...booking.documents];
updatedDocuments[documentIndex] = newDocument;
// Update booking in database using ORM repository directly
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
if (ormBooking) {
ormBooking.documents = updatedDocuments.map(doc => ({
id: doc.id,
type: doc.type,
fileName: doc.fileName,
filePath: doc.filePath,
mimeType: doc.mimeType,
size: doc.size,
uploadedAt: doc.uploadedAt,
}));
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(
`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`
);
return {
success: true,
message: 'Document replaced successfully',
newDocument: {
id: newDocument.id,
type: newDocument.type,
fileName: newDocument.fileName,
filePath: newDocument.filePath,
mimeType: newDocument.mimeType,
size: newDocument.size,
uploadedAt: newDocument.uploadedAt,
},
};
}
/**
* Infer document type from filename
*/
private inferDocumentType(filename: string): DocumentType {
const lowerFilename = filename.toLowerCase();
if (
lowerFilename.includes('bill') ||
lowerFilename.includes('bol') ||
lowerFilename.includes('lading')
) {
return DocumentType.BILL_OF_LADING;
}
if (lowerFilename.includes('packing') || lowerFilename.includes('list')) {
return DocumentType.PACKING_LIST;
}
if (lowerFilename.includes('invoice') || lowerFilename.includes('commercial')) {
return DocumentType.COMMERCIAL_INVOICE;
}
if (lowerFilename.includes('certificate') || lowerFilename.includes('origin')) {
return DocumentType.CERTIFICATE_OF_ORIGIN;
}
return DocumentType.OTHER;
}
/**
* Convert domain entity to response DTO
*/
private toResponseDto(booking: CsvBooking): CsvBookingResponseDto {
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
return {
id: booking.id,
bookingNumber: booking.bookingNumber,
userId: booking.userId,
organizationId: booking.organizationId,
carrierName: booking.carrierName,
carrierEmail: booking.carrierEmail,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
priceUSD: booking.priceUSD,
priceEUR: booking.priceEUR,
primaryCurrency: booking.primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
status: booking.status,
documents: booking.documents.map(this.toDocumentDto),
confirmationToken: booking.confirmationToken,
requestedAt: booking.requestedAt,
respondedAt: booking.respondedAt || null,
notes: booking.notes,
rejectionReason: booking.rejectionReason,
routeDescription: booking.getRouteDescription(),
isExpired: booking.isExpired(),
price: booking.getPriceInCurrency(primaryCurrency),
commissionRate: booking.commissionRate,
commissionAmountEur: booking.commissionAmountEur,
};
}
/**
* Convert domain document to DTO
*/
private toDocumentDto(document: any): CsvBookingDocumentDto {
return {
id: document.id,
type: document.type,
fileName: document.fileName,
filePath: document.filePath,
mimeType: document.mimeType,
size: document.size,
uploadedAt: document.uploadedAt,
};
}
}