Skip to content

Règles de gestion non-triviales

Règles métier qui ne sont pas évidentes à la lecture du code. Mettre à jour quand une règle change.

Workflow BookingStatus

Transitions normales :

InCreation / Estimate / Draft / WaitingList


   Confirmed (acompte facturé)


    Billed (totalité facturée)


    Canceled

Chemin parallèle paiement web/mobile :

PendingWebOrMobile (en attente paiement Saferpay/CembraPay)

        ▼ (paiement validé via worker 1min)
   Confirmed ou Billed (selon PaymentDepositOnly)

Sémantique des statuts intermédiaires :

  • InCreation = panier en cours (pas encore validé)
  • Estimate = devis envoyé au client
  • Draft = réservation provisoire — places réservées sans facture
  • WaitingList = liste d'attente (voyage complet)

Listes utilitaires dans Domain/Enum/BookingStatus.cs > ValidatingBookingStatus :

  • HasImpactOnOccurrenceOccupancy = Draft + Confirmed + Billed
  • HasImpactOnOccurrenceOccupancyExpr (Expression) et BookingHasImpactOnOccurrenceOccupancy(b) (in-memory) = idem + PendingWebOrMobile qui a un txid OU est encore dans la fenêtre de hold (CreatedAt >= PendingHoldCutoff()). Voir Hold des réservations web ci-dessous.
  • IsActive = Confirmed + Billed
  • ShouldHaveBill = Confirmed + Billed + Canceled
  • CanBeDeleted = InCreation + Estimate + Draft + PendingWebOrMobile

Hold des réservations web (anti-surbooking) — ajouté juin 2026

Problème : une résa web naît PendingWebOrMobile sans TransactionId (posé seulement à l'initiation du paiement). Tant qu'aucun txid n'est posé, l'ancienne règle ne bloquait pas la place → deux clients arrivant en même temps sur le paiement pour la dernière place passaient tous deux la validation = surbooking.

Règle : une résa PendingWebOrMobile occupe la place si TransactionId != null OU CreatedAt >= PendingHoldCutoff() (= now - PendingHoldDuration, 20 min). Un panier abandonné cesse de bloquer après 20 min. Le hold est centralisé : PendingHoldDuration / PendingHoldCutoff() dans ValidatingBookingStatus, PendingHoldCutoff() étant funcletisé par EF (cutoff réévalué à chaque requête). S'applique partout (dispo, validation, listes passagers, dossiers chauffeur, rapports, dashboards) — un même prédicat unique.

Exclusion de la personne courante (PendingHolderKey) : à la validation d'une nouvelle résa web (BookingService.Validate), on ignore les holds pending de la même personne pour ne pas qu'elle se bloque elle-même. Ça concerne tous les clients (invités ET clients existants) : à chaque aller-retour site↔paiement, une nouvelle résa temporaire est recréée (effacée après validation de la résa finale). La seule différence : un invité crée en plus un Customer temporaire à chaque tour (CustomerId différent), alors qu'un client existant garde le même CustomerId. Comme l'invité change d'id, l'exclusion par CustomerId/BookingId ne suffit pas : la clé est nom + prénom + email (normalisés Trim().ToLower()), stable dans les deux cas. Les 3 helpers OccurrenceService.GetSeatOccupancy/GetRoomTypeAvailability/GetDriveAvailability acceptent un PendingHolderKey excludeHolder optionnel. Les artefacts résiduels sont nettoyés par ailleurs (succès de la résa finale + worker CleanGuestCustomers).

Affichage public (site/app) sans casser l'API : sur web/mobile il n'y a jamais de currentBookingId (une nouvelle résa est créée à chaque aller-retour site↔paiement), donc on ne peut pas dériver le holder depuis l'id. Le site/app calcule le holder de son côté et le passe via un seul query param optionnel holder, au format name|firstname|email, à ces endpoints :

  • OccurrenceController : vehicle-occupancy, room-type-availability (passé à GetSeatOccupancy/GetRoomTypeAvailability).
  • TravelController : GET /api/travels/{slug} (passé à CalculateOccupancyAndSetBookingState(travel, excludeHolder:)InternalCalculateOccupancyLoadBookingsAsync, qui applique l'exclusion sur les compteurs d'occupation du DTO voyage).

Chaque contrôleur parse holder (ParseHolder) en PendingHolderKey ; vide/absent → null (pas d'exclusion). Étant optionnel, le contrat reste rétrocompatible (un ancien client qui ne l'envoie pas garde l'ancien comportement). Le matching compare toujours nom ET prénom ET email (normalisés Trim().ToLower()) : le site doit donc envoyer les 3 segments pour qu'un hold soit exclu, pour invités comme clients existants (cf. ci-dessus). Sans ça, une personne revenant du paiement verrait sa propre place « prise » par son hold précédent. La validation autoritaire reste BookingService.Validate (le surbooking est empêché quoi qu'affiche le display).

Limite connue (assumée) : si deux résas sont postées à quelques ms d'intervalle (avant commit de l'une), les deux Validate lisent la base avant l'INSERT de l'autre → la course n'est pas fermée à 100 % (cas très rare). Un vrai correctif nécessiterait une transaction sérialisable ou une contrainte d'unicité DB sur le siège.

Quota de ligne de chargement (OneDay) : la résa est comptée en entier — corrigé août 2026

À la validation d'une résa web/mobile sur une course d'un jour (BookingService.Validate, branche travel.Type == OneDay, atteinte uniquement quand NoVehiclePlan est vrai — sinon c'est le contrôle par siège qui s'applique), les passagers sont groupés par ligne de chargement et le groupe est comparé au RemainingQuota de la ligne.

Bug corrigé : chaque passager était testé isolément contre RemainingQuota <= 0, c'est-à-dire « la ligne est-elle déjà pleine ? ». Une résa de N passagers passait donc dès qu'il restait 1 place. Cas réel : « Une journée à Europa-Park® 2026 » du 22.08.2026, quota 50, résa web 18873 (2 pax) acceptée le 05.08 à 22:21 alors que 49 places étaient prises → 51 passagers pour 50 places. Ce n'était ni un défaut de recalcul (la disponibilité valait bien 1), ni le hold ci-dessus (les deux résas avaient un txid et étaient facturées).

Scénario exact (confirmé par l'équipe, août 2026) : le site web plafonne le nombre de passagers sélectionnables aux places restantes — c'est la première barrière, et elle a fonctionné pour tous les clients arrivés après. Les deux clients étaient simplement dans le tunnel en même temps : chacun a commencé sa réservation alors qu'il y avait la place pour les deux, la première s'est finalisée à 22:02, et la seconde — son formulaire déjà rempli à 2 personnes — a été validée à 22:21. Le plafond du site s'appuie sur la disponibilité vue au moment où le formulaire est rempli ; c'est donc le contrôle serveur, dernière barrière, qui devait rattraper le cas et ne le faisait pas. ⚠ Ne pas en déduire que les 19 minutes d'écart disqualifient le scénario concurrent : ce qui compte est le chevauchement des tunnels, pas l'écart entre les deux validations.

Limite restante, non corrigée : la validation ne contrôle jamais Occurrence.CapacityMax — ce champ ne sert qu'à l'occupation affichée et au BookingState (TravelOccupancyAndBookingStateService). Pour une course d'un jour, la seule limite opposable à la réservation est la somme des quotas de lignes. Tant qu'une occurrence n'a qu'une ligne dont le quota vaut la capacité (le cas d'Europa-Park : 1 ligne, quota 50, capacité 50), les deux coïncident ; avec plusieurs lignes dont les quotas totalisent plus que la capacité, le dépassement global reste possible sans qu'aucune ligne ne soit pleine.

Contingents de chambres/cabines : bloquants côté web/mobile, jamais côté back-office — confirmé Buchard août 2026

Le contingent d'un type de chambre (OccurrenceRoomTypeQuota, saisi par occurrence) n'a pas la même force selon le canal :

  • Site web et app : le contingent est bloquant. BookingService.Validate compare les types de chambre demandés à OccurrenceService.GetRoomTypeAvailability et refuse avec « Il n'y a plus de chambres disponibles pour ce voyage. »
  • Back-office : aucun contrôle bloquant. Un vendeur peut sciemment vendre au-delà du contingent, puis appeler l'hôtel ou l'armateur pour s'arranger et augmenter le quota. C'est un comportement voulu, pas un défaut. L'écran de sélection de la chambre/cabine signale visuellement que le type est complet — les vendeurs connaissent le mécanisme et le dépassement est donc un geste conscient (confirmé Buchard, août 2026). Ne pas proposer d'avertissement supplémentaire à ce titre.

Le levier technique n'est pas l'origine de la réservation mais le canal de sauvegarde : Pages/Bookings/CreateUpdate.cshtml.cs appelle Validate(booking, validateWebsiteRules: **false**), ce qui court-circuite tout le bloc de contrôles de disponibilité (chambres, sièges et quota de ligne OneDay). ⚠ Conséquence à connaître : une réservation d'origine Web éditée depuis le back-office n'est plus contrôlée du tout — Booking.Origin garde la valeur Web à vie, mais c'est le false du back-office qui gagne. Un vendeur peut donc déplacer une réservation web vers un type de cabine complet sans le moindre avertissement.

Cas réel (Asana 1217953711561586) : croisière de l'Italie au Maroc du 04.05.2027, « Cabine double balcon » contingent 8, atteint le 19.08.2026. La réservation web 21042 avait correctement acheté une « Cabine double vue mer » (4ᵉ sur 5) le 22.08 ; elle a ensuite été basculée sur balcon par une édition back-office, portant le type à 9 cabines pour 8. Le tunnel web n'a jamais été en défaut.

Défaut restant côté web : le contrôle ne teste que rta.Quota <= 0, c'est-à-dire « ce type est-il déjà plein », et ne compare jamais le nombre de chambres demandées au reste disponible — même forme que le bug de quota OneDay ci-dessus. Une réservation de 2 cabines passe dès qu'il en reste 1.

⚠ Trois autres angles morts de GetRoomTypeAvailability, non corrigés : le décrément se fait une fois par BookingAccommodation (PassengerRooms.FirstOrDefault()), donc une accommodation portant plusieurs chambres n'en consomme qu'une ; aucune ligne de disponibilité n'est créée si le type de chambre n'appartient pas à une accommodation Main d'un jour de voyage ou si le couple (RoomTypeId, OptionId) ne correspond pas, auquel cas il n'y a aucune limite ; et dans BookingService.GetRoomTypeAvailabilitySeaside toute la logique de décrément est en commentaire, donc la disponibilité balnéaire vaut le quota brut sans soustraire les réservations.

Acompte vs total : qui décide ?

  • En back-office Horizon : c'est le statut du booking qui fait foi.
    • Confirmed ⇒ acompte facturé (Invoice de type Confirmation).
    • Billed ⇒ totalité facturée (Invoice de type Balance après le Confirmation, ou Simple directement).
  • En paiement web/mobile (Saferpay ou CembraPay) : c'est le flag Booking.PaymentDepositOnly qui pilote.
    • true → on capture l'acompte (montant calculé depuis Travel.DepositPercentage côté site web), booking devient Confirmed.
    • false → on capture le total, booking devient Billed directement.
  • Leçon : ne JAMAIS lire PaymentDepositOnly pour un booking back-office, c'est trompeur. Lire le statut.
  • Leçon : Travel.DepositPercentage est la source de vérité de l'acompte. Le site web doit lire ce champ via l'API et NON hardcoder un pourcentage.

Ce que contient réellement l'acompte : deux postes échappent au découpage

DepositPercentage n'est pas un pourcentage uniforme du total. Chaque InvoiceItem porte Price (quote-part acompte ou solde, selon booking.Status) et FullPrice (montant plein), et deux postes sont écrits à Price == FullPrice, donc facturés à 100 % sur la Confirmation :

PostePart sur la ConfirmationSite d'écriture
Transport + hébergement catalogue, excursions, supplément vol, pension, suppléments manuels, supplément global, Early Booking, points de fidélité× DepositPercentage / 100BookingService.CreateOrUpdateInvoice (~:2144, :2244, :2265, :2380, :2403, :2453, :2473)
Prime d'assurance annulation (compte 3021)100 %BookingService.cs:2326Price = FullPrice = insurancesCosts
Bon cadeau (compte 2061, en déduction)100 %BookingService.cs:2438Price = FullPrice = giftEffectiveValue * -1

Conséquences à connaître :

  • La prime d'assurance annulation est due en totalité dès l'acompte, jamais étalée. C'est voulu.
  • Un bon cadeau posé avant la confirmation absorbe l'acompte en priorité : sur un acompte de 828.40, un bon de 500 ramène l'acompte demandé à 328.40, et le solde reste inchangé. C'est exactement l'arbitrage n° 1 en attente dans ADR 0011 (« l'acompte se calcule-t-il sur le prix plein ou sur le montant réduit ? ») — le chiffre rend la question concrète pour Buchard.
  • ⚠ La ligne assurance est ajoutée en itérant tous les passagers, annulés compris (:2288), cohérent avec passengersTotalCost.Insurances qui ré-additionne les annulés (:1907-1912) : une prime reste facturée après annulation du passager. Ne pas « corriger » sans lire la section Annulation totale : la prime d'assurance annulation….

Total de la facture Balance = montant plein, pas le reliquat

En NewInvoiceProcess, invoiceTotal de la Balance somme les FullPrice (BookingService.cs:2644-2646) : la facture de solde porte le total de la réservation, et c'est TotalPaid qui fait apparaître le reliquat sur le PDF. Il n'y a qu'un seul document BC par réservation ; acompte et solde ne sont qu'un découpage d'affichage Horizon (cf. InvoiceService.cs:645).

Calcul du tier de prix passager (PassengerType)

Le prix d'un passager dépend de son âge à la date de départ et de son TripType. Logique répliquée dans 2 endroits — garder synchronisé :

  • BookingService.GetCostPerPassenger (calcul du prix appliqué)
  • PdfGeneratorService.GetPriceTier (label affiché sur les PDFs Confirmation/Balance)

Seuils selon TravelType :

TravelTypeBébé (gratuit)EnfantJuniorAdulte
OneDay< 4 ans4-1212-1818+
Seaside sans Accommodation< 2 ans2-1212-1818+
Seaside avec Accommodation< accommodation.ChildMin[ChildMin, ChildMax)[JuniorMin, JuniorMax)>= AdultMin
Catalog, OutOfCatalog, Group(pas de tier — prix uniforme par occurrence)

Le TripType.RoundTrip lit Occurrence.SellingPrice/Junior/Child ; les OneWay/Return lisent les variantes SellingPriceOneWay/Junior/Child.

Passager repris / partagé : verrou d'identité — ajouté juin 2026

L'identité (Civility/Firstname/Name/Birthdate/Phone) vit sur l'entité partagée Passenger, pas sur BookingPassenger. Un même Passenger peut être référencé par plusieurs réservations :

  • repris via la modal « Passagers précédents » (General.vue > selectFormerPassenger, qui recopie le Passenger.Id existant),
  • ou simplement au chargement d'une résa existante.

Au save (BookingService ~L823/L827), un passengerDto.Id existant réutilise et écrase le Passenger partagé ⇒ éditer le nom dans une résa le changeait dans toutes les autres.

Règle : dans le SPA de réservation, quand un passager est repris/partagé, ses champs d'identité (nom, prénom, date de naissance, civilité, téléphone) sont verrouillés + bandeau « Passager repris ». Détection :

  • Édition : BookingPassengerDto.IsShared, calculé dans ReverseMap. ⚠ Verrou seulement s'il existe une résa plus ancienne (bp.Booking.BookingDate < entity.BookingDate) utilisant ce passager : la résa la plus ancienne (là où la personne a été créée) reste éditable comme enregistrement « maître » (éditer son identité s'y propage volontairement aux autres). Les résas postérieures sont verrouillées.
  • Création : flag front passenger.isReused, posé par selectFormerPassenger (une nouvelle résa est toujours la plus récente ⇒ jamais « maître » ⇒ toujours verrouillée si reprise).

Copy-on-write (piloté par le backend) : bouton « Modifier quand même » → Passenger.vue > editReusedPassenger se contente de poser le flag passenger.forceNewPassenger = true et de lever le verrou (l'id front ne change pas, pour que les références chambres/activités restent valides). Au save, BookingService.Map détecte ForceNewPassenger, génère un nouveau Guid côté serveur, repointe atomiquement les références chambres (RoomTypeCategories[].Days[].Passengers[].PassengerId) et activités (Activities[].Passengers[].PassengerId) vers ce nouvel id, puis la boucle passagers crée un nouveau Passenger (le partagé reste intact). Le lien client (customerId) est conservé. ⚠ Pourquoi pas un simple changement d'id côté front : tenté d'abord, mais la mutation de passenger.id ne se propageait pas de façon fiable jusqu'au payload sérialisé (référence Vuex) → la base était quand même écrasée. Le flag + remap serveur est déterministe. Le bouton « détacher le client » est masqué tant que le verrou est actif (sinon il viderait l'identité partagée en contournant le verrou).

Code Helvetic (export passagers vol charter)

Calculé dans Pages/Occurrences/HelveticListPassengers.cshtml.cs > GetSalutationCode.

Le formatage des noms (sans diacritiques, tirets/apostrophes → espace, chaque mot capitalisé) est centralisé dans Application/Library/HelveticNameFormatter.Format (testé : HelveticNameFormatterTest). ⚠ Ne pas confondre avec EuropaParkList.FormatStr qui a des règles différentes (une seule majuscule, conserve les tirets) — les deux ne sont volontairement pas fusionnés.

Civilité :

  • passenger.Civility (string) si défini, sinon fallback sur passenger.Customer.Civility.
  • "Madam""Ms", "Sir""Mr", autre → "".

Surcharge selon âge à la date du vol :

  • âge < 2 → "INF" (infant) quelle que soit la civilité
  • âge < 12 → "CHD" (child) quelle que soit la civilité
  • sinon → renvoie la civilité Mr/Ms/""

Si Birthdate.Year < 1900 (date manquante / invalide), la civilité adulte est utilisée par défaut.

Tableaux de chargement : 1ère ligne éditable = chauffeur du voyage

Pattern appliqué côté catalogue (non-seaside) depuis l'origine et seaside depuis la modification de mai 2026 :

  • Le chauffeur du car principal du voyage (lu depuis Visual Planning) est recopié dans editables[0].DriverName avec DriverEditable = false.
  • Les chauffeurs de chargement/dépose s'écrivent ensuite à partir de editables[1..] (offset resourceCount calculé en conséquence).
  • Le champ DriverName de la ligne IsMainTravel (rendue avec @Html.Raw) garde ses séparateurs <br>. La copie vers editables[0].DriverName (rendu <textarea>) remplace <br> par \n.
  • Si aucune ligne éditable n'existe, le chauffeur du voyage n'apparaît pas (le user doit cliquer "Ajouter une ligne"). Cohérent avec le catalogue.

Code : Application/Services/Entities/LoadingTableService.cs.

Compteurs de passagers : qui filtre quoi

Plusieurs écrans affichent un nombre de passagers pour une même occurrence et peuvent diverger si on ne sait pas quel filtre chacun applique. Référence pour ne plus se faire piéger :

SourceFiltre statut bookingFiltre TripType (sens)Filtre LoadingStop/UnloadingStop
Pages/Occurrences/ListPassengers.cshtml.csHasImpactOnOccurrenceOccupancy ∪ (PendingWebOrMobile & (TransactionId != null ou hold CreatedAt >= PendingHoldCutoff())) + !Cancelled❌ aucun❌ aucun
TravelOccupancyAndBookingStateService (cellule "Occupancy" de /Occurrences/Index)idem❌ aucun❌ aucun
TravelListService.GetPassengersPlan (/Occurrences/Plan)idem✅ par sens : aller = RoundTrip + OneWay + Legacy, retour = RoundTrip + Return + Legacy✅ — depuis le fix mai 2026, les passagers sans stop assigné vont dans le drive virtuel "Trajet indéterminé" (ils étaient silencieusement perdus avant)
LoadingTableService.BaseBookingPassengersQuery (tableau de chargement, export Helvetic)idem✅ par sens (bp.TripType != excludedTripType selon Returns flag du tableau)non couvert pour l'instant : passagers avec LoadingStopId NULL non rattachés à un stop logistique → comptage faux possible

Diagnostic d'un écart :

  • Si "Plan aller" < "ListPassengers" → des passagers en TripType.Return (ils sont uniquement comptés côté retour) ou des passagers sans LoadingStop assigné (avant fix mai 2026).
  • Si "Plan aller" + "Plan retour" > "ListPassengers" → normal, les RoundTrip sont comptés des deux côtés.
  • Si "Index Occurrences" > "tableau de chargement" → suspecter LoadingStopId NULL ou un mismatch de drives effectifs.

Trajet indéterminé : fallback pour stops manquants

Quand un BookingPassenger :

  • a un LoadingStop (ou UnloadingStop selon sens) dont le Drive n'est plus dans les EffectiveDrives de l'occurrence (Drive supprimé, override OccurrenceLine, etc.) → le passager est rattaché à un drive virtuel "Trajet indéterminé" (Drive.Id = Guid.Empty) sous un stop reconstitué depuis Locality + Place.
  • a LoadingStopId (ou UnloadingStopId) purement NULL → depuis le fix mai 2026, le passager est aussi rattaché au "Trajet indéterminé", sous un stop virtuel "Lieu non assigné" (Locality vide). Permet au commercial de voir qu'il y a un défaut de saisie à corriger. Avant ce fix, ces passagers étaient silencieusement éjectés du compteur.

Implémenté dans TravelListService.PopulateDriveWithPassengers (boucle sur tous les passagers — Where(p => p.StopE != null) retiré). À répliquer dans LoadingTableService quand on s'attaquera au même cas côté tableau de chargement.

Logo / pass Frimobil sur la facture

Le pass Frimobil (transports publics zone Fribourg inclus jour aller/retour) est affiché sur la facture Balance (InvoiceType == 2) via ShowFrimobilLogo (PdfGeneratorService.cs). Conditions cumulées :

  • au moins un passager non annulé dont le LoadingStop est dans le périmètre Frimobil (LocalityfrimobilArea ou State == "FR") ;
  • ET le voyage n'est pas une course d'un jour (Travel.Type != TravelType.OneDay) — ajouté mai 2026 : un OneDay ne donne jamais droit au pass.

Rendu côté template : TransportAndAccommodation.liquid ({% if InvoiceType == 2 and ShowFrimobilLogo %}).

Sièges sur la facture : masqués pour les rotations « sans plan de car » — ajouté juin 2026

La section Transport(s) (n° de sièges par passager) du template TransportAndAccommodation.liquid n'est plus rendue quand la rotation est sans plan de car. Condition : {% if BookingStatus > 4 and HasSeats and NoVehiclePlan == false %}.

NoVehiclePlan provient de Occurrence.NoVehiclePlan (PdfGeneratorService.cs), propagé depuis la SeasideDate aller via SeasideDateService.ApplyToOccurrences. Cas typique : balnéaires en car (Igea, Costa Brava) où aucun plan de car n'est établi — les sièges peuvent rester assignés en base mais ne doivent jamais figurer sur la facture. Hors Seaside NoVehiclePlan vaut false par défaut, donc aucun impact. S'applique aux factures Confirmation et Balance (même template).

LoadingStop / UnloadingStop peuvent être NULL — déréférencements à guarder

BookingPassenger.LoadingStopId et UnloadingStopId sont nullables en DB. Tout déréférencement direct (bp.LoadingStop.X) plante avec NullReferenceException quand le passager a été saisi sans stop d'embarquement (cas réel : online bookings, saisies incomplètes). Le bug initial qui a déclenché ce constat = facturation cassée pour un voyage avec 3 passagers à LoadingStopId NULL.

Endroits guardés à ce jour (mai 2026) :

  • PdfGeneratorService.cs:689 (ShowFrimobilLogo) — guard p.LoadingStop != null + ?.ToLower() sur Locality. Fix du bug facturation.
  • TravelListService.PopulateDriveWithPassengers — passagers sans stop rattachés au "Trajet indéterminé / Lieu non assigné" (cf. section précédente).
  • TemplatesPdf/TransportAndAccommodation.liquid{% if passenger.BookingPassenger.LoadingStop %} autour des accès .Place/.Locality, fallback "Lieu non assigné".

Endroits NON guardés (dette connue, à fixer si plantage remonté) :

  • BookingService.cs:1116-1119 — mapping LoadingStopName/UnloadingStopName à la lecture d'un booking.
  • OnlinePaymentService.cs:210-211 — payload CembraPay (departure_location, departure_hour).
  • LoadingTableService.cs:751, 1422, 1430 — render du tableau de chargement.
  • OccurrenceService.cs:942 — calcul bookingPassenger.LoadingStop.DriveId.
  • TravelOccupancyAndBookingStateService.cs:430Select(p => p.LoadingStop.Id) (probablement OK car traduit en SQL côté EF, à confirmer si on touche).

Règle pour les nouvelles modifs : ne JAMAIS faire bp.LoadingStop.X ou bp.UnloadingStop.X sans guard != null. Préférer bp.LoadingStop?.X ?? defaultValue ou un if explicite.

EarlyBooking : déclenchement centralisé

Source unique de vérité :

  • Backend C# : Booking.IsEarlyBookingActive() (niveau booking) et la surcharge Booking.IsEarlyBookingActive(BookingPassenger) (par passager), Domain/Entities/Booking.cs.
  • Frontend Vue : isEarlyBookingActive(booking, travel, passenger = null) (helper, common/library.js).

Règle : Travel.EarlyBooking == true ET dateEffective.Date <= Travel.EarlyBookingDate.Date. La deadline est inclusive jour J : les deux dates sont comparées sans heure (.Date côté C#, setHours(0,0,0,0) côté Vue). Une résa faite le jour J à n'importe quelle heure bénéficie de la promo.

Éligibilité par passager (depuis juin 2026) : dateEffective = BookingPassenger.BookingDate ?? Booking.BookingDate. Chaque passager est jugé sur la date à laquelle il a rejoint la résa, pas sur la date de la réservation. Motivation : un passager ajouté à une résa après la deadline ne doit pas hériter de la remise via la date (antérieure) du booking. Tamponnage de BookingPassenger.BookingDate :

  • CreateAsync : tous les passagers d'une nouvelle résa = Booking.BookingDate (création).
  • UpdateAsync : seuls les BookingPassenger nouvellement créés reçoivent DateTime.Now ; les existants conservent leur date stockée.
  • Legacy (null) : fallback sur Booking.BookingDate (les anciennes résas gardent leur comportement). Le champ est nullable et jamais backfillé — c'est le fallback qui assure la rétro-compat.
  • Front : à l'ajout d'un passager dans le SPA (store.js, slot id === null), bookingDate = new Date() pour que l'aperçu prix soit juste immédiatement ; round-trip via BookingPassengerDto.BookingDate.
  • La version sans argument reste utilisée pour les usages « la promo est-elle en jeu sur la résa ? » (mention PDF voucher) — elle équivaut à la date du booking (la plus précoce).

Toujours un montant fixe en CHF, par adulte éligible. Le mode pourcentage a été supprimé en août 2026 (Travel.EarlyBookingType retiré de l'entité, du DTO back-office et de l'écran de configuration ; colonne droppée par migration). Motif : la remise était calculée dans la boucle par passager avec le montant total de la réservation pour base (BookingService), donc 2 adultes à 10 % retiraient 20 % du dossier, 3 adultes 30 %. Le front, lui, renvoyait toujours 0 pour ce mode (total += total * value / 100 avec total initialisé à 0) — front et facture divergeaient donc totalement. Aucun voyage n'utilisait le pourcentage en prod (53 voyages en early booking, tous en montant fixe de 10 à 150 CHF), la suppression n'a donc rien changé aux dossiers existants. ⚠ Dtos/Api/Travel/TravelDto.EarlyBookingType est conservé, figé à Value, pour ne pas casser le contrat du site.

Champs lus en live sur le Travel (pas snapshotés sur la Booking) : EarlyBooking, EarlyBookingDate, EarlyBookingValue. Conséquence : modifier le Travel après création de la résa change rétroactivement l'affichage et le recalcul de remise sur les résas existantes. Comportement intentionnel (prolongement de promo → bénéfice rétroactif).

4 sites d'appel à garder cohérents — tous passent désormais par les helpers ci-dessus :

  1. BookingService (boucle remise dans le calcul d'amount, ~ligne 1925) — gate par passager via IsEarlyBookingActive(passenger), remise pour les adultes uniquement.
  2. BookingService (~ligne 2350) — ligne d'InvoiceItem "Réduction Early Booking". Le gate earlyBookingPrice > 0 suffit (au moins un passager éligible) : plus de double-check IsEarlyBookingActive() redondant.
  3. PdfGeneratorService (IsEarlyBooked) — affichage de la mention sur SeasideVoucher.liquid (niveau booking, sans argument).
  4. Vue cost.js + Price.vue — récap prix dans l'UI d'édition de résa ; cost.js gate par passager, Price.vue reste au niveau booking pour l'en-tête promo.

Restriction Vue : cost.js n'applique la remise au calcul que pour les voyages TravelType.Seaside. Le backend C# et Price.vue n'ont pas cette restriction. Probablement un bug — à confirmer avec Buchard si EarlyBooking est censé être réservé aux balnéaires ou ouvert à tous types.

(L'ancien « bug connu cost.js » sur la remise en % est caduc : le mode pourcentage n'existe plus, cf. ci-dessus.)

Une modification du catalogue ne se propage JAMAIS en lot aux réservations existantes — confirmé Buchard août 2026

Règle, et c'est un choix métier délibéré : ajouter ou modifier après coup un supplément global, une offre spéciale ou une promotion Early Booking (activation / date limite / montant) ne met pas à jour les factures des réservations déjà enregistrées, même éligibles. La facture n'est recalculée qu'au ré-enregistrement de la réservation, dossier par dossier — c'est-à-dire au prochain passage dans BookingService.UpdateAsyncCreateOrUpdateInvoice.

Pourquoi il ne faut pas « améliorer » ça en batch : chaque réservation reprise déclenche la cascade complète (avoir BC + nouvelle facture + délettrage/relettrage des paiements, cf. modules/billing-payment.md). Propager automatiquement un paramétrage sur un portefeuille, c'est réémettre potentiellement plusieurs centaines de factures avant d'avoir pu constater que le supplément était mal configuré — erreur non rattrapable une fois les documents partis en compta et chez les clients. Une reprise est donc manuelle et volontaire, et aucun écran ne la fait en lot. Ne pas implémenter de recalcul de masse sans arbitrage Buchard explicite.

Mécanique par cas :

  • Supplément global : ReconcileGlobalSupplementsAsync n'est appelée que depuis UpdateAsync, donc uniquement quand la résa repasse par un enregistrement. Un supplément nouvellement actif n'apparaît qu'à ce moment-là.
  • Offre spéciale : le Discount est valorisé côté front et stocké sur la résa (cf. § Offres spéciales « par personne »). Une offre créée après coup n'est jamais appliquée rétroactivement, même au ré-enregistrement — il faut la ressaisir à la main. Vérifié août 2026 : todayOffers() n'est consommé qu'à la résolution du client (booking/steps/General.vue:273, dans le callback de récupération de la fiche client, et steps/passengers/Passenger.vue:487), jamais au chargement d'une résa existante ; et SpecialOffer n'apparaît nulle part dans BookingService — aucun chemin serveur ne les réapplique. ⚠ Cas de bord : changer le client d'une résa existante repasse par ce callback et pousse donc les offres du jour dans les discounts — seul moyen de voir une offre apparaître après coup, et il est involontaire.
  • Early Booking : les champs Travel.EarlyBooking* sont lus en live, donc le recalcul se fait bien au ré-enregistrement, sans ressaisie.

Corollaire — l'affichage et la facture stockée divergent entre les deux. Les paramètres Early Booking et les lignes de supplément sont relus à chaque impression du PDF de confirmation, alors que la facture enregistrée attend le ré-enregistrement. Un PDF réimprimé peut donc afficher des lignes ou un total qui ne correspondent plus au document parti en compta — c'est le mécanisme décrit dans modules/billing-payment.md § « Le PDF de Confirmation est régénéré en direct » (cas réel : résa 16759).

Retirer une excursion d'un voyage n'annule pas celles déjà réservées — ajouté août 2026

Symétrique de la règle ci-dessus : enlever une excursion facultative du voyage supprime les liens TravelActivity / TravelDayActivity, mais laisse intactes les BookingPassengerActivity des réservations existantes. L'excursion reste comptée dans le prix de la résa et sur ses factures — elle est simplement retirée du catalogue proposé aux nouvelles réservations. C'est cohérent avec « une modification du catalogue ne se propage jamais », mais ça a produit un bug d'affichage (Asana 1217785611307289, résas 10082 / 13270 du départ Corse du 29.08.2026) : l'écran de réservation, qui listait les excursions depuis le catalogue du voyage, n'avait plus rien à afficher — l'excursion devenait invisible tout en restant facturée, et le total affiché tombait en dessous du montant facturé.

Garde-fou (août 2026) : TravelService.UpdateAsync refuse désormais d'enregistrer un voyage qui retire une excursion encore réservée sur un départ à venir par une réservation Provisoire / Confirmé / Facturée. Message nommant l'excursion et les n° de résa concernés. Il faut retirer l'excursion des réservations d'abord — ce qui, pour une résa facturée, déclenche la cascade avoir + nouvelle facture, et c'est bien l'intention : le client ne doit plus payer une excursion qu'il ne fera pas.

Limites assumées :

  • Préventif seulement : les 54 réservations déjà porteuses d'une excursion orpheline (relevé du 26.08.2026) ne sont pas assainies. L'écran de réservation les affiche désormais avec un avertissement, à traiter au cas par cas.
  • Les départs passés et les réservations annulées ne bloquent rien, pour que le nettoyage de catalogue reste possible.
  • Détail technique dans modules/product-catalog.md § Activités.

Supplément global : hors prix passager, refacturé via les frais d'annulation — août 2026

Le supplément global (carburant) est stocké une fois pour la réservation (BookingGlobalSupplement.Amount = AmountPerPersonPerDay × nbPassagers × nbJours) et émis comme ligne de facture distincte. Il ne transite jamais par BookingPassenger.Price.

Règle : cost.js > getRealCostPerPassenger(passenger, includeCancelledPassengers = false, includeGlobalSupplement = false). Un seul appel de tout le front passe includeGlobalSupplement = true : calculateFee dans cancel-booking/App.vue. Le supplément n'entre donc dans aucun prix affiché ni stocké — uniquement dans l'assiette des frais d'annulation, c'est-à-dire la valeur pré-remplie dans le champ éditable « frais ».

Pourquoi via les frais : la ligne d'avoir vaut BookingPassenger.Price − CancellationFees (BookingService.cs:2873) et la ligne de supplément n'est pas reprise sur le chemin d'édition simple (ReconcileGlobalSupplementsAsync : « never reprice the lines that stay ») — le client continue donc d'être facturé le carburant du passager annulé. Le répercuter dans les frais est exactement ce que les vendeurs faisaient à la main (résas 16291, 16245 : frais = prix passager + part de carburant), en ordre dispersé. Le dénominateur de la part suit includeCancelledPassengers = true : on divise par l'effectif qui a fixé le montant, puisqu'il n'est pas reprisé sur ce chemin.

⚠ Deux chemins de (re)calcul, et ils ne font PAS la même chose

ShouldApplyGlobalSupplements (BookingService.cs:3250) aiguille vers l'un des deux, et le montant du carburant dépend donc de l'historique des manipulations, pas seulement des données de la résa :

SituationMéthodeEffet sur les lignes existantes
Création de résaApplyGlobalSupplementsAsyncCalcul initial sur Count(p => !p.Cancelled)
ConfirmedBilledApplyGlobalSupplementsAsyncEfface tout et reconstruit sur l'effectif non annulé courant
Résa web/mobile déjà Billed (wasBilled && Origin.Web/Mobile)ApplyGlobalSupplementsAsyncEfface tout et reconstruit
Édition simple back-officeReconcileGlobalSupplementsAsyncRetire les inapplicables, ajoute les manquants, ne reprise jamais ceux qui restent

Conséquence sur « annuler un passager puis facturer » — les deux cas ne se valent pas :

  • Résa Confirmed, on annule un passager, puis on facture : Apply reconstruit le supplément sur l'effectif restant ⇒ la part du passager annulé quitte la ligne, et les frais d'annulation la portent. Facturée une seule fois. Cohérent.
  • Résa déjà Billed, on annule un passager : Reconcile ⇒ la ligne garde l'effectif d'origine, alors que les frais d'annulation contiennent déjà la part ⇒ carburant facturé deux fois.

Nuance côté front : le pré-remplissage des frais qui embarque la part de carburant (cancel-booking/App.vue > calculateAllPassengersFee) ne se déclenche que si passenger.cancellationInsuranceId !== null. Sans assurance sélectionnée, le vendeur saisit les frais à la main et le double comptage dépend de ce qu'il a tapé — d'où des dossiers en apparence contradictoires.

Question de fond non tranchée par Buchard : un passager annulé doit-il payer sa part de carburant ? Tant que la réponse n'est pas unique, les deux chemins continueront de diverger. Cf. RAPPORT_FACTURATION.md § 4.5, question 4.

Ne jamais mettre la part dans BookingPassenger.Price. Le prix d'un passager annulé doit rester identique à celui de ses co-voyageurs, sinon la table de l'écran d'annulation affiche des prix incohérents entre passagers d'une même résa. Le mécanisme inverse a existé du 04.08 au 06.08.2026 (7faa05d0 : part ajoutée dans getRealCostPerPassenger par défaut + BookingPassengerModel.GlobalSupplementShare soustrait dans Confirmation.liquid / Balance.liquid / Estimate.liquid) et a été entièrement retiré : il postulait que le prix stocké contenait toujours la part, ce qui était faux pour 2 820 des 3 903 résas concernées — résas antérieures au 04.08, résas web et mobile (le site et l'app ont leur propre calcul et n'ont jamais eu la règle), et suppléments posés côté serveur après coup par ApplyGlobalSupplementsAsync / ReconcileGlobalSupplementsAsync, qui créent la ligne sans toucher aux prix. Symptôme : résa 17848, PDF à 47.– au lieu de 49.–, document qui ne s'additionnait plus.

Ne jamais soustraire la part à l'affichage : le prix passager n'en contient pas, la ligne de supplément la porte seule, les documents s'additionnent tels quels.

Hors du palier d'assurance annulation (PaymentInsurances.vue) : réponse Buchard d'août 2026 — sans ça, un passager proche d'une borne de tranche (199.– + 4.– = 203.– sur une borne à 200.–) basculerait dans le palier supérieur et verrait sa prime changer. C'est le comportement par défaut depuis que includeGlobalSupplement vaut false.

Les deux totaux de l'écran d'annulation ne sortent pas de la même source, volontairement : « Total réservation » affiche booking.general.amount, le montant de base de la réservation, qui doit rester stable même si l'utilisateur change les chambres dans l'écran d'annulation — ne pas le remplacer par un calcul live. « Total réservation après annulation » appelle getTotalCost(), qui filtre déjà les passagers annulés. Sur une résa à 2 passagers à 49.– + 4.– de carburant : 102.– / 53.– après annulation d'un passager, 102.– / 0.– après annulation totale.

Annulation totale : le sous-total retombe à zéro. getSubTotalCost ajoutait chambres, activités de groupe, suppléments manuels et supplément global sans regarder s'il restait un passager actif, alors que le coût passager, lui, filtre les annulés — l'écran affichait donc « Total après annulation » = le seul supplément global (résa 17848 : 4.– au lieu de 0.–). Corrigé le 06.08.2026 : ces quatre composantes ne comptent que s'il reste au moins un passager non annulé (ou si on inclut les annulés). Seules les primes d'assurance des passagers annulés survivent à une annulation totale, volontairement — elles ne sont pas remboursées.

Le prix passager est un montant stocké, pas recalculé : BookingPassenger.Price est écrit par le front (booking/store.js, cancel-booking/App.vue) et seulement lu côté serveur. Les résas enregistrées dans le tunnel Horizon entre le 04.08.2026 18:14 (merge ad76ff11) et le retour en arrière du 06.08 gardent donc un prix gonflé de la part tant qu'elles ne sont pas rouvertes et réenregistrées — 43 résas back-office identifiées par Σ BookingPassenger.Price == Booking.Amount + Origin = Horizon + UpdatedAt >= 04.08 18:14. Rouvrir/enregistrer est neutre côté compta tant que le total ne bouge pas : BookingService.CreateOrUpdateInvoice ne régénère la facture que si newInvoiceTotal != relatedInvoiceTotal. Si le total bouge, InvoiceService émet un avoir BC + une nouvelle facture avec délettrage/relettrage du paiement.

Le refus « La réservation a été facturée, vous ne pouvez pas la mettre à jour car le montant a changé » ne concerne QUE les résas legacy (BookingService.cs:2700-2734, branche !booking.NewInvoiceProcess). CreateAsync pose NewInvoiceProcess = true sur toute création depuis avril 2025 (:377) : sur le processus courant, la branche else (:2735-2745) appelle _invoiceService.UpdateAsync sans aucun garde-fou. Modifier une résa entièrement payée passe donc silencieusement et déclenche toute la cascade BC. (Cette doc affirmait l'inverse avant août 2026.) Faut-il rétablir un blocage ? Arbitrage Buchard en attente — cf. RAPPORT_FACTURATION.md question 7.

Cible : centraliser les calculs de prix côté Horizon pour avoir une source de vérité unique (Horizon + site + app). Tant que les trois clients calculent chacun de leur côté, toute règle de prix ajoutée dans cost.js seul divergera silencieusement des résas web et mobile — c'est exactement ce qui s'est passé ici.

Rabais (Discount) en % : base de calcul

Règle validée par Buchard (juillet 2026, confirmation Asana) : un rabais en %quelle que soit sa nature (rabais groupe, commission, manuel…) — ne s'applique JAMAIS sur les suppléments globaux (BookingGlobalSupplement). Les suppléments manuels (booking.Supplements, ex. chambre single) restent en revanche dans la base (confirmé par Loïc, juillet 2026 — supplément global = coût refacturé non rabattable, supplément manuel = prix de vente rabattable).

Source de vérité = le front Vue (cost.js). La base d'un rabais en % (ValueOrPercent.Percent) est le sous-total net de l'early booking et du supplément global : percentBase = Math.max(0, getSubTotalCost() − getTotalGlobalSupplement()), où getSubTotalCost() = transport + hébergement + activités + flightCodes + pension(MealPlan) + assurances + suppléments manuels + suppléments globaux − earlyBooking, clampé à 0.

Backend aligné (BookingService, calcul d'amount) : l'early booking est calculé avant le rabais, et le % porte sur discountBase = Math.Max(0, amountWithoutDiscount − earlyBookingPrice − globalSupplement) (BookingService.cs:1935) — utilisé à la fois pour le total et pour l'InvoiceItem « Rabais (…) » (PDF/NAV). Historique : avant juin 2026, le % était calculé sur amountWithoutDiscount brut → écart front/back sur l'early booking (ex. front 529 CHF vs back 562.80 CHF) et rabais groupe appliqué sur le supplément diesel (facture 26115853). Régressions couvertes par CreateAsync_PercentDiscountBase_ShouldBeNetOfEarlyBooking et CreateAsync_PercentDiscountBase_ShouldExcludeGlobalSupplement.

Piège — les templates PDF recalculent le rabais : Confirmation/Balance/Estimate.liquid ne rendent pas la valeur de l'InvoiceItem stocké, ils recalculent TotalWithoutDiscount × discount.Value / 100. Donc le PDF peut diverger de la facture en base si TotalWithoutDiscount n'est pas la bonne base. PdfGeneratorService doit lui passer percentDiscountBase = total (Σ InvoiceItems FullPrice>0) + earlyBookingReduction (= discountBase), pas le total brut. Même logique pour DepositWithoutInsuranceAndDiscount. Symptôme observé : DB -528.96 mais PDF -562.80 (base brute room+pension sans EB).

Offres spéciales « par personne » (SpecialOffer.IsPerPerson) : les bébés ne comptent pas — ajouté juillet 2026

Une SpecialOffer avec IsPerPerson = true produit un rabais de Value × nombre de passagers (et non Value une fois pour la réservation). Le libellé du Discount porte le détail : Offre spéciale: <nom> (<Value> CHF × <n> pax).

Règle : sur un balnéaire sans hébergement (Travel.Type = Seaside et Travel.AccommodationId == null), un bébé ne compte pas dans le n. Motif : le bébé voyage gratuitement (tarif transport 0 pour age < 2, cf. cost.js / Calcul du tier de prix passager), donc lui accorder un rabais reviendrait à rembourser une prestation jamais facturée. Pour tous les autres types de voyage (balnéaire avec hôtel, catalogue, course d'un jour), aucune exclusion : n = nombre de passagers annoncés.

Âge de référence = âge au départ (Occurrence.Start), même référence que tous les paliers tarifaires. Seuil bébé = age < 2 (constante SEASIDE_NO_ACCOMMODATION_BABY_MAX_AGE).

Date de naissance inconnue ⇒ le passager compte. Choix délibéré d'ergonomie : le rabais est poussé à l'étape 1 (sélection du client), alors qu'on ne connaît les dates de naissance qu'à l'étape « Personnes ». Afficher d'emblée le rabais complet évite qu'un vendeur se demande « il y avait une offre, pourquoi je ne la vois pas ? » ; l'offre se réduit ensuite, jamais elle n'apparaît par surprise.

Recalcul — le rabais est recompilé (valeur et libellé) à chaque fois que le nombre de passagers éligibles bouge :

  • changement du nombre de passagers (UPDATE_GENERAL) ;
  • changement d'une date de naissance (watcher passenger.birthdate dans steps/passengers/Passenger.vue → mutation REFRESH_PER_PERSON_DISCOUNTS).

Implémentation : helpers purs getPerPersonOfferPaxCount / isExcludedFromPerPersonOffer dans Vue/src/common/library.js (testés : tests/unit/common/per-person-offer.test.js), consommés par booking/store.js (refreshPerPersonDiscounts, ADD_CLUB_MEMBERSHIP_BOOKING_DISCOUNT) et booking/steps/General.vue.

Le calcul est 100 % front : le back reçoit un Discount déjà valorisé et ne le recalcule pas. La recompilation repose sur les flags Vuex isPerPerson / unitValue, non persistés — à la réouverture d'une réservation enregistrée, le rabais est figé et ne suit plus les changements de passagers.

⚠ La recompilation écrase une valeur saisie à la main dans l'écran « Rabais » de Price.vue si l'utilisateur modifie ensuite un passager ou une date de naissance.

Le NAV_TravelID posé sur chaque ligne d'une facture identifie la commande BC du voyage. Résolution centralisée dans BookingService.ResolveTravelNavNo(travelOccurrence), appelée aux deux endroits qui facturent (CreateOrUpdateInvoice et CreateOrUpdateCancellationInvoice) :

Type de voyageSource du NavNo
BalnéaireSeasideDate.NavNo de la rotation aller (si Occurrence.NavNo est null)
Tout le reste, courses d'un jour comprisesOccurrence.NavNo

Le NavNo par trajet (OneDayTravelDriveOccurrences.NavNo) n'entre PAS dans la facturation (décision 28.07.2026). Entre le 23.07 et le 28.07.2026, ResolveTravelNavNo résolvait le NavNo du trajet desservant le lieu d'embarquement du premier passager (avec fallback occurrence + alerte Sentry sur dossier éclaté) : ce bricolage a été retiré, on ne le réintroduit pas. Son usage réel est la sync Visual Planning (section suivante).

Tests : BookingServiceTest, groupe M (CreateAsync_OneDay_ShouldInvoiceOnTheOccurrenceNavNo, CreateAsync_Catalog_ShouldKeepTheOccurrenceNavNo).

Le NavNo saisi sur un trajet (OneDayTravelDriveOccurrences.NavNo, travel-design > Dates, colonne N° NAV) désigne le VOYAGE VP de ce trajet. Utilisé uniquement pour les Journées Buchard — un départ où chaque trajet est planifié séparément dans VP ; partout ailleurs le champ reste vide et rien ne change.

À chaque sauvegarde de réservation (BookingService : création, mise à jour, annulation) et de tableau de chargement (LoadingTableService), VisualPlanningService.UpdatePassengersTotalByOccurrence(occurrenceId) pousse :

  1. Le compteur du départ sur Occurrence.NavNo — total tous passagers actifs, aller = retour. Il reste poussé même quand des trajets ont leur propre NavNo.
  2. Un compteur par trajet ayant un NavNo, en plus. Un trajet sans NavNo n'est pas poussé (ses passagers ne comptent que dans le compteur du départ).

Les passagers sont toujours comptés par OccurrenceId, jamais par NavNo (juil. 2026) : le NavNo est une saisie manuelle, une faute de frappe ne doit pas déplacer silencieusement un compteur d'un départ à un autre. Il ne sert plus qu'à désigner la cible VP. ⚠ Conséquence : si deux occurrences partagent le même NavNo, le compteur poussé n'est plus leur somme mais celui de l'occurrence sauvegardée. Seule exception, la branche balnéaire de LoadingTableService (UpdatePassengersTotalByNavNo) : elle ne connaît que le SeasideDate.NavNo de la rotation, les occurrences concernées en sont déduites — elle reste donc NavNo-based.

Aller et retour comptés séparément (contrairement au compteur du départ) : un passager peut partir de A et rentrer à B. Aller = passagers OneWay/RoundTrip rattachés au trajet via leur lieu de chargement, retour = passagers Return/RoundTrip via leur lieu de déchargement.

Rattachement passager → trajet (VisualPlanningService.ServingDriveId) : le LoadingTimetableStopId / UnloadingTimetableStopId du passager donne le trajet exactement (TimetableStop.DriveId pour les drives one-shot, sinon Timetable.DriveId) ; à défaut on retombe sur le lieu physique (LoadingStopId) → premier trajet de l'occurrence qui le dessert. ⚠ Un arrêt physique peut être desservi par plusieurs trajets : sans timetable stop, le rattachement est arbitraire (premier trajet par ordre de NavNo). Un passager qu'on ne sait rattacher à aucun trajet n'apparaît dans aucun compteur de trajet — il reste dans le compteur du départ.

Coût : chaque compteur = 3 appels HTTP VP (lookup VOYAGE, lookup COURSE, écriture), non mis en cache. Un départ à N trajets fait donc 3×(N+1) appels par push. Les échecs VP sont avalés par ApiClient (retourne null), une résa n'échoue jamais à cause de VP.

Traitements par lot : UpdateAsync(booking, syncVisualPlanning: false) (juil. 2026). Toute boucle qui met à jour les résas d'un départ doit désactiver le push par résa et pousser une seule fois par occurrence à la fin (HashSet<Guid> occurrencesToSync). Sans ça, facturer 150 résas d'une Journée Buchard à 12 trajets = 5 850 appels HTTP VP qui réécrivent tous la même valeur (Confirmed et Billed sont tous deux dans HasImpactOnOccurrenceOccupancy, les compteurs ne bougent pas pendant la facturation) — de l'ordre de 20 min de requête bloquée. Appliqué à /Occurrences/Billing, /SeasideDates/Billing et /Occurrences/Cancel. La surcharge UpdateAsync(booking) sans flag reste le comportement par défaut (push immédiat) pour les mises à jour unitaires.

Tests : tests/Application/Services/VisualPlanningPassengersTest.cs (GetDrivesPassengersTotals_*).

Calcul d'occupation des occurrences

TravelOccupancyAndBookingStateService.InternalCalculateOccupancy filtre les occurrences avant calcul :

csharp
var filteredOccurrences = includeAllOccurrences
    ? occurrencesList
    : occurrencesList.Where(o =>
        (includeOngoingOccurrence ? o.End > now : o.Start > now) &&
        o.Status == OccurrenceStatus.Published).ToList();

Règle : par défaut, les occurrences passées sont skip-pées (o.Start > now ou o.End > now). Pour calculer aussi l'occupation des archives (page /Occurrences/Index?archive=true), on doit passer includeAllOccurrences: true. Idem pour les statistiques sur l'historique.

Le statut booking-state est aussi posé pour les occurrences passées/annulées :

  • OccurrenceStatus.CancelledBookingState = Cancelled
  • o.Start < nowBookingState = Done

Travel Draft vs DraftInternalBookable vs Published

TravelStatus a trois valeurs :

  • Draft (0) — voyage non finalisé, invisible et non bookable partout.
  • Published (1) — voyage publié, visible et bookable côté public et back-office.
  • DraftInternalBookable (2) — voyage non publié au public mais bookable en interne depuis Horizon back-office. Cas d'usage : ouvrir une date à la vente pour le commercial avant la communication publique.

Comportement attendu par couche :

CoucheDraftDraftInternalBookablePublished
Back-office listing (OccurrenceService.SearchAsync → Occurrences/Index, PassengerSeats, Resources/Occurrences)cachévisiblevisible
Back-office booking (Bookings/CreateUpdate page + BookingService.CreateAsync)bloquéautoriséautorisé
API publique (Areas/Api/TravelController, OccurrenceController)cachécaché (filtré == Published)visible
Dashboards / Reports (DashboardService, ReportService)excluexclu (filtré == Published)inclus

Donc DraftInternalBookable = "comme Draft sauf en back-office".

Implémentation — logique whitelist partout : on liste explicitement les statuts autorisés. Tout nouveau TravelStatus ajouté plus tard reste non-bookable par défaut tant qu'on ne l'ajoute pas explicitement aux 3 filtres back-office.

  • Listing back-office : OccurrenceService.SearchAsync applique la baseline sur le Main Travel dès la construction de la requête : mainTravel.Status == Published || mainTravel.Status == DraftInternalBookable (mainTravel reproduit par OrderByDescending(IsPriority).First() — cf. Occurrence.MainTravelOccurrence). Une occurrence avec un main Draft est cachée même si un "voyage copie" est publié, et inversement.
  • Accès page : Bookings/CreateUpdate.cshtml.cs > OnGet retourne NotFound() si le Travel.Status du main travel n'est ni Published ni DraftInternalBookable. Le check ne s'applique pas à l'édition d'un booking existant (bookingId != Guid.Empty).
  • Service : BookingService.CreateAsync rejette avec ServiceResult<Booking>.Failed(...) si le Travel.Status n'est ni Published ni DraftInternalBookable — filet de sécurité contre les payloads forgés en back-office.
  • API publique de booking : Areas/Api/BookingController.Create rejette avec BadRequest si occurrence.MainTravelOccurrence.Travel.Status != TravelStatus.Published (strict — DraftInternalBookable est explicitement bloqué côté API publique car réservé au back-office). Check en tête de méthode, avant Validate et CreateAsync.
  • Public / stats : les requêtes == TravelStatus.Published strictes dans TravelController, OccurrenceController, DashboardService, ReportService excluent automatiquement DraftInternalBookable. Ne PAS élargir ces filtres sans contrepartie métier explicite.

TVA zone rose : split forfaitaire 25 CHF par passager

Un voyage zone rose (OccurrenceCost.VatType == VatType.PinkArea) est un voyage transfrontalier : départ Suisse + portion à l'étranger. La part suisse du voyage est fiscalement matérialisée par un forfait de 25 CHF par passager soumis à la TVA suisse normale ; le reste du prix est exonéré.

Mécanisme côté facture (Confirmation / Balance, BookingService.UpdateAsync autour de :2008) : au lieu d'une seule ligne "Transport et hébergement" en groupe TVA Z-ROSE, deux lignes sont émises vers BC :

LigneAccount G/LNAV_VAT_Prod_Posting_GroupMontant
Forfait TVA3010vatRate.Group (TVA suisse normale)25 × nb_passagers_non_annulés, stocké HT (/ 1 + taux)
Transport et hébergement3011Z-ROSE (exonéré)reste du prix, TTC, diminué du forfait TTC (25 plein)

Le forfait est proratisé par Travel.DepositPercentage selon le statut (Confirmed = acompte, Billed = solde). Le total client est inchangé : c'est un split interne pour la TVA BC, pas un ajout de prix → la ligne "Forfait TVA" n'est pas affichée sur le PDF client (volontairement masquée dans Confirmation.liquid / Balance.liquid).

HT sur la ligne, TTC sur le total — piège majeur. Le forfait de 25 CHF est un montant TTC : on retire 25 de la ligne transport mais on écrit la base HT sur la ligne "Forfait TVA", car c'est BC qui rajoute la TVA suisse pour retomber sur 25. Conséquence : la somme brute des lignes est inférieure au dû réel de la TVA du forfait (à 8.1 % : 25 - 25/1.081 = 1.87 par passager). Cette TVA est donc réintégrée explicitement dans Invoice.Total (BookingService : pinkAreaForfaitVat / pinkAreaForfaitVatPrice). Ne jamais recalculer Invoice.Total comme un simple Sum(InvoiceItems.Price) sur un dossier zone rose : Invoice.Total est le montant dû par le client (= celui de BC), les lignes sont une décomposition fiscale.

Régression corrigée le 29.07.2026 : Invoice.Total valait la somme nette des lignes → les paiements en ligne (OnlinePaymentService, enregistrés à hauteur de invoice.Total) et les QR-factures (PdfGeneratorService) étaient courts de ~1.87 CHF/passager, laissant un solde ouvert dans BC alors que le client avait bien payé le montant plein.

Toute somme de lignes qui représente « ce que le client doit » doit réintégrer pinkAreaForfaitVat — la ligne forfait étant stockée HT, une somme brute est systématiquement courte de ~1.87 CHF/passager. Deuxième occurrence de ce piège, corrigée le 10.08.2026 : le plafonnement du bon cadeau (invoiceSumBeforeBalanceCheck, BookingService) sommait les lignes sans la TVA du forfait, donc l'« excédent » du bon était surévalué d'autant. Sur la résa 26125404 (bon 38871, 224 CHF, total réel 223) le système n'a consommé que 219.25 du bon, laissant 4.75 dessus au lieu de 1 et un solde de 3.75 ouvert dans BC. Test de régression : BookingServiceTest.CreateAsync_PinkArea_GiftShouldBeConsumedOnTheGrossAmount. Reste une incohérence connue entre les deux calculs : le total facture exclut les lignes AddedInNavInvoiceOnly (remises visibles au solde uniquement), pas le plafonnement du bon — à aligner si un cas remise + bon cadeau se présente.

Annulation (BookingService.CancelAsync) : même règle, appliquée aux lignes de frais retenus par Buchard (pas aux lignes "assurance externe" qui partent sur invoiceInsuranceItems). Le forfait global = 25 × nb_passagers_contributifs_buchard est déduit proportionnellement (TTC) de chaque ligne "Annulation pour: X" puis ajouté comme ligne séparée "Forfait TVA" en base HT (account 3011, TVA suisse), la TVA étant elle aussi réintégrée dans le total de l'avoir/facture. S'applique aussi bien à une annulation complète (booking Canceled) qu'à une annulation partielle (certains passagers Cancelled, booking encore actif). Garde-fou : pas de split si le total des frais retenus est ≤ au forfait (évite des montants négatifs sur petits frais) — donc pas de forfait sur un avoir (total de lignes négatif).

Cas VatType.Swiss : voyage 100% suisse → TVA normale sur tout, aucun split. Cas VatType.OutsidePinkArea : voyage 100% étranger → groupe ZERO (exonéré), aucun forfait.

TVA non renseignée : un départ vendable doit toujours porter un VatType valide

OccurrenceCost.VatType est un enum non nullable à 3 valeurs (Swiss=0, PinkArea=1, OutsidePinkArea=2). Le « - » affiché dans l'écran voyage (étape Synthèse) n'est pas une 4e valeur métier : c'est -1, une valeur hors enum, écrite par défaut à la création d'une occurrence (travel-design/store.js, emptyCost.vatType = -1). Elle existe pour forcer une saisie explicite — sans elle on ne distinguerait pas « pas encore choisi » de « TVA suisse ».

Un -1 qui atteint la facturation coûte de l'argent. Les switch (vatType) de BookingService (CreateOrUpdateInvoice, CreateOrUpdateCancellationInvoice) n'avaient pas de default : aucun case ne matchant, les valeurs d'initialisation restaient en place — compte 3010, rabais 3902/3911, groupe TVA NATIONAL. Autrement dit un départ sans TVA était facturé exactement comme une TVA suisse, sans erreur ni alerte. Constaté le 10.08.2026 sur la Patagonie 21.03.2027 et la Croisière Nouvel An à Venise 28.12.2026 : 9 factures, 55'539 CHF facturés, ~4'162 CHF de TVA indue à 8.1 %.

Garde-fous en place depuis (les trois sont nécessaires, aucun ne remplace les autres) :

  1. default: dans les deux switch de BookingServiceServiceResult.Failed explicite au lieu du repli silencieux en TVA suisse. C'est le seul filet qui garantit qu'un -1 ne coûte plus rien, même s'il réapparaît en base.
  2. TravelService.ValidateOccurrencesVatType (appelée par CreateAsync et UpdateAsync) → refuse Published et DraftInternalBookable si une occurrence a un VatType hors enum, en listant les départs fautifs. Toléré en Draft : un voyage en cours de construction doit rester enregistrable. Un OccurrenceCost null signifie « graphe non chargé par l'appelant », pas « TVA absente » → jamais bloquant. Doublé côté BookingService.CreateAsync, qui lit le VatType sur booking.TravelOccurrence.Occurrence.OccurrenceCost — d'où l'Include(o => o.OccurrenceCost) obligatoire dans les deux requêtes qui alimentent ce graphe : BookingService.Map (occurrenceQuery, celle qui compte, EF rattachant ensuite le graphe par fixup) et le ??= de CreateAsync. Retirer l'un des deux rend la garde silencieusement inopérante.
  3. Contrôle front à chaque enregistrement d'un voyage vendable (Footer.vuevalidateStepSummary). Avant, ce contrôle était positionnel : il ne se déclenchait que si l'utilisateur cliquait Enregistrer alors qu'il était sur l'étape 7, donc enregistrer depuis les étapes 2 à 6 laissait passer le voyage sans TVA. C'est l'origine des cas de prod.

Tests de régression : TravelServiceTest.CreateAsync_Should*VatType*, BookingServiceTest.CreateAsync_ShouldRefuseBooking_WhenOccurrenceHasNoVatType.

OccurrenceCost n'a aucun champ d'audit (ni IAuditableEntity ni ITimeStamped, et Occurrence non plus) : impossible de savoir qui a changé une TVA ni quand. La question récurrente de Buchard « la paramétrisation disparaît toute seule » reste donc indémontrable dans les deux sens. Seul constat objectif possible aujourd'hui : sur la copie de prod du 06.08.2026, aucune occurrence n'a jamais produit de factures sous deux Invoice.VatGroup différents, ce qui écarte le scénario d'une TVA qui saute en cours de commercialisation.

Versioning des factures (Invoice.Enabled)

Chaque réémission d'une facture crée une nouvelle entité Invoice ; l'ancienne version est marquée Enabled = false. La facture courante d'un booking se récupère toujours par :

csharp
booking.Invoices.FirstOrDefault(i => i.Type == X && i.Enabled, new Invoice { … })

Le helper Booking.GetCurrentInvoice(InvoiceType) encapsule ce pattern.

Soft delete via interceptor

AuditableAndSoftDeleteInterceptor (branché dans Startup.cs) :

  • Intercepte les Delete EF et les transforme en update IsDeleted = true.
  • Renseigne CreatedAt/UpdatedAt/CreatedBy/UpdatedBy via IHttpContextAccessor.
  • Les requêtes EF excluent automatiquement les entités IsDeleted = true (filter global).

Conséquence : les services consomment EF directement (pas de Repository) en se reposant sur ce filter. Pour les requêtes qui doivent inclure les soft-deleted, utiliser IgnoreQueryFilters() explicitement.

Bons cadeaux : envoi email

  • Émission via site web/mobile → email envoyé automatiquement au bénéficiaire avec le bon.
  • Émission via Horizon back-officepas d'envoi email auto. L'agent Buchard imprime/envoie manuellement.
  • Une option IncludeEnvelope permet de matérialiser l'envoi physique.
  • Détail technique du canal d'envoi, redirection hors-prod, etc. dans .claude/modules/mailing.md.

Compte d'encaissement cash par bureau

Buchard a plusieurs bureaux physiques (Leytron, Ecuvillens, Aubonne, Chaux-de-Fonds, Neuchâtel). Chaque cash payment dans PaymentType.Cash<Ville> est rattaché au compte comptable du bureau correspondant. Le commercial sélectionne sa ville à la réservation. Aucune autre logique métier ne change selon le bureau.

PaymentType.RekaCheck : validé sans paiement enregistré — c'est voulu

OnlinePaymentService.IsPaymentApproved répond « payé » inconditionnellement pour RekaCheck (branches identiques pour bookings ligne ~662, bons cadeaux ~800, adhésions Club ~863), sans jamais interroger Saferpay ni CembraPay. Ce n'est pas un bug, ne pas « corriger ».

Le chèque Reka est un titre physique : il ne transite par aucun prestataire en ligne. Le flux réel est donc :

  1. La réservation est validée et la facture créée, mais aucun Payment n'est enregistré (b.PaymentType != RekaCheck conditionne l'insertion, ligne ~147).
  2. Le client règle hors application (envoi du chèque).
  3. L'équipe Buchard ajoute le paiement manuellement en back-office à réception.
  4. Si le paiement n'est pas arrivé au moment de la facturation, une facture du montant total est envoyée au client.

Autrement dit une résa RekaCheck sans paiement est une créance suivie, pas une perte. Conséquence à connaître : ce type reste sélectionnable depuis l'API publique, et une garde côté API a été retirée après vérification métier (audit sécurité du 28.07.2026, point C-2 — risque accepté et documenté dans .claude/audits/2026-07-28-security-audit.md).

Champs client modifiables par le client lui-même — ajouté juillet 2026

PUT /api/customers (self-service) n'applique que les champs de profil, via une liste blanche explicite (CustomerController.ApplySelfServiceFields) : civilité, genre, nom, prénom, société, adresse(s), date de naissance, NPA, ville, état, pays, téléphones, contact d'urgence, newsletter.

Tout le reste est propriété du serveur et ignoré s'il est envoyé : NavNo / NavName / ValidOverrideNav (le couple NavNo + ValidOverrideNav permettait d'écraser la fiche Business Central d'un tiers et de lire ses données en retour), AgencyCode, Discount / DiscountType, ClubMembership*, PaymentSuccessful, BadPayer, TravelBan, Enabled, IsGuest, EMail, Password, PasswordResetToken, Id.

La liste blanche est le point important : un champ ajouté au DTO n'est pas modifiable par défaut. À l'inscription (POST /api/customers, anonyme) le périmètre est plus large — Password, EMail, PaymentType et ClubMembership* restent nécessaires à l'adhésion Club payante — mais ResetProtectedFields neutralise le rattachement ERP, les conditions commerciales, les indicateurs de risque et l'état de paiement.

Assurance annulation : jamais pour un client de type agence — ajouté juillet 2026

Quand le client de la réservation est CustomerType.Agency, aucune assurance annulation ne peut être proposée ni enregistrée (l'agence porte elle-même la couverture de ses clients finaux). Blocage appliqué en profondeur, aux 3 niveaux :

  1. EndpointBookings/CreateUpdate.OnGetInsurances retourne une liste vide. Le customerId est passé en query par le front (nécessaire tant que la réservation n'est pas encore persistée) ; à défaut, le type est résolu depuis le client du bookingId.
  2. Persistance (garde-fou réel)BookingService.Map calcule isAgencyCustomer depuis entity.CustomerId et ignore les Insurances transmises dans le DTO. Tient même si l'UI est contournée ou si le type du client change après coup.
  3. UI Vue — l'étape « Assurances » affiche un message explicatif au lieu du tableau de sélection (getter isAgencyCustomer du store booking, alimenté par CustomerDto.Type). L'appel à l'endpoint est court-circuité.

Bascule de client en cours de saisie : sélectionner un client particulier, cocher une assurance, puis basculer sur un client agence ⇒ les assurances déjà cochées sont retirées (mutation CLEAR_PASSENGER_INSURANCES, commitée depuis General.vue après résolution du client).

Non concerné — historique préservé : la règle ne bloque que la saisie de nouvelles assurances. Les réservations agence existantes qui portent déjà une assurance en base gardent leurs lignes BookingPassengerInsurance : Bookings/Cancel.OnGetCancellationCatalog est volontairement inchangé (l'écran d'annulation continue de relire et proposer ces assurances historiques), et CLEAR_PASSENGER_INSURANCES n'est jamais commitée au chargement d'une réservation existante — uniquement sur un changement de client initié par l'utilisateur.

Programme de fidélité : gelé pour les clients de type agence — ajouté août 2026

Seuls les clients finaux participent au programme de fidélité. Quand le client est CustomerType.Agency, aucun mouvement de points n'est possible : ni gain (achat de voyage, adhésion Club, téléchargement de l'app, ajustement manuel), ni consommation, ni remboursement. Le solde affiché est 0.

Le blocage est centralisé dans LoyaltyService (helper IsAgencyAsync), au même endroit que le garde-fou de la date de lancement du programme (cf. modules/customer-membership.md) — donc aucun point d'entrée (facturation, API mobile, back-office, worker) ne peut le contourner :

MéthodeComportement pour une agence
GetAvailablePointsAsync / GetPendingPointsAsync / GetAvailableCreditsAsync0 / liste vide
GetConsumableBookingPointsAsync0pas de ligne « Points fidélité » sur la facture, pas de rabais
CreditBookingPurchaseAsync, CreditClubMembership, CreditFirstMobileAppInstall, CreditManualAdjustmentAsyncFailed
DebitBookingPointsAsync, RefundBookingPointsAsyncSucceed([]) (no-op)

Le garde lit le type via IgnoreQueryFilters() : une agence soft-deleted reste gelée. Un customerId inconnu n'est pas une agence (les gardes existants traitent ce cas).

Stock antérieur purgé : 12 agences avaient accumulé 2 211 points avant le gel (aucune n'en avait jamais consommé — les seuls débits étaient des BookingCancellation). Le script one-shot tools/sql/2026-08_freeze-agency-loyalty-points.sql insère un débit ManualDebitAdjustment tracé par crédit et remet PointsRemaining à 0. La table étant append-only, l'historique reste visible dans l'onglet « Points de fidelité » de la fiche, qui affiche en plus un bandeau d'explication pour les agences.

Non couvert : l'affichage « vous gagnerez X points » du site/app (TravelController.CalculateWinningPoints, LoyaltyController.winning-points) est un calcul sans client — il continue d'annoncer des points à quiconque consulte un voyage, agence comprise.

Annulation totale : la prime d'assurance annulation est remboursée UNIQUEMENT s'il n'y a pas de frais

Sur une annulation totale, le remboursement se fait par un credit memo de la facture d'origine. L'inclusion (ou non) de la ligne « Assurance … » (la prime d'assurance annulation, compte 3021) dans cet avoir dépend de la présence de frais :

CastotalFeesPrime d'assurance annulation dans la note de crédit
Annulation sans frais pour le client0Incluse → prime remboursée au client
Annulation avec frais à la charge du client> 0Exclue → prime gardée par Buchard
  • Câblage : BookingService.CreateOrUpdateCancellationInvoice appelle _navCreditMemoService.CreateAsync(invoiceToCancel, cancelInsurances: totalFees == 0) (BookingService.cs:2957, et :2941-2944 pour l'ancien process). Côté service, NavCreditMemoService.cs:123 : if (!cancelInsurances && Description.Contains("Assurance")) removeLine = false; — quand cancelInsurances == false, la ligne d'assurance n'est pas reprise dans l'avoir.
  • totalFees ne compte QUE les frais réellement à la charge du client — corrigé juillet 2026. L'accumulateur applique le même test que celui qui aiguille la ligne de frais vers sa facture (settingInsurance == null || settingInsurance.ExternalInsurance) : les frais couverts par une assurance interne partent sur la facture CancellationInsurance (compte 3022, CustomerId nul) et ne doivent donc jamais entrer dans totalFees. Le vocabulaire de l'écran d'annulation est la référence : « Frais à la charge du client » = assurances externes, « Frais à la charge d'AXA » = assurances internes (cancel-booking/App.vue, getPassengerFees).
  • Symptôme avant correction (résa 15263, mai 2026) : annulation couverte par l'assurance « Voyages balnéaires » (interne). La cliente ne devait rien, mais les frais facturés à l'assureur remontaient dans totalFees → la ligne « Assurance » de 125 CHF était exclue de l'avoir → note de crédit de 2135 au lieu de 2260, et un solde ouvert de 125 CHF sur une cliente qui ne devait rien.
  • Attention — comportement contre-intuitif : on pourrait croire l'inverse (« sur une annulation, on retient toujours la prime »). C'est faux : sans frais, la note de crédit rend le forfait complet, assurance comprise. Confirmé par test (juil. 2026).
  • Prérequis : la ligne « Assurance - … » doit être sur la facture d'origine. En NewInvoiceProcess c'est toujours le cas (BookingService.cs:2231) ; pour de vieilles résas hors NewInvoiceProcess, l'assurance a pu ne jamais être facturée → rien à créditer.
  • Fragilité : la détection repose sur Description.Contains("Assurance") ; un changement de libellé de cette ligne casserait la règle silencieusement.
  • Ne pas confondre avec la couverture des frais par l'assureur (compte 3022, facture CancellationInsurance émise vers l'assurance) : c'est un mécanisme distinct de la prime remboursée au client ci-dessus.

Conditions d'assurance annulation : le nombre de jours avant départ se compte en jours entiers

Les tranches d'une SettingInsuranceCondition (MinDays/MaxDays) sont exprimées en jours entiers et se suivent sans recouvrement — typiquement 0-10 → 100 %, 11-30 → 80 %, 31-45 → 50 %. Le décompte doit donc se faire sur des dates ramenées à minuit, des deux côtés :

  • Front (cancel-booking/App.vue:calculateFee) : cancellationDate.setHours(0,0,0,0) puis Math.floor((start - cancellationDate) / 86400000).
  • Back (BookingService.CreateOrUpdateCancellationInvoice) : (Occurrence.Start.Date - cancellationDate.Date).TotalDayscorrigé juillet 2026, il utilisait auparavant les timestamps bruts.

Le piège corrigé : avec l'heure, une annulation saisie à 14h13 pour un départ 31 jours plus tard donne 30.41 jours. Aucune tranche ne matche (30.41 > MaxDays 30 et < MinDays 31), condition vaut null et percent retombe à 0 — silencieusement. Toute date d'annulation dont l'heure n'est pas minuit tombait dans ce trou. Aujourd'hui l'impact se limite au libellé de la ligne de facture, qui perdait son « : 50 % » ; mais tout usage futur de ce pourcentage aurait hérité du même zéro.

Check-in/check-out balnéaire : date de séjour hôtel = calcul live, jamais le snapshot figé — ajouté juillet 2026

Pour un séjour balnéaire, la date d'arrivée/départ hôtel se calcule en direct depuis l'occurrence et le « nuit dans le bus » effectif : arrivée = Occurrence.Start (+1 si première nuit dans le bus), départ = Occurrence.End (−1 si dernière nuit dans le bus), où l'effectif = override SeasideDate sinon flag Travel (helper SeasideStay). C'est la seule source de vérité — voucher hôtel, listes de chambres, calcul de durée.

Ne jamais se fier à BookingAccommodation.CheckIn/CheckOut pour l'affichage : c'est un instantané figé écrit une seule fois par le front à la saisie, jamais recalculé si la rotation change ensuite. Deux dossiers sur la même rotation peuvent donc porter des snapshots différents (celui réservé avant une modif de rotation garde les anciennes dates). Les listes de chambres lisaient ce snapshot → dates périmées communiquées aux hôtels (risque « client sans chambre à l'arrivée ») ; corrigé en juillet 2026 en les basculant sur le calcul live (SeasideDateService.GetHotelStaysAsync, batch 1 requête). Détail technique et cas réel : modules/seaside.md.

Adhésion Club Buchard

  • Adhésion annuelle payante (Single ou Couple).
  • Donne accès à des SpecialOffer filtrées et à des discounts auto-appliqués (addClubMembershipBookingDiscount côté Vuex).
  • Renouvellement annuel → facture InvoiceCategory.MembershipRenew.
  • Customer.PaymentType stocke ici (ad-hoc) le moyen de paiement utilisé pour l'adhésion en ligne — ne pas interpréter comme une "préférence client générale".

Points de fidélité

Détail complet : .claude/docs/modules/loyalty-points.md. Règles non triviales :

  • Barème : gain 0,1 pt / CHF dépensé (sur Booking.FinalAmount, arrondi au supérieur, ×2 si origine Mobile) ; consommation 10 pts = 1 CHF.
  • Consommation tout-ou-rien : le client ne choisit pas le nombre de points. Le flag API BookingDto.General.ConsumeLoyaltyPoints consomme tous les points disponibles, plafonnés à ceil(montant × 10), en FIFO par date d'expiration.
  • Validité différée : un crédit d'achat de voyage n'est utilisable qu'à partir de la fin du voyage (LoyaltyTransaction.ValidFrom = date de retour) ; tous les crédits expirent après 24 mois (job Worker quotidien).
  • Déclenchement à la facture, pas au booking : le débit des points utilisés et le crédit des points gagnés se font à la création de la facture (InvoiceService), pas au POST de la réservation. Idempotent pour éviter le double-crédit acompte/solde.
  • Remboursement à l'annulation : retire les points gagnés sur la résa (BookingCancellation) et re-crédite les points dépensés (BookingRefund), avec expiration d'origine ou aujourd'hui + 6 mois si déjà périmée.
  • Effet facture : ligne négative « Points fidélité (N pts) » au compte NAV 3916 (TVA suisse) / 3917 (hors-Suisse), répartie acompte/solde via Travel.DepositPercentage.

Sync Visual Planning

  • Sync lecture : services VisualPlanningService.GetTravelDriversAndHostsByOccurrence, GetLoadingDriversByOccurrence, etc. récupèrent les assignments depuis VP via API HTTP, en se basant sur Occurrence.NavNo (et OrderResourcesByDate selon le tableau).
  • Sync écriture : Horizon écrit dans VP uniquement le nombre de passagers par date (events) aller/retour. Aucune autre donnée n'est poussée.
  • Sync daily : Worker.DoDailyJob > VisualPlanningService.SyncHostsAndDrivers rafraîchit le référentiel des chauffeurs/hôtesses (mapping VisualPlanningIdResource).

Auto-discount "Rabais groupe"

Dans Vue booking store.js (UPDATE_GENERAL → numberOfPassengers), un discount automatique est ajouté quand le nombre de passagers atteint un seuil et que le voyage n'est PAS un OneDay :

  • 8 ou 9 passagers → 3 % de remise.
  • 10 passagers et + → 5 % de remise.
  • Le discount est posé sur le booking et retiré si le nombre repasse en dessous.

Base de calcul des rabais en % (rabais groupe et tout autre discount Percent) : le pourcentage s'applique sur une base qui exclut le supplément global (BookingGlobalSupplement) — règle validée par Buchard en juillet 2026 (cf. section « Rabais en % : base de calcul ») — et exclut aussi la réduction Early Booking (base nette d'early booking). Autrement dit discountBase = transport + hébergement + activités + suppléments per-booking − early booking, sans le supplément global. Cohérent front/serveur : ⚠️ Ce calcul est dupliqué à 5 endroits — les garder synchronisés (un rabais qui « mord » encore sur le supplément = une de ces copies a été oubliée) :

  1. cost.js > getTotalDiscount (Total récap) → percentBase = getSubTotalCost() − getTotalGlobalSupplement().
  2. cost.js > getDeposit (Acompte récap) → même base.
  3. Price.vue > regularDiscountsTotal (ligne « Rabais » affichée dans le récap prix back-office) → même base. ⚠ copie indépendante de getTotalDiscount, facile à oublier.
  4. BookingService.CreateOrUpdateInvoice (montant booking + ligne de facture NAV) → discountBase = max(0, amountWithoutDiscount − earlyBooking − globalSupplement).
  5. PdfGeneratorService (percentDiscountBase, base des rabais % + acompte dans les templates Estimate/Confirmation/Balance.liquid) → total + earlyBookingReduction − globalSupplementTotal.
  • Les rabais en valeur fixe (Value) ne sont pas concernés (montant indépendant de la base).
  • Régression serveur couverte par BookingServiceTest.CreateAsync_PercentDiscountBase_ShouldExcludeGlobalSupplement.

Mécanisme rare : "voyage copie" via TravelOccurrence.IsPriority

Pour des cas très spécifiques (un car VIP, une excursion supplémentaire offerte), un Travel "copie" est créé et associé à une Occurrence existante. Ce Travel copie est invisible sauf en édition via la date. Une alerte UI signale son existence. À éviter en tant que dev — n'en créer que sur demande explicite, et préférer toute autre solution.

Jobs périodiques du Worker

Worker/BackgroundWorker.cs :

  • 1 minute : IOnlinePaymentService.CheckPendingPayments — interroge Saferpay/CembraPay pour les bookings PendingWebOrMobile. Si paiement OK → bascule en Confirmed (ou Billed) + envoi email.
  • 5 minutes : WorkerService.Get(Workers.CustomersAndBooking) — actuellement commenté (sync Globe désactivée définitivement).
  • Daily : VisualPlanningService.SyncHostsAndDrivers + CustomerService.CleanGuestCustomers.
  • Weekly : à expliciter.

Commentaires : modération et réponses Buchard

  • Création client (POST /api/travels/{id}/comments) : le serveur force IsApproved = false et CustomerId = id du customer authentifié, quelle que soit la payload. Toute moyenne d'étoiles (Travel.CommentsAverageRating) ne compte que les approuvés.
  • Réponse Buchard : posée via Pages/Comments action « Répondre ». Crée un TravelComment enfant avec ParentCommentId non null, Author = "Buchard Voyages" (constante côté entité), IsApproved = true automatique (l'admin est lui-même le modérateur), Rating = null, CustomerId = null, TravelId hérité du parent.
  • Un seul niveau : AddAdminReply refuse si le parent a déjà un ParentCommentId (pas de réponse à une réponse).
  • Un reply par parent : AddAdminReply refuse si le parent a déjà une réponse — pas d'historique multi-tour.
  • Cascade delete : supprimer un commentaire racine supprime aussi sa réponse Buchard. La FK self-ref est Restrict (SQL Server refuse cascade sur self-ref) → cascade géré explicitement dans TravelService.DeleteComment / BulkDeleteComments.
  • Listing modération : SearchComments ne renvoie que les commentaires racine (ParentCommentId == null). Les réponses ne sont jamais des lignes du DataTable ; elles sont projetées en plat (ReplyId/ReplyComment/ReplyCreatedAt) dans TravelCommentSearchResultDto et affichées via la colonne « Réponse Buchard » + modale « Voir ».
  • Badge "en attente" : GetPendingCommentsCount exclut ParentCommentId != null par défense — si jamais une réponse se retrouvait IsApproved=false, elle ne polluerait pas le badge.
  • API publique : les réponses ne sont PAS exposées dans TravelCommentDto ni dans GET /api/travels/{id} (back-office only à ce stade). Si on les expose plus tard, penser à filtrer à l'aller pour ne pas casser les calculs d'average rating.

Locale fr-CH forcée

ApplicationModule.ConfigureServices impose la culture fr (ou en en option mais format identique) :

  • ShortDatePattern = "dd.MM.yyyy"
  • NumberDecimalSeparator = "."

Conséquence : ne JAMAIS utiliser DateTime.Parse ou decimal.Parse sans préciser la culture quand la donnée vient d'un format ISO ou d'une API étrangère.

2FA back-office obligatoire après période de grâce

Tout utilisateur back-office natif (login email/mot de passe) doit activer l'authentification à deux facteurs TOTP. Règles :

  • Date butoir fixe en config : TwoFactor:SetupDeadline (format yyyy-MM-dd, lue dans appsettings, interprétée en UTC). Identique pour tous, pas de délai par-utilisateur. Si la clé est vide/absente → feature dormante (aucun forçage ni rappel).
  • Avant la date : accès normal, bannière de rappel dans _Layout. À partir de la date (>=) sans 2FA : redirection forcée vers /Identity/Account/Manage/EnableAuthenticator, aucune autre page accessible (même mécanique que RedirectPendingUserMiddleware).
  • Tous les rôles sont concernés (Sales, Logistics, Production, Direction, Administrator).
  • Exemption : comptes Microsoft SSO (User.IsMicrosoftAccount) — le MFA est déjà géré par Azure AD. Centralisé dans TwoFactorPolicyService.IsSubjectToTwoFactor.
  • Récupération : 10 codes de récupération à usage unique régénérés à chaque activation, plus un reset admin (IUserService.ResetTwoFactorAsync via la modale d'édition utilisateur) qui désactive le 2FA et réinitialise la clé. La date butoir fixe s'applique pareil ensuite : si elle est dépassée, l'utilisateur est reforcé à configurer au prochain login.
  • Source de vérité de la décision : TwoFactorPolicyService.Evaluate(user, nowUtc)Allowed | BeforeDeadline | SetupRequired. Réutilisé par le middleware et la bannière. La date est exposée par ITwoFactorPolicyService.SetupDeadline. La clé authenticator et les recovery codes sont stockés par Identity dans AspNetUserTokens (pas de table custom ; seule colonne ajoutée : User.TwoFactorEnabledAt, audit).

Contributors

No contributors

Changelog

No recent changes