diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts
index aba25fc..fed40dc 100644
--- a/apps/backend/src/application/controllers/csv-bookings.controller.ts
+++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts
@@ -14,6 +14,7 @@ import {
BadRequestException,
ForbiddenException,
ParseIntPipe,
+ ParseUUIDPipe,
DefaultValuePipe,
Inject,
} from '@nestjs/common';
@@ -591,6 +592,31 @@ export class CsvBookingsController {
return await this.csvBookingService.cancelBooking(id, userId);
}
+ /**
+ * Delete an unpaid booking
+ *
+ * DELETE /api/v1/csv-bookings/:id
+ */
+ @Delete(':id')
+ @UseGuards(JwtAuthGuard)
+ @ApiBearerAuth()
+ @ApiOperation({
+ summary: 'Delete an unpaid booking',
+ description:
+ 'Permanently deletes a booking whose commission has not been paid. Only accessible by the booking owner. A paid booking has been sent to the carrier and can only be cancelled.',
+ })
+ @ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
+ @ApiResponse({ status: 200, description: 'Booking deleted successfully' })
+ @ApiResponse({ status: 400, description: 'Booking has been paid and cannot be deleted' })
+ @ApiResponse({ status: 404, description: 'Booking not found' })
+ @ApiResponse({ status: 401, description: 'Unauthorized' })
+ async deleteBooking(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Request() req: any
+ ): Promise<{ success: boolean; message: string }> {
+ return await this.csvBookingService.deleteBooking(id, req.user.id);
+ }
+
/**
* Update booking cargo details before payment
*
diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts
index 3bb52a8..e4ac8a0 100644
--- a/apps/backend/src/application/services/csv-booking.service.ts
+++ b/apps/backend/src/application/services/csv-booking.service.ts
@@ -1024,6 +1024,44 @@ export class CsvBookingService {
return this.toResponseDto(updatedBooking);
}
+ /**
+ * Delete an unpaid booking (user action).
+ *
+ * Seul le proprietaire peut supprimer, et seulement tant qu'aucun paiement
+ * n'a ete encaisse — voir `CsvBooking.isDeletable()`. Une reservation payee
+ * est partie chez le transporteur : elle s'annule, elle ne s'efface pas.
+ *
+ * Les documents deja televerses restent dans le stockage objet, comme lors de
+ * la suppression d'un document isole : la politique du projet est de les
+ * conserver pour l'audit.
+ */
+ async deleteBooking(id: string, userId: string): Promise<{ success: boolean; message: string }> {
+ this.logger.log(`Deleting booking ${id} by user ${userId}`);
+
+ const booking = await this.csvBookingRepository.findById(id);
+
+ if (!booking) {
+ throw new NotFoundException('Booking not found');
+ }
+
+ // Meme reponse qu'une reservation inexistante : appartenir a quelqu'un
+ // d'autre ne doit pas etre distinguable de ne pas exister.
+ if (booking.userId !== userId) {
+ throw new NotFoundException('Booking not found');
+ }
+
+ if (!booking.isDeletable()) {
+ throw new BadRequestException(
+ `Cannot delete a booking with status ${booking.status}. Only unpaid bookings can be deleted.`
+ );
+ }
+
+ await this.csvBookingRepository.delete(id);
+ this.logger.log(`Booking ${id} deleted`);
+
+ return { success: true, message: 'Booking deleted successfully' };
+ }
+
/**
* Update the cargo details of a booking before payment (user action).
*
diff --git a/apps/backend/src/domain/entities/csv-booking.entity.spec.ts b/apps/backend/src/domain/entities/csv-booking.entity.spec.ts
index 01edc3a..025a81f 100644
--- a/apps/backend/src/domain/entities/csv-booking.entity.spec.ts
+++ b/apps/backend/src/domain/entities/csv-booking.entity.spec.ts
@@ -315,6 +315,41 @@ describe('CsvBooking Entity', () => {
});
});
+ describe('isDeletable', () => {
+ it('allows deleting a booking whose commission is still unpaid', () => {
+ const booking = createValidBooking();
+ booking.status = CsvBookingStatus.PENDING_PAYMENT;
+
+ expect(booking.isDeletable()).toBe(true);
+ });
+
+ it.each([
+ // Paye : la reservation est partie chez le transporteur.
+ CsvBookingStatus.PENDING,
+ CsvBookingStatus.ACCEPTED,
+ CsvBookingStatus.REJECTED,
+ CsvBookingStatus.CANCELLED,
+ // Virement declare : il peut etre en cours d'acheminement.
+ CsvBookingStatus.PENDING_BANK_TRANSFER,
+ ])('refuses to delete a %s booking', status => {
+ const booking = createValidBooking();
+ booking.status = status;
+
+ expect(booking.isDeletable()).toBe(false);
+ });
+
+ it('stops being deletable once the payment is completed', () => {
+ const booking = createValidBooking();
+ booking.status = CsvBookingStatus.PENDING_PAYMENT;
+ expect(booking.isDeletable()).toBe(true);
+
+ booking.markPaymentCompleted();
+
+ expect(booking.status).toBe(CsvBookingStatus.PENDING);
+ expect(booking.isDeletable()).toBe(false);
+ });
+ });
+
describe('Expiration Logic', () => {
it('should not be expired for recent bookings', () => {
const booking = createValidBooking();
diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts
index c6c3ee1..d5f725e 100644
--- a/apps/backend/src/domain/entities/csv-booking.entity.ts
+++ b/apps/backend/src/domain/entities/csv-booking.entity.ts
@@ -14,6 +14,12 @@ export enum CsvBookingStatus {
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.PENDING_PAYMENT];
+
/**
* Document Interface
*
@@ -276,6 +282,24 @@ export class CsvBooking {
}
}
+ /**
+ * 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)
*
diff --git a/apps/frontend/app/[locale]/dashboard/bookings/page.tsx b/apps/frontend/app/[locale]/dashboard/bookings/page.tsx
index 40b86b8..4cdfd08 100644
--- a/apps/frontend/app/[locale]/dashboard/bookings/page.tsx
+++ b/apps/frontend/app/[locale]/dashboard/bookings/page.tsx
@@ -5,9 +5,19 @@ import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import type { ColumnDef } from '@tanstack/react-table';
-import { CreditCard, Eye, MoreVertical, Package, Pencil, Plus, Sparkles, X } from 'lucide-react';
+import {
+ CreditCard,
+ Eye,
+ MoreVertical,
+ Package,
+ Pencil,
+ Plus,
+ Sparkles,
+ Trash2,
+ X,
+} from 'lucide-react';
-import { listCsvBookings } from '@/lib/api';
+import { deleteCsvBooking, listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation';
import { PageHeader } from '@/components/ui/PageHeader';
import { Button } from '@/components/ui/button';
@@ -33,6 +43,8 @@ import {
BookingStatusBadge,
useBookingStatusLabel,
} from '@/components/bookings/BookingStatusBadge';
+import { useConfirm } from '@/components/ui/use-confirm';
+import { useToast } from '@/components/ui/toast';
import ExportButton from '@/components/ExportButton';
import { useReservationQuota } from '@/hooks/useReservationQuota';
import { useUrlState } from '@/hooks/use-url-state';
@@ -53,6 +65,8 @@ export default function BookingsListPage() {
const searchParams = useSearchParams();
const url = useUrlState();
const statusLabel = useBookingStatusLabel();
+ const confirm = useConfirm();
+ const { toast } = useToast();
// L'etat de vue vit dans l'URL : la liste est partageable et survit au
// rechargement, ce qui n'etait pas le cas avec un etat purement local.
@@ -80,6 +94,31 @@ export default function BookingsListPage() {
const quota = useReservationQuota();
+ /**
+ * Supprime une reservation impayee, apres confirmation.
+ *
+ * L'action est destructrice et sans annulation : la confirmation nomme la
+ * reservation concernee plutot que de demander « etes-vous sur ? ».
+ */
+ const remove = async (b: any) => {
+ const reference = b.bookingNumber || b.quoteNumber || `${b.origin} → ${b.destination}`;
+ const confirmed = await confirm({
+ title: t('deleteConfirm.title'),
+ description: t('deleteConfirm.body', { reference }),
+ confirmLabel: t('actions.delete'),
+ destructive: true,
+ });
+ if (!confirmed) return;
+
+ try {
+ await deleteCsvBooking(b.id);
+ toast.success(t('deleteConfirm.done'));
+ await refetch();
+ } catch {
+ toast.error(t('deleteConfirm.failed'));
+ }
+ };
+
// « Modifier » rouvre le parcours existant (recherche → transporteur →
// paiement) pre-rempli, afin qu'un changement redonne lieu a une cotation.
const buildEditUrl = (b: any) => {
@@ -291,6 +330,16 @@ export default function BookingsListPage() {
{t('actions.pay')}
+ {/* Suppression reservee aux reservations impayees : une fois
+ la commission reglee, la reservation est partie chez le
+ transporteur et ne peut plus qu'etre annulee. */}
+ remove(b)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ {t('actions.delete')}
+
>
)}
@@ -409,10 +458,7 @@ export default function BookingsListPage() {
}}
filters={
<>
-