From 607257f538ea119469bdaab67a231ebd749b9324 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:53 +0200 Subject: [PATCH 1/3] feat(domain): une reservation non payee peut etre supprimee Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- .../entities/csv-booking.entity.spec.ts | 35 +++++++++++++++++++ .../src/domain/entities/csv-booking.entity.ts | 24 +++++++++++++ 2 files changed, 59 insertions(+) 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) * From efc47aae67214aca5c32b4af1579d97b88d85a81 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:53 +0200 Subject: [PATCH 2/3] feat(api): endpoint de suppression d une reservation non payee Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- .../controllers/csv-bookings.controller.ts | 26 +++++++++++++ .../services/csv-booking.service.ts | 38 +++++++++++++++++++ 2 files changed, 64 insertions(+) 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). * From b3564a19f63ed74864045e08d6d8ba7c7a8f9fb0 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:53 +0200 Subject: [PATCH 3/3] feat(ui): action de suppression dans la liste des reservations Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- .../app/[locale]/dashboard/bookings/page.tsx | 61 ++++++++++++++++--- apps/frontend/messages/en.json | 9 ++- apps/frontend/messages/fr.json | 9 ++- apps/frontend/src/lib/api/bookings.ts | 34 ++++++++++- apps/frontend/src/lib/api/index.ts | 1 + 5 files changed, 102 insertions(+), 12 deletions(-) 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={ <> - url.set('by', value)}> @@ -468,8 +514,7 @@ export default function BookingsListPage() { empty={{ icon: Package, title: t('empty.title'), - description: - searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings'), + description: searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings'), action: newReservationAction, }} caption={t('title')} diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index 692fea1..1df4c55 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -532,7 +532,14 @@ "actions": { "view": "View", "pay": "Pay", - "edit": "Edit" + "edit": "Edit", + "delete": "Delete" + }, + "deleteConfirm": { + "title": "Delete this booking?", + "body": "{reference} will be permanently deleted. This cannot be undone.", + "done": "Booking deleted", + "failed": "The booking could not be deleted" }, "detail": { "title": "Booking", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index b1a5545..ba2b85c 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -532,7 +532,14 @@ "actions": { "view": "Voir", "pay": "Payer", - "edit": "Modifier" + "edit": "Modifier", + "delete": "Supprimer" + }, + "deleteConfirm": { + "title": "Supprimer cette réservation ?", + "body": "{reference} sera définitivement supprimée. Cette action est irréversible.", + "done": "Réservation supprimée", + "failed": "La réservation n’a pas pu être supprimée" }, "detail": { "title": "Réservation", diff --git a/apps/frontend/src/lib/api/bookings.ts b/apps/frontend/src/lib/api/bookings.ts index 3ca3dca..471c151 100644 --- a/apps/frontend/src/lib/api/bookings.ts +++ b/apps/frontend/src/lib/api/bookings.ts @@ -4,7 +4,7 @@ * Endpoints for managing container bookings */ -import { get, post, patch, upload } from './client'; +import { get, post, patch, del, upload } from './client'; import type { CreateBookingRequest, BookingResponse, @@ -39,6 +39,8 @@ export interface UpdateBookingStatusRequest { export interface CsvBookingResponse { id: string; bookingId: string; + /** Référence affichée, ex. « XPD-2026-R9KE8U ». */ + bookingNumber?: string; carrierName: string; carrierEmail: string; origin: string; @@ -51,7 +53,13 @@ export interface CsvBookingResponse { primaryCurrency: string; transitDays: number; containerType: string; - status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; + status: + | 'PENDING_PAYMENT' + | 'PENDING_BANK_TRANSFER' + | 'PENDING' + | 'ACCEPTED' + | 'REJECTED' + | 'CANCELLED'; documents: Array<{ type: string; fileName: string; @@ -67,6 +75,19 @@ export interface CsvBookingResponse { commissionRate?: number; commissionAmountEur?: number; options?: Record; + + // Champs renvoyes par l'API mais absents de ce type : la page de detail les + // affichait sans que TypeScript puisse les verifier. + requestedAt?: string; + respondedAt?: string; + rejectionReason?: string; + routeDescription?: string; + isExpired?: boolean; + price?: number; + freightTotal?: number; + freightCurrency?: string; + fobTotal?: number; + fobCurrency?: string; } export interface CommissionPaymentResponse { @@ -279,6 +300,15 @@ export async function cancelCsvBooking(id: string): Promise { return patch(`/api/v1/csv-bookings/${id}/cancel`, {}); } +/** + * Supprime définitivement une réservation dont la commission n'a pas été payée. + * Une réservation payée est partie chez le transporteur : elle s'annule. + * DELETE /api/v1/csv-bookings/:id + */ +export async function deleteCsvBooking(id: string): Promise { + return del(`/api/v1/csv-bookings/${id}`); +} + /** * Update cargo details of a booking awaiting payment * PATCH /api/v1/csv-bookings/:id/details diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts index 595c792..3ff3ab0 100644 --- a/apps/frontend/src/lib/api/index.ts +++ b/apps/frontend/src/lib/api/index.ts @@ -54,6 +54,7 @@ export { listCsvBookings, getCsvBookingStats, cancelCsvBooking, + deleteCsvBooking, acceptCsvBooking, rejectCsvBooking, type CsvBookingResponse,