Règles de gestion (mobile)
Règles métier non-évidentes matérialisées dans le code mobile, avec leur « pourquoi ». Pour les règles backend canoniques, voir
/docs/spektrum/buchard/horizon/domain/business-rules.mdvia MCP.
Catalogue & affichage
Recommandations Home tournantes
publicApi.travels.getRandom a un TTL cache 1 minute au lieu des 5 min par défaut.
- Pourquoi : on veut que les recommandations changent réellement entre deux ouvertures rapprochées de l'app.
- Où :
services/api.ts(publicApi.travels.getRandom).
Liste des pays mise en cache 30 min
publicApi.countries.list a un TTL 30 min.
- Pourquoi : référentiel quasi-statique, on évite des allers-retours réseau coûteux sur saisie d'adresse.
Accommodation optimizer (NP-Hard greedy)
parseDaysAccommodations(travel) regroupe les jours d'un voyage par hôtel via un algo glouton de Set Cover.
- Pourquoi : éviter de répéter « Hôtel A » trois fois si le client y passe les jours 1-3 et 6-7.
- Comment l'appliquer : ne pas chercher à optimiser, l'approximation suffit (< 30 jours / < 10 hôtels). Ignore les hôtels externes (
internal: false). Court-circuit pour Seaside : un seul hôtel couvre tous les jours. - Code :
utils/accommodation-optimizer.ts.
Wizard de réservation
Filtrage des assurances : prix ET date
getInsurancesForPricePerPerson(price) filtre les assurances par deux critères combinés :
- Le prix par personne doit être dans
[priceStart, priceEnd] - La date de l'occurrence (
selectedTO.start) doit être dans[startDate, endDate]de l'assurance
Puis tri décroissant par value.
- Pourquoi : les contrats d'assurance négociés par Buchard couvrent des tranches de prix et des périodes saisonnières précises.
- Base de prix = la chambre + option réellement réservée :
getInsurancesForRoomKey(roomKey)dérive le prix par personne de la clé composite(roomTypeId, roomId, optionId)de la réservation —getPriceForRoom(roomId, optionId) / room.people— pas du premier room/option du room type. (Bug corrigé, vu sur sorrente-capri : le raccourcirooms[0]/options[0]prenait une option non tarifée sur l'occurrence → prix 0 → toutes les tranches enpriceStart ≥ 1exclues → dropdown assurance invisible.) - Quirks connus (non corrigés, assumés) : (1) un
startDate/endDateabsent retombe surnew Date()(maintenant) — unendDatemanquant exclut donc silencieusement l'assurance pour toute occurrence future ; (2) le site web inclut toujours l'assurancevalue 0(|| i.value === 0) hors tranche — le mobile la filtre comme les autres (choix produit, tranché juillet 2026). - Code :
stores/bookingConstructor.tsgetInsurancesForPricePerPerson,getInsurancesForRoomKey.
Suppléments globaux : calcul répliqué du site web (et de Horizon)
Chaque Occurrence/OccurrenceSummary porte une liste globalSupplements (taxes/suppléments — id, description, amountPerPersonPerDay, isFlatRatePerPerson). computeGlobalSupplements(travel, occurrence, passengers) (utils/global-supplements.ts) calcule, pour chaque supplément :
nbDays=3fixe sitravel.type === Seaside(valeur métier, pas dérivée de la durée réelle du séjour), sinonround((occurrence.end - occurrence.start) / 1 jour) + 1, plancher1.nbPax= nombre de passagers aveccancelled !== true.amount=gs.isFlatRatePerPerson ? gs.amountPerPersonPerDay * nbPax : gs.amountPerPersonPerDay * nbPax * nbDays. ⚠️ Piège de nommage : même quandisFlatRatePerPerson = true, le calcul utilise bienamountPerPersonPerDaymais sans multiplier par les jours — le champ est mal nommé côté backend, la formule ci-dessus est la référence.
- Pourquoi : Horizon recalcule ce montant de façon autoritaire à la création du booking, mais ne l'applique pas si le client ne l'envoie pas dans le payload — sans ce calcul côté mobile, le total affiché et l'acompte facturé seraient sous-évalués (écart déjà constaté avec le total du site web, qui fait ce même calcul côté client dans
recomputeGlobalSupplements). - Dans le total, remisable et proraté (aligné backend) : le montant des suppléments entre dans le total (
fullPrice/subTotal) comme dans Horizon (amount += globalSupplementavant toute remise,BookingService.CreateOrUpdateInvoice) : il est donc proraté par le taux d'acompte, absorbable par un bon cadeau et compté dans le plafond des points. Seule exception : la base d'un rabais % (code promo futur, early booking exclu aussi) :discountBase = max(0, brut − earlyBooking − suppléments). (Remplace l'ancienne règle mobile « non remisable / non proraté » — supprimée : elle faisait diverger le montant débité de la facture Horizon dès qu'un acompte, un gros bon ou des points entraient en jeu.) - Comment l'appliquer :
useBCPricing(composables/booking-constructor/pricing.ts) expose le détail viaglobalSupplements(tableauBookingGlobalSupplement[],{ globalSupplementId, description, amount }) ; la somme entre danscomputeBookingPricing(utils/booking-pricing.ts) viaglobalSupplementsAmount.bookingConstructor.ts(finalBooking) l'assigne directement àoutput.booking.globalSupplements(toujours, y compris[]— pas d'assignation conditionnelle comme pourgifts).TBPayment_Summary.vueaffiche le « Prix total TVA incl. » suppléments inclus, puis une ligne informative « dont {description} » par supplément. isFlatRatePerPersonest absent du.d.tsde@spektrum/horizon-types@1.9.0(dernière version publiée) bien que présent sur le wire — augmenté localement viaGlobalSupplementExt(types/extensions.ts), même pattern queconsumeLoyaltyPoints. À supprimer si une future version du package l'expose nativement.- Code :
utils/global-supplements.ts,composables/booking-constructor/pricing.ts,stores/bookingConstructor.ts,components/travel-booking/payment/TBPayment_Summary.vue.
Chaîne de calcul du prix au paiement — répliquée de Horizon
Le montant envoyé à Saferpay est calculé côté client et n'est jamais recalculé avant l'encaissement : MobileBookingApiController.InitializeBooking (repo buchard-website) transmet booking.AmountToPay tel quel à Saferpay. Horizon recalcule ensuite le prix de façon autoritaire à la création de la facture (BookingService.CreateOrUpdateInvoice) — si le client calcule autre chose, le client paie X et la facture dit Y. La chaîne mobile (fonction pure computeBookingPricing, utils/booking-pricing.ts) réplique donc exactement Horizon + le cost.js du site web (le client qui encaisse aujourd'hui) :
- Brut
= chambres + passagers (assurances et pensions incluses) + suppléments globaux; - − early booking (cf. règle dédiée) →
subTotal(clampé ≥ 0, arrondi 0.1) ; - − code : bon cadeau (
VALUE, plafonné ausubTotal, cf. règle dédiée) ou % futur (base hors suppléments et early booking, sémantiqueDiscountHorizon) ; - − points :
min(points × 0.1, reste après code)(cf. règle « Solde optimiste » pour le débit réel) ; - →
totalCost(=Booking.FinalAmountHorizon, clampé ≥ 0, arrondi 0.1) ; acompte éventuel calculé à part (cf. règle « Acompte ») ; toPayNow = acompte choisi ? depositAmount : totalCost→booking.amountToPay+booking.paymentAmount;general.amount=general.finalAmount=totalCost(parité site web).
- Arrondi 0.1 CHF (
Math.round(x*10)/10) à chaque agrégat, comme cost.js — jamais 0.05. Le montant final de l'acompte est lui aussi arrondi 0.1 (exigence client de juillet 2026 — divergence assumée vsgetDepositdu site, qui ne ré-arrondit pas). - Points gagnés :
pointsRewarded = ceil(totalCost × 0.1 × 2)— ×2 mobile (Origin.Mobile), arrondi supérieur, sur le total complet même en payant un acompte (Horizon crédite à la création de facture, surFinalAmount). - Duplication assumée : cette chaîne existe dans 4 bases de code (Horizon C#, cost.js du site, cost.js back-office, mobile) qui ont déjà divergé par le passé — cf.
DETTE_TECHNIQUE.md§ « Pas d'endpoint de pricing ». - Code :
utils/booking-pricing.ts(fonction pure, couverte parutils/__tests__/booking-pricing.test.ts),composables/booking-constructor/pricing.ts(collecte des entrées),stores/bookingConstructor.ts(finalBooking).
Acompte : taux du voyage + flag paymentDepositOnly
L'option « Payer un acompte » (TBPaymentAmount.vue, scope Regle paymentAmount, valeurs 'full' | 'deposit') utilise le taux du voyage : travel.depositPercentage (int %, source de vérité Horizon — ne jamais hardcoder de taux). L'option n'est proposée que si 0 < depositPercentage < 100 (depositAvailable).
- Formule (spec client, réconciliée avec la facture
Confirmationau prorata par ligne) :deposit = round1(max(0, (subTotal − assurances − pointsDiscount) × depositPercentage/100 − bon + assurances)). Les assurances sortent de la base % et sont facturées à 100 % d'emblée ; les points réduisent la base avant le % (donc prorata) ; le bon est déduit à pleine valeur après le % (pas de prorata) — « déduit au max sur l'acompte » : le clamp ≥ 0 fait que la part du bon dépassant l'acompte est reportée sur le solde (Horizon la déduit de la factureBalance). - Arrondi 0.1 + clamp ≥ 0 sur le montant final : exigence client (juillet 2026) — divergence assumée vs cost.js
getDeposit, qui ne ré-arrondit pas. - Solde (
balanceAmount) :round1(max(0, totalCost − depositAmount)),0hors acompte. Affiché dans le résumé paiement (TBPayment_Summary.vue, ligne « Solde à régler ultérieurement » sous « Acompte »,data-testid="payment-balance") — c'est là que le client voit où atterrit le reste d'un bon qui dépasse l'acompte (choix produit : pas de message de split dans la carte « Bon et promotion », qui garde son « Bon appliqué : … » minimal). - Pas de % affiché : le radio dit « Payer un acompte » et la ligne du résumé « Acompte » — sans le taux (demande client : le pourcentage et son calcul déroutaient l'utilisateur).
travel.depositPercentagereste la source du calcul, il n'est juste plus rendu. paymentDepositOnly: envoyétruesur le booking quand l'acompte est choisi — c'est ce flag que lit le worker Horizon pour basculerConfirmed(factureConfirmation= acompte) vsBilled(facture totale). (Avant ce câblage, le mobile chargeait un 50 % hardcodé sans le flag : le booking passaitBilledavec une facture pleine alors que la moitié seulement était débitée.)- Code :
utils/booking-pricing.ts(depositAmount,balanceAmount),types/pricing.ts,components/travel-booking/payment/TBPaymentAmount.vue,components/travel-booking/payment/TBPayment_Summary.vue,stores/bookingConstructor.ts(paymentDepositOnly).
Early booking : remise par adulte, deadline inclusive
Quand travel.earlyBooking est actif et que la réservation est faite au plus tard le jour de travel.earlyBookingDate (comparaison date-only, jour J inclus — parité Booking.IsEarlyBookingActive Horizon), une remise est appliquée par passager adulte (tier calculé) : earlyBookingValue (type Value) ou brut × earlyBookingValue/100 (type Percent), multipliée par le nombre d'adultes.
- On suit Horizon, pas le cost.js du site : le site restreint la remise aux Seaside et calcule le % sur une base auto-référentielle — deux bugs documentés côté backend (doc Horizon
domain/business-rules.md§ EarlyBooking). - Affichage : ligne « Réduction Early Booking » dans le résumé paiement (miroir de l'
InvoiceItemHorizon). - Code :
utils/booking-pricing.ts(earlyBookingDiscount),composables/booking-constructor/pricing.ts(earlyBookingInput: éligibilité date + comptage des adultes).
Points gagnés affichés = calculateur backend (plus de formule front)
Le nombre de points de fidélité gagnés montré au paiement (« Vous gagnez N points », TBPayment_Summary.vue) vient du calculateur autoritaire Horizon GET /api/loyalty/winning-points?amount= (public, AllowAnonymous) — plus de réplication de la formule côté front. La réponse { winningPoints, mobileWinningPoints } porte deux valeurs ; le mobile affiche mobileWinningPoints (le ×2 app et l'arrondi ceil sont appliqués côté serveur). useBCPricing expose un pointsRewarded = ref(0) alimenté par un watch(tabulatedFinalPrice.toPayNow) débounce 300 ms, avec une garde de séquence qui ignore une réponse arrivée dans le désordre ; erreurs avalées en logError(_, 'pricing').
- Pourquoi : la formule front
floor(toPayNow / 10) * 2divergeait du backend (ceil(amount × 0,1)puis ×2 mobile, cf. Horizonmodules/loyalty-points.md) — un paiement de 2452 CHF affichait 490 au lieu de 491. Le backend étant l'autorité (crédit réel calculé à la facture), on lit sa valeur au lieu de la deviner. - Async, donc hors
BookingPricing:pointsRewardedn'est plus un champ detabulatedFinalPrice(tabulation synchrone) — c'est unrefséparé exposé paruseBCPricingpuis par le storebookingConstructor, lu parTBPayment_Summary.vue. La constante frontpointsRewardMultiplier(2.0) a été supprimée. - Nuance acompte :
amount=toPayNow(montant à payer maintenant, = acompte si paiement 50 %), pas le prix total — alors que le backend crédite en réalité sur le montant total (Booking.FinalAmount) à la facture. Pour un paiement en acompte, le chiffre affiché reflète donc l'acompte : comportement historique conservé (le fix se limite à l'arrondi, pas à la base de calcul). - Type :
LoyaltyPointsResult({ winningPoints, mobileWinningPoints }) déclaré localement dansservices/api.ts— absent de@spektrum/horizon-types. - Code :
services/api.ts(publicApi.loyalty.winningPoints),composables/booking-constructor/pricing.ts(pointsRewarded),stores/bookingConstructor.ts,components/travel-booking/payment/TBPayment_Summary.vue,types/pricing.ts. Cf.modules/billing-payment.md, Horizonmodules/loyalty-points.md.
Prix par passager calculé au submit (p.price + itemPrices)
Au moment d'assembler finalBooking (juste avant paymentApi.initialize), chaque passager reçoit un prix complet p.price = getFullPricePerPassenger(p) (composables/booking-constructor/pricing.ts), miroir du Cost.getRealCostPerPassenger du site web (buchard-website/.../Booking/cost.js). La formule mobile compose les primitives de prix existantes :
- part chambre =
getRoomPriceForPassenger(id)(prix chambre ÷ nb d'occupants) ; le sentinel-1(passager dans aucune chambre) est ramené à0; - transport par tranche =
getPriceForPassengerType(type, oneWay)— ajouté uniquement quand le booking n'a aucune chambre (roomReservations.size === 0:OneDay/ vol-sec Seaside) ; - upcharge pension =
getMealPlanPrice(mealPlan, type)(Seaside avecaccommodation).
L'assurance est volontairement exclue de p.price — elle est émise en ligne p.itemPrices ({ passengerId, label: 'Assurances', price }, une seule ligne : le mobile n'a pas d'excursions/activités, contrairement au web qui émet aussi « Excursions facultatives supplémentaires »). Même partage prix/itemPrices que le site.
- Pourquoi : avant ce câblage,
p.price = getRoomPriceForPassenger(p.id)ne renvoyait que la part chambre — et-1pour tous les passagers d'un voyage sans chambre (OneDay, vol-sec Seaside). Le prix par passager envoyé au backend était donc faux. Horizon recalcule le prix par passager de façon autoritaire (cf.BookingPassengerCostDetail), mais on envoie une valeur cohérente. - Invariant de réconciliation : par construction,
Σ p.price + Σ (assurance en itemPrices) === tabulatedFinalPrice.fullPrice − suppléments globaux(fullPriceinclut les suppléments globaux, portés au niveau booking et pas répartis par passager), carΣ getRoomPriceForPassenger(id)sur tous les passagers =tabulateRoomPrice(). Le helper est la version « un passager » du coupletabulateRoomPrice/tabulatePassengerPrice. (Le moyen de paiement « Facture » n'ajoute plus de frais — plus de termebillingPricedans le total.) - Non remisé / brut :
p.priceest pré-remise — bon cadeau, points de fidélité et suppléments globaux restent appliqués au niveau booking (toPayNow), pas répartis par passager (diffère du web, qui distribuebooking.discountspar passager — concept absent du mobile). - Divergence assumée vs web : pour un Seaside avec hébergement, le site ajoute le transport
sellingPrice*par tranche en plus de la chambre ; le mobile ne l'ajoute que pour les voyages sans chambre (aligné sur le total mobiletabulatePassengerPrice, sinonΣ p.pricedépasserait le montant débité). - Code :
composables/booking-constructor/pricing.ts(getFullPricePerPassenger),stores/bookingConstructor.ts(finalBooking, boucle passagers). Cf.modules/booking.md.
Age tiers calculés à la volée
Le PassengerType (Adult/Junior/Child/Baby) n'est pas stocké sur le passager — il est dérivé de la date de naissance + des seuils de l'Accommodation (ChildMin/Max, JuniorMin/Max, AdultMin) ou du voyage.
- Pourquoi : un même client peut être
Juniorsur un voyage etAdultsur un autre selon les seuils négociés avec l'hôtel. - Code :
utils/age-tiers.ts, consommé paruseBCPassengers.
Tranches d'âge & prix bébé sur les parcours sans chambre
Dès qu'un hôtel est présent (catalogue multi-jour, balnéaire normal), c'est l'hôtel qui fixe les tranches et leurs prix (seuils childMin/childMax/juniorMin/juniorMax/adultMin de l'accommodation, pricing par chambres) — rien de ce qui suit ne s'y applique. Les deux parcours room-less (course d'1 jour et vol-sec Seaside — étapes PASSENGER_COUNT/PASSENGER_COUNT_INFO) proposent quatre tiers, BABY inclus, avec des tranches dictées par le client :
- Course d'1 jour : Bébé [0-3), Enfant [3-12), Adolescent [12-18), Adulte 18+. Le bébé est toujours gratuit (
getPriceForPassengerType→0). ⚠️ Horizon et le site utilisent encore la borne <4 pour la gratuité — divergence temporaire assumée, cf.DETTE_TECHNIQUE.md§4. - Vol-sec Seaside : Bébé [0-2), Enfant [2-12), Adolescent [12-18), Adulte 18+. Le bébé est toujours gratuit (
getPriceForPassengerType→0) — aligné Horizon, qui hardcode 0 pour âge <2 (BookingService.cs:1623), comme le site.
getPriceForPassengerType reste le point de passage unique : le prix affiché au sélecteur, la tabulation (tabulatePassengerPrice), le p.price du payload et amountToPay suivent tous la même règle.
- Infos passager collectées pour les bébés comme pour les autres tiers (téléphone : adultes seulement, inchangé).
- Libellés : « Adulte / Adolescent / Enfant / Bébé » + tranche dynamique — helpers
passengerTypeName,passengerTypeIcon,formatAgeRangeSentence(getAgeRangeForPassenger(...))(utils/age-tiers.ts). Wording sur la borne exclusive : « Enfant de 3 ans révolus à 12 ans », « Adolescent de 12 ans révolus à 18 ans », « Bébé de moins de 3 ans » (vol-sec : « de 2 ans révolus à 12 ans », « de moins de 2 ans »). Ces helpers sont réservés aux parcours room-less — le chemin accommodation garde ses libellés décalés (règle suivante).formatAgeRange(label date de naissance) affiche la borne haute inclusive (« 3-11 ans » —maxAgeest exclusif). - Bornes date de naissance :
getAgeRangeForPassenger(BABY)porteminAge: 0sur tous les chemins — sinon aucunmaxn'est dérivé etDateInputretombe sur son défaut adulte (aujourd'hui − 18 ans) : min > max, aucune date sélectionnable. Leminde tout tier borné est décalé de +1 jour (maxAgeexclusif : né il y a exactementmaxAgeans = tier supérieur) — tranches contiguës sans chevauchement. - Suppléments globaux : les bébés restent comptés dans
nbPax(parité Horizon, qui recalcule pareil à la facture). Early booking : inchangé (adultes uniquement). - Code :
composables/booking-constructor/passengers.ts(passengerTypeMap,passengerTypeCounts,changePassengerCount),composables/booking-constructor/pricing.ts(getPriceForPassengerType),utils/age-tiers.ts,TBStepPassengerCounts.vue,TBStepPassengerCountsInformation.vue,TBPayment_Summary.vue(branche sans chambre). Tests :utils/__tests__/age-tiers.test.ts,stores/__tests__/bookingConstructor.test.ts.
Tier Child affiché « Enfant » quand l'accommodation n'a pas de tranche junior
Sur le chemin accommodation du wizard (Seaside avec hôtel), l'enum PassengerType est rendu avec des libellés décalés : JUNIOR → « Enfant », CHILD → « Bébé » (quirk Horizon hérité — les parcours room-less affichent, eux, les 4 tiers réels, cf. règle précédente). Quand une accommodation Seaside n'a pas de tranche junior (juniorMin === juniorMax, ex. les deux à 0 — config child 6-16 / junior 0 / adult 16+), le tier CHILD (childMin–childMax) est la seule tranche sous-adulte et représente de vrais enfants : il doit alors s'afficher « Enfant » (icône icon_child.svg), pas « Bébé » (icône baby.svg).
- Règle produit : dès qu'il n'y a que deux tranches (adulte + une seule sous-adulte), on libelle « Adulte / Enfant », jamais « Bébé ». Comme
JUNIORest déjà « Enfant », seul le cas pas de junior change l'affichage du tierCHILD. - Portée : Seaside +
travel.accommodationuniquement (une seule accommodation, seuils non ambigus) ; les voyages catalogue à hôtels par jour ne sont pas touchés (le prédicat renvoiefalse). - Affichage seulement :
PassengerType.CHILDreste l'enum/clé de pricing et de capacité chambre — seuls le mot, l'icône et la tranche d'âge saisissable changent. - Saisie de la date de naissance : dans ce cas,
getAgeRangeForPassenger(CHILD)élargit la tranche à[0, childMax](au lieu de[childMin, childMax]) — l'« Enfant » couvrant alors tout le sous-adulte, un enfant plus jeune quechildMinreste saisissable (sinon il était bloqué par la borne basse). Reflété automatiquement dansTBPassengerInfoForm(label « Date de naissance (…) » + bornesmin/maxdu champ date viagetBirthdateBoundsForPassenger). - Code : prédicat
childTierShowsAsEnfant(travel)dansutils/age-tiers.ts, consommé pargetAgeRangeForPassenger(bornes date de naissance) etTBStepAccommodation(dispositions chambre et l'accordéon « Filtrer par nombre de personnes » : ligneBébé(s)→Enfant(s), et la ligne juniorEnfant(s) de 6-15 ansest masquée quand il n'y a pas de tranche junior — sinon deux lignes « Enfant(s) » dont une stérile). Le prédicat exige Seaside + accommodation, donc renvoie toujoursfalsesur les parcours room-less —TBStepPassengerCountsetTBStepPassengerCountsInformationne le consomment plus (libellés viapassengerTypeName, règle précédente). Hors périmètre : le résumé paiement (branche sans chambre = single-day, pas d'accommodation).
requiresMealPlan uniquement Seaside avec hôtel
La pension est demandée uniquement si TravelType.SEASIDE ET travel.accommodation est défini.
- Pourquoi : un vol-sec Seaside (transport seul) n'a pas d'hôtel donc pas de pension à choisir.
- Code :
stores/bookingConstructor.ts,requiresMealPlan.
CheckIn/CheckOut Seaside : les nuits en bus décalent le séjour hôtel
Pour un Seaside avec hôtel, finalBooking fabrique une période « Séjour » unique (booking.roomTypeCategories[].days[0] — les Seaside ont travel.days = []) dont les bornes excluent les nuits passées dans le bus : checkIn = occurrence.start + 1 jour si travel.firstNightInTheBus, checkOut = occurrence.end − 1 jour si travel.lastNightInTheBus.
- Pourquoi : Horizon persiste
BookingAccommodation.CheckIn/CheckOutverbatim depuis le payload client (BookingService.Map— son helperSeasideStay.HotelArrival/HotelDeparturene sert qu'au prorata du meal-plan, jamais à la persistance), et la rooming list hôtel (page Occupancy,GetByAccommodationAndDate) filtre par plage surCheckIn. Sans le décalage, une résa mobile avec bus de nuit portait la date de départ du bus (un jour trop tôt) et disparaissait de la rooming list. Formule = parité avec le site web (Vue/Booking/stores/booking.js, même ±1 jour) et avecSeasideStayHorizon. - Wire : les dates partent locales sans fuseau via
serializeWireDates(cf. « Dates sortantes ») — les deux règles sont indépendantes (l'une corrige le fuseau, l'autre le jour du séjour). - Code :
stores/bookingConstructor.ts(finalBooking, bloc fallback « Séjour »). Tests :stores/__tests__/bookingConstructor.test.ts(« finalBooking seaside stay dates »), e2ebooking-wizard-seaside.spec.ts(« night buses shift the booked hotel stay off the travel bounds »).
Dropdown lieu de départ = villes uniquement (affichage)
Le select « Lieu de départ/retour » (TBPassengerInfoForm) affiche une liste plate de villes triée alpha — plus de regroupement par ligne/trajet (<optgroup>), ni nom de trajet, ni heure, ni nom d'arrêt. Une ville n'apparaît qu'une fois (dédup par stop.id).
- Désambiguïsation : si plusieurs arrêts tombent dans la même ville (
locality), on suffixe le nom d'arrêt :"{locality} ({place})". - Single-day : la capacité étant par trajet, la même ville peut apparaître sur deux trajets, suffixée de ses places restantes :
"{locality} ({n} place(s))". - Places restantes aussi en multi-day (si dispo) : les villes des voyages multi-day sont elles aussi suffixées de leurs places restantes
"{locality} ({n} place(s))"quand les drives de l'occurrence portentquota/occupancy. La liste des arrêts reste sourcée deslines(catalogue, plus complètes pour un voyage statique) ; les places sont superposées depuis les drives de l'occurrence sélectionnée (singleDayDrives, la seule source fiable dequota/occupancy— cf. commentaire du computedlines). Si le drive correspondant n'a pas la capacité, pas de suffixe (comportement inchangé). Place-désambiguïsation et places se combinent via" · ":"Sion (Gare · 12 places)". - Affichage seulement : la valeur enregistrée reste le
stop.id(loadingStopId) — pricing, résumé paiement et back-office inchangés. Le pré-remplissage dynamique (dynamicDepartureLocality) matche toujours car chaque label commence par la ville. - Code :
composables/booking-constructor/vehicle.ts(stopOptions,buildDisambiguatedOptions,driveSeatSuffix),components/travel-booking/TBPassengerInfoForm.vue.
Lieu de départ exigé seulement quand des arrêts sont proposés
Le champ « Lieu de départ/retour » n'est exigé — règle Regle du formulaire et passengerInfosValid (store) — que quand stopOptions est non vide, le même signal qui pilote le rendu du select (v-if="stopOptions.length > 0").
- Pourquoi : certains voyages single-day arrivent sans drives/stops (incohérence backend documentée dans le TODO en tête de
TBStepPassengerCountsInformation.vue). Le champ était masqué mais sonrequiredinconditionnel faisait échouer$validate()sur un champ invisible : « Valider » et « Suivant » restaient morts, sans aucun champ à surligner ni erreur console. Même doctrine que pension/assurance (accord store/Regle par construction, cf. « Pension & assurance vierges par défaut »). - Comment :
useBCPassengersreçoit un predicate injectéhasStopOptions: () => boolean, câblé dansstores/bookingConstructor.tssur lestopOptionsdeuseBCVehicle(closure appelée au runtime seulement — les deux composables ont une dépendance croisée à la construction). - Code :
TBPassengerInfoForm.vue(règleloadingStopId),composables/booking-constructor/passengers.ts(passengerInfosValid),stores/bookingConstructor.ts(hasStopOptions).
Pension & assurance vierges par défaut (choix conscient)
Dans le formulaire passager (TBPassengerInfoForm), mealPlan et insuranceId démarrent vides (undefined / '') — pas de valeur pré-sélectionnée. Les deux select affichent une option vide (with-empty-selection).
- Pourquoi : produit — éviter que le client « skippe » trop vite en gardant un défaut (la pension de l'hôtel, ou « Aucune assurance »). On force un choix conscient.
- Validation (gate de progression) :
passengerInfosValid(doncallPassengerInfosValid) n'exige ces champs que quand ils sont réellement présentés :- pension →
requiresMealPlan(Seaside + hôtel). Testtypeof mealPlan === 'number'carMealPlan.BREAKFAST === 0est une valeur valide (unselectvide rend''). - assurance →
passengerHasInsuranceOptions(passengerId): exigée si et seulement si la chambre du passager offre ≥1 assurance dans la tranche prix/date (getInsurancesForRoomKey) — c'est exactement le signal que le formulaire utilise pour afficher leselect(hasInsurances). Un passager dans aucune chambre (single-day / vol-sec) renvoiefalse, donc l'assurance n'est jamais exigée là où l'UI ne la montre pas.
- pension →
- Pourquoi ce signal plutôt qu'un proxy d'étape : le store et Regle (formulaire) valident en parallèle ; ils doivent s'accorder. En partageant
getInsurancesForRoomKey, l'accord est par construction — le dead-end « bouton grisé sans champ à surligner » (chambre sans assurance en plage) devient impossible. (Remplace l'ancien proxyrequiresInsurance, supprimé.) - Code :
composables/booking-constructor/passengers.ts(blankPassengerFactory,passengerInfosValid),stores/bookingConstructor.ts(passengerHasInsuranceOptions,getInsurancesForRoomKey),components/travel-booking/TBPassengerInfoForm.vue.
Téléphone obligatoire pour les passagers adultes
Le formulaire passager (TBPassengerInfoForm) collecte un numéro de téléphone par passager adulte (PassengerType.ADULT) — champ « Numéro de téléphone » (ValidatedField type="tel" → PhoneInput), masqué pour les autres tiers (Junior/Child/Baby). Obligatoire pour chaque adulte : bloque « Valider » (overlay) et « Suivant » (footer).
- Pourquoi : Buchard doit pouvoir joindre chaque passager adulte, pas seulement le contact de la réservation (section « Contact » du paiement, distincte).
- Les deux gates parallèles (même pattern que pension/assurance) : la règle Regle du formulaire est par-item — le
$eachest passé en forme fonction ($each: (passenger) => ({ … phone: { required: requiredIf(() => passenger.value.type === ADULT) } })) — etpassengerInfosValid(store) refuse un adulte sansphone.typeétant figé à la création du passager (pas dérivé de la date de naissance saisie), le conditionnel est stable. - Pas de validation de format : règle
requiredseule, comme tous les champs téléphone de l'app (contact paiement, infos personnelles, liste d'attente). - Pré-rempli quand la donnée existe (contrairement à pension/assurance — c'est de la donnée, pas un choix conscient) : passager 1 ←
customer.mobiledu compte connecté (blankPassengerFactory) ; « Ajouter un passager enregistré » ←phonedu passager d'une réservation précédente (PassengerSelectModal.setAndClose, fallback''). - Payload :
BookingPassenger.phoneexiste déjà côté@spektrum/horizon-typesetfinalBookingpousse l'objet passager entier — aucun mapping ajouté ; les non-adultes envoient''comme avant. - Code :
components/travel-booking/TBPassengerInfoForm.vue,composables/booking-constructor/passengers.ts(blankPassengerFactory,passengerInfosValid),components/travel-booking/PassengerSelectModal.vue.
incrementRoomReservation est l'unique chemin pour ajouter des passagers à une chambre
Trois étapes atomiques : (1) lire la capacité, (2) créer N passagers vierges, (3) les booker. Symétrique : decrementRoomReservation.
- Pourquoi : maintient l'invariant capacité chambre = nombre exact de passagers.
- Code :
stores/bookingConstructor.ts.
Filtre « nombre de personnes » de l'étape Accommodation = actif uniquement accordéon ouvert
L'étape Accommodation affiche un accordéon « Filtrer par nombre de personnes » (rendu seulement quand le total des dispositions dépasse 5, showRoomFilter). Le filtre ne restreint la liste que tant que l'accordéon est déplié (roomFilterActive = dropDownIsVisible !== undefined) ; replié, toutes les dispositions s'affichent.
- Sémantique de correspondance : « capacité ≥ groupe » par tranche — une disposition est gardée si
room.adult >= filtre.adult && room.junior >= filtre.junior && room.child >= filtre.child(la chambre peut accueillir au moins le groupe sélectionné, pas l'inverse). - Pourquoi cet « actif = ouvert » : le défaut
{ adult: 1, junior: 0, child: 0 }masquerait la plupart des chambres si le filtre était toujours appliqué — déplier l'accordéon est l'opt-in explicite, le replier remet à zéro l'affichage. - Comment :
visibleRoomsFor(rta)est la source unique des dispositions rendues (prix > 0, puis filtre personnes si actif, puis tri par capacité totale) ; le<li>du type de chambre disparaît sivisibleRoomsFor(...).length === 0. Filtre purement UI (ref localepersonCountFilter), aucune mutation dubookingConstructor. - Code :
components/travel-booking/TBStepAccommodation.vue.
Changement d'occurrence = reset rooms + passagers + sièges
Le watch(travelOccurrenceId) clear tout ce qui dépend de l'occurrence avant de charger la nouvelle.
- Pourquoi : les disponibilités et le plan véhicule sont liés à une occurrence précise — réutiliser l'ancien state mène à des incohérences.
- Voyages multi-day (hors Seaside) : le véhicule/plan de sièges lui-même change désormais avec l'occurrence (
bookingConstructor.vehicles, l'affectation réelle de l'occurrence — plus le modèle générique du catalog, cf.modules/product-catalog.mdetmodules/occurrence-capacity.md). Source lue viatravel.occurrences[].vehicles, passelectedTO.vehicles(bug backend :GET /occurrences/:idrenvoie une liste de véhicules vide).TBSeatMap.vuedoit donc re-synchroniser sa sélection de pont/deck (selectedDeckId) à chaque changement de véhicule, pas seulement au montage — sinon un aller-retour par les pastilles d'étape (occurrence A → B) laisse le plan de sièges vide.
Édition d'un booking — exclure ses propres sièges
getVehicleOccupancy(occurrenceId, currentBookingId) exclut les sièges du booking en cours d'édition.
- Pourquoi : sinon l'utilisateur ne pourrait pas resélectionner ses propres sièges (vus comme « occupés »).
Bon cadeau — l'id doit être transmis, pas seulement le montant
TBPaymentRebateCode.vue valide un code via publicApi.gifts.validate puis stocke code/montant/id dans le scope Regle partagé rebateCode. Le montant (remainingValue ?? value, cf. règle suivante) alimente le calcul de prix (useBCPricing → rebateCodeDiscount/toPayNow), mais le backend ne marque un bon comme consommé (Gift.IsUsed = true) que s'il reçoit l'id du bon dans booking.gifts au moment de la création de la réservation — il re-fetch l'entité Gift par cet id, il n'a pas besoin des autres champs.
- Pourquoi : avant ce câblage, la réduction n'était que cosmétique côté client — le montant était bien déduit du prix affiché/payé, mais rien n'identifiait quel bon avait été utilisé auprès du backend, donc le bon n'était jamais consommé serveur et restait réutilisable indéfiniment.
- Comment :
useBCPricingexposeappliedGiftId(lu depuisrebateCode.giftId) ;bookingConstructor.ts(finalBooking) peupleoutput.booking.gifts = [{ id: appliedGiftId.value } as Gift]uniquement quand un bon est appliqué — un objetGiftminimal (id seul, casté) plutôt qu'uncreateDefault<Gift>('Gift')complet, puisque le backend ignore tout le reste. Un seul bon à la fois (un seul champ de saisie dans l'UI). - Code :
components/travel-booking/payment/TBPaymentRebateCode.vue,composables/booking-constructor/pricing.ts,stores/bookingConstructor.ts. Cf.modules/gifts.md,modules/billing-payment.md.
Bon cadeau — solde restant déduit, plafonné au sous-total (suppléments inclus)
La déduction appliquée par un bon est remainingValue ?? value : le solde restant d'un bon partiellement utilisé (RemainingValue, null = bon intact → on retombe sur value), stocké dans rebateCode.amount par TBPaymentRebateCode.vue. computeBookingPricing plafonne ensuite cette déduction au sous-total de la réservation, suppléments globaux inclus (rebateCodeDiscount = min(amount, subTotal)) et calcule le solde qui restera sur le bon (rebateCodeLeftover = max(0, amount − subTotal), uniquement pour un rabais de type VALUE).
- Pourquoi ce plafond-là : Horizon repousse l'excédent sur le bon à la création de facture (
Gift.RemainingValue = excèsquand la somme des lignes passe négative) — l'excès s'y mesure sur le total complet, suppléments inclus. Un gros bon absorbe donc les suppléments avant de laisser un solde ; le mobile calque ce comportement. Et : (1) sans lireremainingValue, un bon partiellement consommé déduisait sa valeur d'origine (sur-remise) ; (2) sans plafond, la remise affichée dépassait le prix. - Effet sur
toPayNow:totalCostest déjà borné ≥ 0 ; en acompte, le bon est déduit à pleine valeur après le % (cf. « Acompte »). - Affichage : la case du bon montre le montant appliqué (
rebateCodeDiscount) et, quandrebateCodeLeftover > 0, « Il vous restera n CHF sur ce bon cadeau. ».TBPayment_Summary.vueaffiche la même remise plafonnée. - Code :
utils/booking-pricing.ts(rebateCodeDiscount,rebateCodeLeftover),types/pricing.ts,components/travel-booking/payment/TBPaymentRebateCode.vue. Cf.modules/gifts.md.
Carte « Contact » au paiement : le profil client complet, obligatoire
L'étape Paiement porte une carte unique « Contact » (dernière section, TBPaymentContactInfo.vue, scope Regle contactInfo typé api_Customer) qui collecte tout le profil : adresse (name, firstName, address, zip, city, country) et contact (eMail, mobile, contact d'urgence emergencyName/emergencyFirstname/emergencyPhone) — tous required pour tout le monde. Pré-remplie depuis le compte quand l'utilisateur est connecté ; invité : firstName/name ← 1ᵉʳ passager, country = 'CH'. Au submit, finalBooking spreade le scope tel quel : output.customer = { ...output.customer, ...contactInfo } (les noms de champs matchent api_Customer). (Remplace les deux anciennes cartes « Adresse de facturation » (TBPaymentBillingInfo.vue, scope billingInfo — composant et scope supprimés) et « Contact » — fusionnées à la demande du client.)
- Pourquoi : ces champs ne sont persistés côté backend que via la création du client invité —
MobileBookingApiController.ResolveCustomer(repo buchard-website) →AccountUtil.CreateAccountIfNoExist→ HorizonPOST /api/customers, qui enregistre le DTO complet. Le booking lui-même ne porte quegeneral.customerId— sans ces champs dansoutput.customer, ils ne sont sauvés nulle part. Horizon exigeeMailà la création (CustomerService.ValidateEntity) ;mobilereste requis côté app (relaxé côté Horizon viarequireMobile: false, mais nécessaire pour NAV et le prestataire de paiement). - Toujours ouverte au départ, pas de bouton « Annuler » : la carte démarre ouverte pour tout le monde (fini le replié-par-défaut connecté et l'auto-dépliage si données invalides) — c'est le gate « toutes les sections validées » (cf. règle dédiée) qui force le passage par « Valider » avant « Payer ». L'ancien bouton « Annuler » (
cancelEdits) a été supprimé. - Connecté : « Valider » persiste sur le compte, adresse incluse : le champ email est
readonly(identifiant du compte), une note « Ces informations seront mises à jour sur votre compte client. » est affichée, et « Valider » (bouton custom, spinner pendant l'appel) envoie toujours le PUT viaupdateCustomerdu store customer (protectedApi.customers.update, JWT) — adresse (address/zip/city/country) +mobile+ contact d'urgence, plusfirstName/name(requis :updateCustomerdérivefullNamedu partiel, pas du merge) — puis replie la carte. Échec API → toast (handleErrordans le store) et carte laissée ouverte. Invité : « Valider » du_TBPaymentBasereplie localement, aucun appel réseau. - Persistance côté endpoint paiement : pour un utilisateur connecté (
customer.Id != Guid.Empty),ResolveCustomer(endpoint anonyme du site) renvoie toujours le customer posté tel quel et n'appelle jamaisUpdateCustomer— c'est la carte Contact du mobile qui écrit vers Horizon (bullet ci-dessus). Pour un invité, la création du client àResolveCustomerreste le seul canal de persistance. - Pièges d'observation NAV (pour vérifier qu'un invité est bien sauvé) :
Customer.Mobileest[NotMapped]côté Horizon — jamais en DB, poussé uniquement vers NAV/BC (NavCustomerService.Merge→MobilePhoneNo), et ré-hydraté depuis NAV à la lecture (CustomerService.Get). L'email d'un invité n'est volontairement pas poussé vers NAV (clé unique côté BC). Juger la persistance sur les colonnes DB de la fiche client Horizon :EMail+EmergencyName/Firstname/Phone. - Code :
components/travel-booking/payment/TBPaymentContactInfo.vue,composables/booking-constructor/pricing.ts(contactInfo),stores/bookingConstructor.ts(output.customer),stores/customer.ts(updateCustomer). Cf.modules/billing-payment.md,modules/customer-membership.md.
Message de confirmation de paiement selon guest / connecté
Sur la page de retour Saferpay (PaymentCallback.vue), en cas de succès, le sous-titre et les boutons dépendent de si l'utilisateur est connecté — guest = !useCustomerStore().isLoggedIn (pas de flag dédié) :
- Connecté : « Votre réservation a été enregistrée avec succès. Une fois validée, elle sera disponible dans votre espace client. » + bouton primaire « Voir ma réservation » (→
/booking/details/:id) + secondaire « Retour à l'accueil » (comportement historique). - Guest : « Votre réservation a été enregistrée. Vous recevrez une confirmation par e-mail dès qu'elle sera validée. » + seul bouton « Retour à l'accueil ».
- Échec : inchangé (« Le paiement n'a pas pu être effectué. Veuillez réessayer. » + « Retour à l'accueil »).
- Pourquoi : un guest n'a pas d'espace client et la route
/booking/details/:idestrequiresAuth— lui présenter « Voir ma réservation » le renverrait au login (toast « Vous devez être connecté… »). On lui promet donc la confirmation par e-mail (le backend crée le client invité et envoie le mail) et on retire la CTA inaccessible. - Comment :
PaymentCallback.vuecalculeisGuest/canViewBooking(isSuccess && !isGuest) et en dérivesubtitle/primaryButtonLabel/secondaryButtonLabel+ handlersonPrimary/onSecondary(computed plutôt que ternaires inline dans le template, devenus illisibles à trois branches). - Code :
views/PaymentCallback.vue,stores/customer.ts(isLoggedIn). Cf.modules/billing-payment.md.
Acompte indisponible pour les courses d'1 jour
La section « Choix de paiement » de l'étape Paiement (totalité vs acompte 50 %, TBPaymentAmount.vue) est retirée entièrement pour un voyage TravelType.ONE_DAY — une course d'1 jour est payable en totalité uniquement.
- Pourquoi : produit — un acompte 50 % « reste de la facture par la poste 2 semaines avant le départ » n'a pas de sens sur une course d'un jour (peu chère, départ proche). Ciblé sur
ONE_DAYprécisément, pas le concept plus largeisSingleDay(days.length == 0) qui engloberait le vol-sec Seaside — un vrai séjour de 2-3 semaines où l'acompte garde son sens. - Comment :
TBStepPayment.vuecalculeisOneDay = travel.type === ONE_DAYet rend<TbPaymentAmount v-if="!isOneDay">. La section n'étant plus montée, son scope ReglepaymentAmountn'est jamais enregistré :useBCPricingretombe surdownPaymentRate = r$.$value.paymentAmount?.payAmount ?? 1.0→ paiement plein, et la ligne « Acompte » du résumé (TBPayment_Summary.vue, conditionnée àdownPaymentRate < 1.0) disparaît. Aucun flagPaymentDepositOnlyà poser côté mobile : l'acompte n'est qu'un montant (paymentAmount = toPayNow). - Code :
components/travel-booking/TBStepPayment.vue,components/travel-booking/payment/TBPaymentAmount.vue,composables/booking-constructor/pricing.ts(downPaymentRate). Cf.modules/billing-payment.md.
Sections de l'étape Paiement numérotées dynamiquement (contiguës)
L'étape Paiement affiche 5 sections, dans cet ordre : 1. Choix de paiement (masquée ONE_DAY) · 2. Méthode de paiement · 3. Bon et promotion · 4. Points de fidélité (masquée invité) · 5. Contact (carte fusionnée, cf. règle dédiée). Les titres ne codent plus leur numéro en dur : TBStepPayment.vue calcule un numéro contigu 1..n sur les seules sections visibles (stepNumbers) et le passe à chaque section via une prop number (titre `${number}. …`).
- Pourquoi : deux sections sont conditionnelles — « Choix de paiement » (masquée pour les
ONE_DAY, cf. règle ci-dessus) et « Points de fidélité » (masquée pour un invité,v-if="isLoggedIn"). Avec des numéros figés, masquer l'une laissait un trou dans la séquence. La numérotation dynamique garde la liste contiguë dans tous les cas. - Code :
components/travel-booking/TBStepPayment.vue(stepNumbers), les 5 sectionscomponents/travel-booking/payment/TBPayment*.vue(propnumber).
« Payer » exige toutes les sections validées (repliées)
Le bouton « Payer » (TBPayment_Summary.vue, :disabled="!paymentReady") reste verrouillé tant que chaque carte repliable de l'étape Paiement n'a pas été validée (repliée via « Valider ») : Choix de paiement, Méthode, Bon et promotion, Contact. Rouvrir une carte (« Modifier ») re-verrouille « Payer ».
- Mécanisme = champs Regle invisibles, pas un registre parallèle :
TBStepPayment.vueenregistre un scope Regle dédiésectionsValidated— un champ par carte (paymentAmount,paymentMethod,rebateCode,contactInfo) valant'validated'quand la carte est repliée,undefinedquand elle est en édition. Règles :requiredpartout, saufpaymentAmount: requiredIf(() => !isOneDay)(la carte n'est pas rendue pour unONE_DAY, elle ne pourrait jamais être validée). Le scope est collecté par l'agrégat partagé (useCollectScopeRecord,pricing.ts), doncpaymentReady = !r$.$invalidporte le gate sans logique supplémentaire. - Propriété de l'état : l'état d'édition des cartes est lifté dans
TBStepPayment(editing, bindé sur chaque section viav-model:is-editing, les sections exposant undefineModel) — le scope invisible est dérivé de cet état par uncomputed. Il vit dans un scope séparé (pas un champ ajouté àcontactInfo) car le scopecontactInfoest spreadé tel quel dansoutput.customer: un champvalidatedfuiterait dans le payloadpaymentApi.initialize. - États initiaux : Choix de paiement, Méthode et Contact démarrent ouvertes (bloquantes) ; Bon et promotion démarre repliée (optionnelle — un code validé la replie automatiquement, l'état appliqué s'affiche dans son résumé, et l'éditeur vide a un « Annuler » pour refermer sans code). Points de fidélité n'a pas de champ : section résumé-seul, jamais en édition.
- Cycle de vie : le scope se désinscrit tout seul au démontage réel de
TBStepPayment(sortie du wizard) — aucun cleanup manuel ; une nouvelle session wizard repart avec les cartes ouvertes. - Code :
components/travel-booking/TBStepPayment.vue(editing,sectionsValidated),composables/booking-constructor/pricing.ts(type du record collecté,paymentReady), les 4 sectionsTBPayment{Amount,Method,RebateCode,ContactInfo}.vue(defineModel('is-editing')).
Waiting list = mail, pas Booking
La liste d'attente côté mobile n'est pas un Booking en statut WaitingList — c'est un simple POST sur publicApi.mail.addWaitingList.
- Pourquoi : décision produit — pas de réservation d'un siège virtuel, juste un signal à Buchard pour rappeler le client si une place se libère.
- Code :
publicApi.mail.addWaitingList, routewaiting-list-confirmation,composables/booking-constructor/waitingList.ts, flagforceWaitingList.
Liste d'attente — dropdown d'arrêt groupé par ligne, avec l'heure du bus
Le select d'arrêt du formulaire liste d'attente (TBStepWaitingListForm) réplique le CustomerForm.availableStops du site : tous les arrêts de l'occurrence, groupés par ligne (<optgroup> dont le label est le nom du trajet — drive.name), chaque option étiquetée "locality, place (hour)" (l'heure de bus incluse). Deux chemins :
- Catalogue + balnéaire (identiques) : un dropdown unique groupé (
waitingListStops, brancheelse/isSeasidedeuseBCVehicle). Source des arrêts :linescatalog pour le catalogue,selectedTO.lines ?? travel.linespour le balnéaire. - Course d'1 jour : d'abord « Ligne désirée » (choix du trajet,
waitingListDrives), puis « Arrêt désiré » filtré à ce trajet — pas de groupement (une seule ligne sélectionnée). Les libellés portent l'heure eux aussi. - Contrat de soumission : le
selecttravaille parstop.id; au submit (TravelBookingFooter), on résout l'iden libellés humains envoyés au backend —requestedStop="locality, place (hour)",requestedLine= le nom du trajet (dérivé de l'arrêt hors one-day, du picker de ligne en one-day). Miroir exact deonRequestedStopChange/stopLabeldu site. - Requis seulement si des arrêts existent :
requestedStopIdn'est exigé (Regle +waitingListIsValid) que quandwaitingListStops.length > 0(predicatehasRequestedStopOptions), miroir duv-ifdu dropdown — certains single-day arrivent sans drives/stops (même doctrine que pension/assurance, cf. « Lieu de départ exigé seulement quand des arrêts sont proposés »). - Helper de libellé :
waitingListStopLabel(stop)(composables/booking-constructor/vehicle.ts) —"locality, place"+(hour)uniquement si l'heure est présente. Dégradation propre si la donnée n'a pas d'heure. - Code :
composables/booking-constructor/vehicle.ts(waitingListStopLabel,waitingListStops,waitingListDrives),components/travel-booking/TBStepWaitingListForm.vue(waitingListStopGroups, dropdown groupé viaValidatedField),components/travel-booking/TravelBookingFooter.vue(résolutionrequestedStop/requestedLine),components/form-inputs/ValidatedField.vue(propsselectGroups/placeholder). Tests e2e :booking-wizard-{catalog,seaside,oneday}.spec.ts.
Badges de date : « Liste d'attente » (FULL) vs « Sur demande » (TOO_LATE)
À l'étape de choix de date (TBStepDepartureDate), le badge d'une occurrence non réservable suit exactement le site (Occurrences.vue, tripOccurrencesTableRow.cshtml, TravelCard.vue) :
TravelBookingState.FULL→ « Liste d'attente », couleurbg-secondary-80(ambre/orange, =btn--gradient-orangedu site) ;TravelBookingState.TOO_LATE→ « Sur demande », couleurbg-primary-50(bleu, =btn--blue-alternative) ;DONE→ « Terminé »,CANCELLED→ « Annulé » (grisbg-neutral-80).- Pourquoi : les libellés étaient inversés côté mobile (FULL affichait « Sur demande », TOO_LATE « Liste d'attente ») alors que les couleurs étaient déjà correctement liées à l'état — d'où l'impression de « mauvaise couleur ». Le fix a échangé les deux libellés (couleurs inchangées, liées à l'état). Fonctionnellement les deux états mènent à la liste d'attente ; la distinction est uniquement pour la compréhension et la parité site.
- Code :
components/travel-booking/TBStepDepartureDate.vue. Test e2e :booking-wizard-catalog.spec.ts(« date badges »).
Solde optimiste des points de fidélité après une résa payée avec points
Horizon ne débite les points qu'à la création de la facture — faite par le worker une-fois-par-minute après confirmation du paiement Saferpay (bascule PendingWebOrMobile → Confirmed/Billed). GET /api/customers renvoie donc l'ancien solde (trop haut) jusqu'à ~1 min après le paiement. Pour masquer ce délai :
- Au submit (
TBPayment_Summary.vuesubmit(), après unpaymentApi.initializeréussi, seulement sitabulatedFinalPrice.effectivePointsUsed > 0) : on pose un solde optimistecurrent - effectivePointsUsedviaapplyOptimisticLoyalty(bookingId, spent). - À la page de confirmation (
PaymentCallback.vue) : succès →startLoyaltyReconciliation()(pollGET /api/customerstoutes les 10 s jusqu'à ce que le solde serveur diffère dubaselinepré-résa, puis retire l'override) ; échec →revertOptimisticLoyalty().
- Eager = points dépensés uniquement (
current - effectivePointsUsed), pas les points gagnés ×2 — ceux-ci atterrissent en général dansPendingLoyaltyPoints(pas le solde dispo affiché). - Cap ~3 min (
MAX_RECONCILE_ATTEMPTS = 18× 10 s) : si le serveur ne bouge jamais, on abandonne et on retombe sur la valeur serveur réelle. - Pourquoi le store
customer(persisté) et pasbookingConstructor: le wizard est in-memory etfullReset()le vide à la confirmation ; l'override doit survivre au round-trip Saferpay (et à un cold start).main.tsrelance la réconciliation au démarrage si unpendingLoyaltypersisté traîne (app tuée pendant le paiement). - Portée :
LoyaltyPointsCard.vue(réactif viastoreToRefs) reflète l'override ;RecommendedTravelMaps.vue/SeasidePromoCard.vuedéstructurent sansstoreToRefs→ pas réactifs (lacune pré-existante, hors périmètre). - Code :
stores/customer.ts(pendingLoyalty,applyOptimisticLoyalty,revertOptimisticLoyalty,startLoyaltyReconciliation),components/travel-booking/payment/TBPayment_Summary.vue,views/PaymentCallback.vue,main.ts. Voirmodules/customer-membership.md,modules/billing-payment.md.
Rechargement du customer à l'arrivée Home / au resume de l'app
Le store customer est persisté : un cold start réaffiche le snapshot de la session précédente (solde de points périmé) tant qu'aucun refetch n'a lieu. Les points sont crédités côté serveur, de façon asynchrone (création de la facture par le worker une-fois-par-minute) — ils peuvent donc tomber pendant que l'app est fermée ou en arrière-plan. Pour les faire remonter, on rejoue fetchCurrentCustomer() à deux moments :
- Arrivée sur Home (
Home.vueonActivated) :onActivatedse déclenche au mount initial (ouverture d'une app fermée → atterrissage Home) et à chaque ré-activation (navigation in-app vers Home). - Retour au premier plan (
BuchardApp.vue, listenerAppresume) : couvre le cas « app seulement mise en arrière-plan » (pas tuée), quelle que soit la page affichée.
- Garde : fire-and-forget, uniquement si
isLoggedIn(endpoint protégé), erreurs avalées enlogError(_, 'customer'). - Pourquoi
resumeet pasappStateChange:resumene se déclenche pas au cold start, donc pas de double-fetch avec leonActivatedinitial de Home. Pas de double non plus en navigation in-app (app déjà active) ni au resume sur Home (la page<keep-alive>reste activée quand l'app passe en arrière-plan →onActivatedne rejoue pas). - Orthogonal au solde optimiste : tant qu'un
pendingLoyaltyest posé, le getterloyaltyPointsafficheoptimistic— recharger le customer ne perturbe pas la réconciliation en cours. - Code :
views/Home.vue(onActivated),BuchardApp.vue(listenerresume),stores/customer.ts(fetchCurrentCustomer). Voirmodules/customer-membership.md.
Rechargement des bookings à l'arrivée Home
Même logique que le rechargement customer ci-dessus, mais pour la collection bookings : Home.vue onActivated rejoue fetchBookings() inconditionnellement (garde isLoggedIn), et non plus seulement quand travelStore.needsRefresh est posé (ce flag ne sert désormais qu'à recharger catalogue/reco/départs, et n'est levé qu'après un paiement in-app via PaymentCallback → invalidate()).
- Pourquoi :
fetchBookingsne tournait qu'au 1er mount de Home puis, aux ré-activations, uniquement après un paiement (needsRefresh). Un changement backend d'un booking (statut/facture, nom de passager édité en back-office) ne remontait donc pas en revenant par Home. Combiné à la re-captureonActivateddeBookingBase(cf.modules/booking.md), l'écranBookingDetails(keep-alive) reflète la donnée fraîche en repassant par Home — comme il le faisait déjà via MyTravels (fetchBookingsinconditionnel enonMounted, page non keep-alive). - Compromis assumé : un appel
fetchBookingsréseau à chaque retour sur Home (miroir exact du refetch customer juste à côté — demandé produit). - Code :
views/Home.vue(onActivated),components/bases/BookingBase.vue(onActivated). Cf.modules/booking.md.
Barre de progression du palier = modulo 1000 (affichage pur)
La barre « Prochain palier de 100 CHF » (LoyaltyPoints.vue) est purement visuelle : elle n'accorde aucun bonus. 1000 points = 100 CHF ; atteindre 1000 débloque 100 CHF et retire 1000 points → la barre travaille en modulo 1000 (jamais jusqu'à 2000). Répliqué de buchard-website (AccountPageLoyaltyPoints.cshtml).
pointsInCurrentPalier = solde % 1000(curseur =/ 1000) ;pointsToNextPalier = 1000 − (solde % 1000).- La carte de solde affiche toujours le total réel (ex.
1050 POINTS=105 CHF) ; seule la barre applique le modulo (ex.50 pts, curseur 5 %, « Plus que 950 points… » — nombre en gras/vert). - Cas
solde % 1000 === 0(0 point, ou exactement 1000/2000…) : barre à 0 %, message « Cumulez des points pour atteindre votre prochain palier de 100 CHF ! » (pas de nombre en gras). Identique au site. - Base = le solde affiché (getter
loyaltyPoints, donc optimistic-aware), pas la valeur serveur brute. - Code :
views/LoyaltyPoints.vue.
Historique des points = 3 max, expansion inline, filet gauche par statut
LoyaltyPoints.vue liste loyaltyHistory (cf. modules/customer-membership.md) : 3 mouvements max par défaut, bouton « Voir tout l'historique » ⇄ « Réduire » qui déplie tout inline (données déjà chargées, aucun refetch, pas d'écran dédié). Design porté du site (_account-page.scss .loyalty-history__item), couleurs mappées sur nos tokens.
- Filet coloré à gauche (fond blanc + ombre, pas de bordure pleine), classification répliquée du site : en attente (
validFromfutur) → orange pointillés + ligne « Points valides dès le retour du voyage le {date} » ; crédité/positif (incl.BookingPurchase) → vert plein ; débit (points utilisés, expiration, annulation) → rouge plein ; mixte (gain + dépense sur la même ligne) → neutre. Montant+N/−N pts(vert/rouge). Choix produit : on distingue les débits au lieu de tout peindre en vert.- Écart assumé vs site : le site rend l'en-attente en vert pointillé + opacité 0.7 ; le mobile garde l'orange du PDF Buchard (bascule documentée en commentaire dans le CSS).
- Libellé de type via
LOYALTY_TRANSACTION_LABELS(libellés du site, ex. « Achat de voyage » — Horizon dit « Achat d'un voyage », divergence tranchée en faveur du site). - Code :
views/LoyaltyPoints.vue,stores/customer.ts(loyaltyHistory).
Points qui expirent = prochain groupe, sans borne temporelle
L'alerte « ⚠️ N points expirent le {date} » de la carte de solde affiche le prochain groupe de points qui expire — getter nextExpiringLoyalty : loyaltyTransactions avec expiresAt futur et pointsRemaining > 0, groupés par jour calendaire, sommés, le plus tôt d'abord ; null si aucun. Aucune borne (pas de « dans les 12 mois ») — comportement identique au site.
- Code :
stores/customer.ts(nextExpiringLoyalty),views/LoyaltyPoints.vue.
Facture PDF : cache-buster réseau + bouton grisé tant que le worker n'a pas facturé
protectedApi.bookings.getInvoice ajoute un paramètre de cache-busting (?_=${Date.now()}) à l'URL. Le bouton « Facture » de BookingDetails.vue reste visible mais grisé (pseudo-disabled, libellé « Facture bientôt disponible ») tant que le booking est en PENDING_WEB_OR_MOBILE ; un tap affiche alors un toast (« Votre facture est en cours de génération. Elle sera disponible d'ici quelques minutes. ») au lieu de tenter le téléchargement d'un PDF factice. (Avant : le bouton était masqué dans le même intervalle — le client ne savait pas qu'une facture arrivait.)
- Pourquoi le cache-buster :
CapacitorHttpdélègue au client HTTP natif (URLSessioniOS / couche OkHttp Android), qui met en cache les réponses GET par URL, indépendamment de tout cache applicatif mobile (pas decachedFetchsur cet appel). Sans ce paramètre, une facture téléchargée avant que le worker de paiement Horizon n'ait tourné (donc avant que l'Invoiceexiste réellement côté backend, l'endpoint renvoyant alors un PDF factice) resterait servie indéfiniment par ce cache natif — même une fois la vraie facture disponible côté Horizon.Filesystem.writeFilen'y est pour rien : il écrase toujours le fichier local, il n'existe pas d'option « ne pas réécrire si le fichier existe ». - Pourquoi le gate, et pourquoi
status === PENDING_WEB_OR_MOBILE(pasisPendingWorkerProcess) : le worker HorizonWorker.DoOneMinuteJob > CheckPendingPaymentstourne une fois par minute ; tant que le booking estPendingWebOrMobile, l'Invoicen'existe pas encore côté Horizon. À la confirmation du paiement, ce même worker fait basculerBooking.StatusversConfirmed/Billedet crée l'Invoicedans la foulée (BookingService.UpdateAsync) — donc dès que le statut quittePendingWebOrMobile, l'Invoice est garantie d'exister. Le champbooking.isPendingWorkerProcessexiste bien (@spektrum/horizon-types) et porte l'intention sémantique, mais sa formule backend n'est pas vérifiable dans la doc Horizon et pourrait diverger de ce partitionnement (ex. unPendingWebOrMobilesansTransactionId) — on gate donc sur l'enum de statut, exactement la même partition que la version « masquée » d'origine (status != PENDING_WEB_OR_MOBILE). Prédicat partagéisInvoicePending(booking). Voir doc backend Horizonmodules/billing-payment.md→ « Worker — paiements en attente ». - Code :
services/api.ts(bookings.getInvoice),views/BookingDetails.vue(isInvoicePending,onInvoiceClick).
getCurrent cache-busté (données loyalty & customer live)
protectedApi.customers.getCurrent ajoute un cache-buster (?_=${Date.now()}) à GET /customers.
- Pourquoi :
apiClientpasse parCapacitorHttp(pas axios malgré la doc historique), dont la couche native (URLSession/OkHttp) cache les GET par URL, même authentifiés — aucun cache applicatif sur le chemin protégé (le LRUapi-cache.tsne couvre quepublicApi). Même piège quebookings.getInvoiceci-dessus. Sans buster, le solde / points en attente / historique loyalty resteraient périmés ; les refetchresumeet le poll de réconciliation en dépendent (ils doivent renvoyer du frais pour détecter le débit du worker). - Portée : bénéficie à tous les appelants de
getCurrent(login,HomeonActivated,resume, réconciliation, mount deLoyaltyPoints). Le param_inconnu est ignoré côté Horizon ; l'URL porte aussi les params d'allègementignoreTravels/lighter-response(cf. règle suivante). - Code :
services/api.ts(customers.getCurrent),stores/customer.ts. Cf.modules/customer-membership.md.
Fetch customer allégé par défaut (ignoreTravels + lighter-response)
protectedApi.customers.getCurrent envoie toujours ignoreTravels=true et, par défaut, lighter-response=true (GET /api/customers, Horizon commit eb624fa5 — le nom du param est kebab-case uniquement).
- Ce que ça enlève :
ignoreTravels=true→travels: [], ce qui skippe côté Horizon le chargement d'unTravelService.Get(~40 includes) par booking — le coût dominant de l'endpoint ; le mobile ne lit jamaiscustomer.travels.lighter-response=true→loyaltyTransactions: null(les transactions brutes ;loyaltyTransactionsPrettyreste fourni, l'historique fonctionne donc sur le payload allégé). Tout le reste est identique (solde, pending, club, profil). - Opt-out plein payload :
fetchCurrentCustomer({ withLoyaltyTransactions: true })— utilisé uniquement par le mount deLoyaltyPoints.vue, seul consommateur des transactions brutes (getternextExpiringLoyalty, alerte d'expiration). - Préservation sur fetch allégé :
fetchCurrentCustomerremplacecustomer.valueentier ; un fetch allégé reporte lesloyaltyTransactionsdéjà connues au lieu de les écraser parnull— sinon un refetch Home/resumeblanchirait l'alerte d'expiration entre deux fetchs pleins (ex. resume de l'app pendant queLoyaltyPointsest affiché). - Login parallélisé :
login()lancefetchCurrentCustomer()(allégé) etfetchBookings()enPromise.all— le login n'attend plus que le plus lent des deux round-trips. Sémantique d'échec : échec customer → catch → logout de nettoyage +false; échec bookings → non-fatal, le login réussit (cf. « Le fetch bookings n'annule plus le login » plus bas). - Limite : l'appel NAV/Business Central côté Horizon (hydratation de
mobile) tourne toujours, allégé ou pas — le gain vient du N+1 travels et de la taille du payload, et croît avec le nombre de bookings du client. - Code :
services/api.ts(customers.getCurrent),stores/customer.ts(fetchCurrentCustomer,login),views/LoyaltyPoints.vue. Tests :stores/__tests__/customer.test.ts, e2eauth-login.spec.ts+loyalty-points.spec.ts(le mockGET /api/customersdeworld.tshonore les deux params).
Pages terminales = router.replace (pas back-reachable)
Les confirmations de paiement et de liste d'attente, ainsi que login/logout, naviguent en router.replace (et fullReset() pour les confirmations booking).
- Pourquoi : l'historique est l'unique back-stack (cf. ADR
0009).replaceévite qu'un « back » re-rentre dans un flux terminé (wizard payé, login déjà fait). Remplace l'ancienclearStack()de la pile de callbacks supprimée. - Code :
PaymentCallback.vue,WaitingListConfirmation.vue,Login.vue,Logout.vue.
Offres spéciales (rabais programmés)
Résolution des offres → lignes Discount (répliqué du site)
resolveSpecialOfferDiscounts(offers, ctx) (utils/special-offers.ts) réplique exactement le booking.js::initSpecialOffers du site. Les offres viennent de travel.specialOffers ∪ selectedTO.specialOffers (occurrence + voyage, cumulées — pas « la meilleure de chaque » comme les cartes teaser), filtrées sur la fenêtre [startDate, endDate] (aujourd'hui minuit, bornes incluses). Pour chaque offre éligible :
- Éligibilité : offre publique (
isForMembersOnly=false) → tout le monde ; offre membres → seulement sicustomer.hasActiveClubMemberShip(membre Club actif, invité exclu). isPerPerson+Value→value × nbPax(tous les passagers), écrase le ×2 couple. Libellé « Offre spéciale…: (X CHF × N pax = Y CHF) ».- Membre Couple + offre membres →
value × 2(aussi sur lesPercent— parité site, tranché avec le client juillet 2026). - Sinon →
valuenu. Chaque ligne :{ type, description, value, showOnConfirmation: false, isCroisitour: false };typepréservé (pour une offrePercent,value= le pourcentage, converti en CHF par la chaîne de prix). - Pourquoi côté client : Horizon ne re-dérive pas les offres au pricing (contrairement à l'early booking) — il applique
booking.discountsverbatim à la création de facture. Le mobile doit donc les émettre (finalBooking→output.booking.discounts, toujours, y compris[]), sinon la facture dépasseamountToPay. - Couple ×2 sans garde « > 1 passager » : ni le site ni Horizon n'ont de condition sur le nombre de passagers — le ×2 s'applique dès abo Couple + offre membres (choix client, la mémoire produit initiale d'un garde « >1 » n'existe dans aucun des deux codes).
- Code :
utils/special-offers.ts(resolveSpecialOfferDiscounts),composables/booking-constructor/pricing.ts(specialOfferDiscounts,specialOffersInput),stores/bookingConstructor.ts(output.booking.discounts). Tests :utils/__tests__/special-offers.test.ts, e2especial-offers.spec.ts.
Offre spéciale dans la chaîne de prix
computeBookingPricing reçoit specialOffers { valueTotal, percentTotal } (sommes pré-résolues) et applique la remise en parallèle du code promo : Value = CHF forfaitaire ; Percent = base × % sur la base hors suppléments globaux ET hors early booking (= base facture Horizon discountBase, identique à la base du code promo %).
- Divergence assumée vs site : le
cost.jsdu site applique le % surgetSubTotalCost()(suppléments globaux inclus) ; on suit Horizon (exclus) pour queamountToPaycolle à la facture — le site a un bug latent ici (ne mord que sur les offresPercent, rarissimes, les offres Buchard étant quasi toujours en CHF). Tranché avec le client. - N'entame pas l'acompte :
showOnConfirmation: false→ la remise réduit le total/solde, jamais l'acompte (paritécost.js::getDeposit, qui ne déduit que les discountsshowOnConfirmation). L'acompte est calculé sur le sous-total pré-offre ; toute la remise atterrit donc sur le solde (balanceAmount). - Ligne du résumé : une ligne par offre appliquée dans
TBPayment_Summary.vue(libellé + montant, suffixe(N%)pour lesPercent), miroir du reçuPrice.vuedu site. - Code :
utils/booking-pricing.ts(specialOfferDiscount),types/pricing.ts,components/travel-booking/payment/TBPayment_Summary.vue. Tests :utils/__tests__/booking-pricing.test.ts.
Affichage teaser du prix remisé (cartes, dates, en-tête détail)
SpecialOfferPrice.vue (+ perPersonOfferTeaser/totalOfferTeaser, utils/special-offers.ts) affiche le prix remisé selon deux modes (prop respectMembership) : prix brut barré + meilleur prix remisé + badge « Rabais Club Buchard » uniquement quand une offre membres bat le prix public (club < public). Une offre publique seule barre le prix sans badge. Le vrai prix selon l'adhésion reste calculé au paiement.
- Mode marketing (défaut) — cartes + en-tête détail : le meilleur prix club-inclus (
minPrice.pricePerPersonWithClubSpecialOffers) est montré à tout le monde, membre ou non. Divergence assumée du site (TravelCard.vuene montre le club qu'aux membres) : choix produit — pousser l'offre club en teaser d'appel sur le listing/détail, tous rabais affichés. - Mode respect-adhésion (
respectMembership: true) — étape date du wizard (TBStepDepartureDate) : un non-membre voit le prix brut (offres club cachées), un membre actif voit le prix club ; les offres publiques restent visibles pour tous (elles s'appliquent vraiment à tout le monde). Réplique la logique du site (TravelCard.vue/Occurrences.vue:pricePerPersonWithSpecialOfferspour tous,min(public, club)pour un membre). Pourquoi : à cette étape on entre dans le tunnel de réservation — le prix affiché doit coller à ce qui sera facturé. L'éligibilité s'appuie surhasActiveClubMemberShip(le même flag queresolveSpecialOfferDiscounts, cf. « Résolution des offres »), donc teaser = prix payé. Pas de badge à cette étape (comme le site). - Heuristique backend : les champs
minPrice.*WithSpecialOfferssont calculés par Horizon (PriceDto.Calculate) en ignorantisPerPersonet le ×2 couple, avec un facteur d'affichage — le teaser peut donc différer de quelques CHF du montant réellement débité. C'est exactement ce que le site affiche aussi (parité assumée, pas un bug mobile). - Surfaces : cartes catalogue/Seaside (
TravelCardBorder, Home + Seaside) et en-têteTravelDetailsen mode marketing ; sélection de dates du wizard (TBStepDepartureDate) en mode respect-adhésion. Hors périmètre (base de prix différente / widget non-teaser, laissés au prix brut, choix tranché avec le client) : liste « Dates et prix » du détail (TravelOccurrenceRow, baseoccurrencePrice= 1ᵉʳ prix chambre, ≠minPrice) etRecommendedTravelMaps(calculateur de points, pas un teaser de prix). - Code :
utils/special-offers.ts(perPersonOfferTeaser,totalOfferTeaser,OfferTeaser, paramsrespectMembership/isClubMember),components/cards/SpecialOfferPrice.vue(proprespectMembership, litcustomer.hasActiveClubMemberShip), consommé parTravelCardBorder.vue,TBStepDepartureDate.vue(:respect-membership="true"),views/TravelDetails.vue. La propshowClubPricedeTravelCardBorder(ex-usageClubBuchard) a été retirée (la carte s'auto-détermine). Tests :utils/__tests__/special-offers.test.ts, e2especial-offers.spec.ts.
Mes voyages (listing & détail des réservations)
Tri des sections « Mes voyages » (à venir / précédents / annulés)
MyTravels rend trois sections dérivées du store bookings :
- À venir (
futureBookings) — tri ascendant par date de départ (le plus proche d'abord). - Voyages précédents (
pastBookings) — tri descendant (le plus récent d'abord). - Voyages annulés (
cancelledBookings) — tri descendant, comme les passés.
Le tri passés/annulés partage la mécanique du filtre : _getFuturePastBookings applique inverter * (a.start − b.start) avec inverter = future ? 1 : -1 (un seul point de vérité : future ascendant, passé descendant). Les annulés (booking.general.status === BookingStatus.CANCELED, prédicat isCancelled) forment un bucket séparé et sont exclus de futureBookings/pastBookings (!isCancelled(b) dans le filtre).
- Pourquoi l'exclusion : sinon une résa annulée à date future/passée apparaîtrait en double (« à venir »/« précédents » et « annulés »). Effets de bord voulus, hors MyTravels : Home ne propose plus une résa annulée comme « prochain voyage » (
futureBookings[0]), et on ne propose plus de « laisser un avis » sur un voyage annulé (pastBookingsalimenteuncommentedPastBookings). - ⚠️ Limitation — occurrence requise (voyage dépublié → invisible) : les trois getters exigent l'occurrence dans
bookingIdsTravelOcc. OrGET /occurrences/:idetGET /travels/:slugrenvoient 404 si le voyage n'est pasPublished(OccurrenceController.GetById/TravelController.Get, Horizon). Donc une résa annulée dont le voyage est repassé enDraft/dépublié disparaît de toutes les sections (et sa cardBookingDetailséchouerait aussi —hasError). Le payloadgetAllne porte ni nom de voyage ni dates : pas de contournement mobile. Le rendre robuste demanderait un ajout backend (exposertravelName/dates dansBookingDto). - Code :
stores/bookings.ts(isCancelled,_getFuturePastBookings,cancelledBookings),views/MyTravels.vue(section « Voyages annulés » branchée sur le getter, boutons « Plus/Moins » Bootstrap collapse). Tests :stores/__tests__/bookings.test.ts.
Durée d'un voyage affichée = calcul depuis l'occurrence (pas travel.duration)
Sur la card d'une réservation (BookingCardBorder sur MyTravels, PendingTripCard — la section « Mes voyages » de la Home) et son détail (BookingDetails), la durée en jours vient de computeTravelDuration(travelOccurrence) (utils/travel.ts, 1 + floor((end − start) / jour)), pas du champ backend travel.duration.
- Pourquoi :
travel.durationest dérivé du découpageTravelDay[]côté Horizon ; les Seaside n'ont pas deTravelDay(séjour à durée variable) →travel.durationrevientundefinedet la card affichait « jour » sans nombre. L'occurrence portestart/endfiables. Pour un voyage catalogue les deux coïncident (durée = nuitées + 1) : rien ne change. C'est déjà la méthode du wizard (TBStepDepartureDate,TravelBookingIntro). - Garde :
BookingCardBordersousv-if="travel && !isLoading"etPendingTripCardsousv-if="!isLoading && travel"(⇒ occurrence chargée,isLoadingcouvre travel et occurrence) ;BookingDetailssousv-if="travelOccurrence". - Hors périmètre : les cards catalogue/recherche (
TravelCardBorder,TinyTravelCardSummary) n'ont pas d'occurrence unique sélectionnée → elles gardenttravel.duration(masqué pour les Seaside). - Code :
utils/travel.ts(formatComputedTravelDuration),components/cards/BookingCardBorder.vue,components/cards/PendingTripCard.vue,views/BookingDetails.vue. Test e2e :home.spec.ts(« derives a seaside duration from the occurrence »).
BookingDetails — badge « Voyage annulé » + actions du bas masquées
Pour une résa entièrement annulée (booking.general.status === BookingStatus.CANCELED, prédicat local isCancelled), BookingDetails affiche un badge « Voyage annulé » (même style que « Voyage terminé », icône calendar-delete_2.svg) qui prend le pas sur le badge passé (v-if annulé, v-else-if passé), et masque toute la barre d'actions du bas (Facture + Programme).
- Pourquoi masquer la barre : un voyage annulé est informationnel — pas de programme à télécharger, et le mobile n'a pas d'endpoint de facture d'annulation (seul
confirmation-invoiceexiste →CreateConfirmation;CreateCancellationn'est appelé qu'en back-office,Bookings/Cancel.cshtml.cs). Afficher « Facture » téléchargerait une confirmation, pas la note d'annulation → on retire l'action plutôt que d'induire en erreur. bookingOccurrenceStatereste par-date :BookingBasene calcule jamais'cancelled'(seulementpast/ongoingselon la date) — d'où le prédicat local basé sur le statut.- Distinct de l'annulation partielle : l'annulation d'un passager seul (
BookingPassenger.cancelled) garde le bookingConfirmed/Billed— le badge n'apparaît que surStatus === CANCELED. - Code :
views/BookingDetails.vue(isCancelled, badge,v-ifde la barre).
Routing & UI
« Découvrir » réinitialise toujours la recherche
Le bouton « Découvrir » de la BottomNavigation (RouterLink /) appelle clearSearch() + searchTrips() au clic, en plus de naviguer vers Home. Le lien Seaside (/seasides) a son miroir discoverSeaside() (seasideClearSearch() + searchSeaside()).
- Pourquoi : le
searchFilterest un singleton en mémoire etHome/Seasidesont<keep-alive>— sans ce reset, revenir via l'onglet réaffichait les résultats filtrés précédents. Le produit veut que l'onglet atterrisse toujours sur le catalogue complet (du listing concerné). - Snap haut de page : pas de scroll explicite à ajouter — le
watch(searchIsRunning)/watch(seasideSearchIsRunning)ramène déjà la liste en haut quand une recherche démarre, et une nav avant fait querestore()(scroll-restoration) repart à 0. - Code :
components/BottomNavigation.vue(discover,discoverSeaside), réutilise le patternshowFullCataloguedeHome.vue/Seaside.vue.
Catalogue (Home) vs balnéaires (Seaside) = ?seaside= + états indépendants
Home liste les voyages non-balnéaires (searchTrips → ?seaside=false), Seaside les balnéaires (searchSeaside → ?seaside=true). Les deux pages ont des filtres et résultats séparés (filterState/seasideFilterState, searchResult/seasideSearchResult).
- Pourquoi : le client veut deux parcours distincts ; filtrer l'un ne doit pas polluer l'autre. Le backend distingue les deux listings via le query param
seaside(émis sur les deux valeurs, y comprisfalse). Le champ TS correspondant estseaside(TravelSearchParams). - Composants : la recherche Seaside était une copie des composants
components/search/(choix assumé de duplication plutôt que paramétrage — cf. ADR0012). Depuis un release tweak, cette copie a divergé : Seaside n'a plus d'overlay de recherche avancée — saSeasideSearchBarest un simple champ texte (v-modelsurseasideFilterState.searchTerm) qui lancesearchSeaside()au submit, et le filtrage avancé (dates, destinations-pays) a disparu côté balnéaire. Le filtrage Seaside restant = champ texte + barre de catégoriesSeasideInterestsFilterBar. - Code :
stores/travels.ts(searchTrips/searchSeaside,nextPageSeaside),stores/searchFilter.ts(factory +seasideFilterState),services/api.ts(seaside→?seaside=),views/Seaside.vue,components/search/seaside/.
« Back » depuis les résultats de recherche = catalogue complet (Home uniquement)
Le panneau de recherche est un overlay ?overlay=search (useOverlayRoute). « Rechercher » remplace cette entrée par un marqueur ?search=1 (panneau fermé, résultats affichés) ; dépiler ce marqueur (back) sur Home efface le filtre et recharge le catalogue complet.
- Pourquoi : préserver l'ancien comportement « premier back = catalogue complet, deuxième back = sortie », désormais via l'historique réel au lieu de la pile de callbacks (cf. ADR
0009). - Comment :
Home.vueonSearched()(replace du marqueur) +watch(() => route.query.search)gardé surroute.path === '/'qui appelleshowFullCatalogue()quand le marqueur disparaît. Le gardepathévite que lewatch(Home<keep-alive>) se déclenche en quittant Home. - Seaside ne suit plus ce pattern : sans overlay, la
SeasideSearchBarlancesearchSeaside()directement (pas de?overlay=search, pas de marqueur?search=1). Le retour au catalogue complet balnéaire passe par l'onglet « Balnéaires » (discoverSeaside()) ou le lien empty-state (showFullCatalogue()), pas par un back. - Code :
Home.vue,components/search/Search.vue(onSearched).
Navigation « même page » = pas de re-loading (ni reset d'inset)
Le guard ui du router court-circuite setPageLoading (et le reset d'inset bas) quand la nav reste sur la même page :
to.path === from.path(query-only, ex. overlays) ;- ou une nav d'étape à étape du wizard (
sameWizardStep: mêmename+travelSlug, seul:stepNamechange) ; - ou un saut entre
TravelDetailset ses sous-pages (sameTravelCtx:to/fromdans{travel-details, travel-itinerary, travel-departures, travel-comments}, mêmetravelSlug). - Pourquoi : les étapes du wizard miroient l'étape active dans
:stepName, et les sous-pages voyage sont des routes plein écran qui réutilisent les données + l'inset de la page détail — sans ce court-circuit, chaque transition ré-affichait l'overlay de chargement (flash à la fermeture,TravelDetailsayantneedsLoading: true) et le footer wizard perdait sa hauteur mesurée. Le court-circuit ne touche pas la transition (pilotée parpageKey/path) — le slide joue toujours. - Code :
router/index.tsguardui(cf. ADR0010, ADR0012).
rawActiveStepIndex ≠ activeStepIndex
Le booking constructor maintient un index « brut » (toutes les étapes possibles) et un index visible (en sautant les étapes non applicables, ex: pas d'Accommodation si pas d'hôtel).
- Comment l'appliquer : index visible pour l'UI, index brut pour le miroir
:stepNameet la détection avant/arrière. - Référence : ADR
0009, ADR0010. - Mémoire :
project_back_nav.
keepAlive cible la vue par nom PascalCase
Les routes avec meta.keepAlive: 'Home' (par exemple) attendent que la vue déclare defineOptions({ name: 'Home' }).
- Pourquoi :
<keep-alive :include="[...]">matche sur le nom de composant. - Erreur classique : oublier le
defineOptionscasse le keep-alive ET le scroll restoration sans message d'erreur.
Retour piloté par l'historique navigateur réel
Il n'y a plus de pile de callbacks : chaque état « back-able » est une entrée d'historique réelle (page = route, étape wizard = segment :stepName, overlay = ?overlay=). « Back » = router.back() ; le geste iOS / bouton Android / back navigateur passent tous par là. stores/backButton.ts se réduit à setBackButtonRouter, le flag programmatic-nav, et goBack(). La direction du slide est dérivée du delta de history.state.position (cf. ADR 0010).
- Pourquoi : la pile maison désynchronisait de
window.history(sortie wizard viareplacequi enterrait les entrées d'étapes ;clearStack()manuel aux frontières). L'historique réel tient déjà cette info. - Référence : ADR
0009,docs/back-navigation.md. Overlays :useOverlayRoute.
Cycle d'import cassé par setBackButtonRouter(router)
Le module stores/backButton.ts n'importe pas @/router statiquement — le router lui est injecté depuis main.ts avant app.use(router).
- Pourquoi : sinon, cycle d'import
router → views → backButton → router. Symptôme :undefinedau runtime sur l'instance router.
Auth & API
Token expiré au démarrage = refresh transparent
apiClient (wrapper CapacitorHttp) rafraîchit proactivement un token expiré et, sur 401, tente un refresh (authStore.getRefreshedToken → authApi.refresh) avant de rejouer la requête une fois. Le refresh en vol est partagé entre appels concurrents.
- Conséquence : ne jamais appeler
protectedApi.*sans passer parapiClient(sinon perte du refresh). isTokenExpired: décodage base64url + marge 30 s + « non expiré » par défaut : le segment payload d'un JWT est en base64url — l'ancienatob()brut throwait sur-/_(et sur le padding absent), et le catch répondait « expiré » : refresh storm dépendant du contenu du token — un refresh + une rotation single-use par requête jusqu'à ce qu'une rotation produise un token « propre » (AUTH_BUG.md H4-d, bug confirmé dans le code — chaque rotation inutile est une fenêtre de perte-de-réponse H3/H5). Le décodage convertit désormais base64url → base64 + re-padding ; unexpnon numérique ou un parse raté → « non expiré » (le serveur tranche via le chemin 401 réactif) ; et une marge de 30 s (TOKEN_EXPIRY_MARGIN_S) refresh un peu en avance pour qu'un token ne meure pas en vol.- Code :
services/api-client.ts,stores/auth.ts(isTokenExpired). Tests :stores/__tests__/auth.test.ts(base64url, marge, malformé → non expiré).
Le fetch bookings n'annule plus le login
customerStore.login() ne conditionne plus le succès du login au retour de fetchBookings() : le fetch customer est le gate (échec → catch → logout de nettoyage — les tokens déjà écrits par authStore.login ne restent pas orphelins derrière un écran « échec de connexion » — puis false) ; le fetch bookings est non-fatal (il logge ses erreurs et renvoie false, le login réussit quand même).
- Pourquoi : de bons identifiants + un seul
GET /bookingsraté (blip, 500) se soldaient par unlogout()complet silencieux — « la connexion échoue » avec le bon mot de passe, y compris dans le modal de session expirée où l'utilisateur retapait son mot de passe « sans que ça marche » (AUTH_BUG.md H6). Les bookings se rattrapent tout seuls : HomeonActivatedet MyTravelsonMountedrefetchent à chaque passage (cf. « Rechargement des bookings à l'arrivée Home »). - Code :
stores/customer.ts(login). Tests :stores/__tests__/customer.test.ts(« login orchestration »), e2eauth-login.spec.ts(« a login still succeeds when the bookings fetch fails »).
Session expirée = logout complet + modal de connexion sur place
Quand la session est morte — refresh proactif échoué (ensureValidToken) ou 401 suivi d'un refresh/replay échoué — apiClient converge sur sessionExpired() : si une session existait et est définitivement morte (garde ci-dessous), logout complet via useCustomerStore().logout() (customer + bookings + arrêt du poll loyalty + reset analytics + tokens auth) + ouverture fire-and-forget du modal de connexion global (showLoginModal('Votre session a expiré. Veuillez vous reconnecter.'), cf. patterns.md → « Modal système global » et ADR 0018) ; dans tous les cas, throw ApiError(401, 'Authentication failed').
- Pourquoi : avant, la session expirée se soldait par un logout silencieux + le toast générique « Vous n'êtes pas autorisé… » — l'utilisateur restait sur une page en échec sans moyen de se reconnecter sans naviguer vers
/login(et perdre son contexte). Le modal permet la reconnexion sur place (le formulaire est le mêmeLoginFormque la page/login; une reconnexion réussie recharge customer + bookings viacustomerStore.login). - Pourquoi le logout complet (et pas seulement
auth) : ne vider que le store auth laissait le storecustomerpersisté peuplé — un demi-état zombie stable : UI connectée sur données périmées,/logininatteignable (requiresUnauth→ redirect Account), modal plus jamais ré-armé, et la carte « Contact » du paiement (PUT/customers) échouait en boucle → réservation impossible (AUTH_BUG.md H2, corrigé août 2026). - Statuts définitifs du refresh : 400, 401, 403, 423 (
stores/auth.ts,getRefreshedToken) : Horizon répond 400 « Invalid refresh token » pour un token mort/inconnu (rotation single-use — l'ancien token est supprimé avant même que la réponse parte, une réponse perdue laisse donc le client sur un token que le serveur ne connaît plus) et 423 quand le middleware compte-désactivé a vidé la liste des tokens ; il n'émet jamais 401/403 aujourd'hui (gardés pour un futur fix du contrat de statuts backend — AUTH_BUG.md H3). (Avant août 2026, seuls 401/403 étaient définitifs : le cas réel « token supprimé » (400) était classé transitoire pour toujours — pas de logout, pas de modal, erreurs en boucle.) Un 500 du refresh reste transitoire (indistinguable d'un blip serveur, même si certaines morts réelles surfacent en 500 côté Horizon). - Logout + modal seulement si la session est vraiment morte : la condition est
wasLoggedIn && epochInchangé && (token === null || refreshToken === null), à timing mixte —wasLoggedIn= l'intention de session, union des deux stores (authStore.email !== null || customerStore.isLoggedIn) snapshottée en entrée derequest(un rejet définitif du refresh fait passergetRefreshedTokenpar son logout interne, qui videemailavant quesessionExpiredne s'exécute), tokens lus en live au moment du modal (le store auth ne les vide que sur un rejet définitif et les préserve sur un échec transitoire), epoch comparé au snapshot d'entrée (cf. règle « Epoch de session » ci-dessous). Conséquences :- pas de session du tout (les deux stores vides) → ni logout ni modal ;
- demi-état désync (store
customerpersisté peuplé,authvide — l'état zombie hérité d'avant le fix, ou uncustomerégaré) → auto-guérison : logout complet + modal (l'unionwasLoggedInle couvre — avant,wasLoggedInne lisait queauth.emaildésormais null, donc plus jamais de modal) ; - échec transitoire du refresh (blip réseau / 5xx) → tokens survivants = session intacte : ni logout ni modal, le prochain appel protégé retente le refresh ;
- logout volontaire pendant la requête (epoch bumpé) → silencieux, cf. règle suivante ;
- rejet définitif → logout complet + modal.
- Le retry après refresh propage sa vraie erreur (dé-masquage, août 2026) : seul l'échec du refresh converge sur
sessionExpired— la requête rejouée après un refresh réussi throw son propreApiErrorréel (HTTP 400/409/500…), y compris un re-401 (rejet endpoint-level avec un token frais : propagé tel quel, pas de second refresh, pas de modal). (Avant, un seultryenglobait refresh + replay + check de statut : toute erreur du replay était réécrite enApiError(401, 'Authentication failed')— erreurs métier cachées derrière le toast générique et downgradées en warning PostHog. C'était la signature terrain : des warningsAuthentication failedsur des URLs sans rapport — AUTH_BUG.md H4-a/b.) - Pas de replay (v1) : la requête échouée throw toujours — elle n'est pas rejouée après reconnexion ; l'utilisateur retente son action. (Le pattern intercepteur avec file d'attente est explicitement reporté, cf. ADR
0018.) Conséquence assumée : lehandleErrorde l'appelant peut toaster en plus du modal. - Échecs concurrents dédoublonnés : plusieurs requêtes qui meurent en même temps (ex. Home relance customer + bookings) n'ouvrent qu'un modal — le logout complet de la première bumpe l'epoch, les suivantes voient un epoch changé et restent silencieuses (le service modal dédoublonne en plus par remplacement de descripteur).
- Le proactif est devenu un
ApiError(401): avant, l'échec du refresh proactif laissait fuiter l'erreur brute (Error('No refresh token available')ou l'erreur réseau du refresh) sans logout sur ce chemin ; les deux chemins émettent désormais le mêmeApiError(401)terminal. - Note :
getRefreshedToken(store auth) fait sonlogout()interne sur rejet définitif du refresh API — ce n'est plus une redondance : c'est le signal que lit le garde desessionExpired(tokensnullau fire-time = session définitivement morte), et la raison pour laquelleemaildoit être snapshotté avant. Ce logout interne (store auth seul) ne bumpe pas l'epoch — seul le logout applicatif (customerStore.logout()) le fait, sinon le rejet définitif supprimerait son propre modal. Ne pas « déduplicquer ». - Reporting PostHog : warning, pas error : l'
ApiError(401, 'Authentication failed')terminal est un cas attendu (logout + modal déjà déclenchés) — les funnelshandleError/logErrorle reportent avec$exception_level: 'warning'(predicateisSessionExpiredError+ constanteSESSION_EXPIRED_STATUS_TEXT,services/api-error.ts— lestatusTextest unique à ce chemin, un vrai 401 serveur throwHTTP 401et reste une error), etlogErrorle loggue enconsole.warn. Les funnels directs demain.ts(handler Vue,router.onError) ne downgradent pas : un session-expired non catché est un chemin réellement inattendu. Dans le même esprit, un échec de credentials au login (400mauvais mot de passe /404email inconnu surPOST /authentification) n'est pas capturé du tout — comportement utilisateur, pas une erreur d'app : le catch delogin()(stores/auth.ts) toaste « Erreur d'authentification » sans passer parhandleError; les échecs inattendus (5xx, réseau) gardent le cheminhandleErrorcomplet. - Code :
services/api-client.ts(sessionExpired),composables/modal.ts,components/LoginForm.vue,services/api-error.ts(isSessionExpiredError),services/error-handler.ts,stores/auth.ts(login,getRefreshedToken). Tests :services/__tests__/api-client.test.ts,services/__tests__/error-handler.test.ts,stores/__tests__/auth.test.ts, e2esession-expired.spec.ts(dont le cas 400 et l'auto-guérison du demi-état zombie).
Epoch de session : un logout invalide les réponses en vol
Toute écriture d'état de session qui suit un await réseau — fetchCurrentCustomer (store customer), fetchBookings (store bookings), l'écriture des tokens dans getRefreshedToken (store auth) — capture l'epoch de session avant l'await et re-vérifie avant d'écrire (services/session-epoch.ts, module leaf sans import). L'epoch est bumpé par le logout applicatif (customerStore.logout() — bouton « Oui, déconnecter », échec de login, sessionExpired) ; une réponse qui se résout après coup est jetée au lieu d'être écrite.
- Pourquoi : les refetch fire-and-forget (Home
onActivated, listenerresume, poll loyalty) encore en vol au moment du logout se résolvaient après et repeuplaient les stores persistés — « la déconnexion ne marche pas » au premier essai, retour Home reconnecté, et fabrication du demi-état zombie (customer plein / auth vide, survivant aux cold starts). Un refresh en vol réécrivait même des tokens vivants avecemail: null— sensible sur appareil partagé. AUTH_BUG.md H1 (reproduit par l'e2elogout-race.spec.ts, rouge avant ce fix) et H4-c2. - Le bump a deux effets de bord : (1)
apiClientjette sarefreshPromisepartagée (callback enregistré viaonSessionEpochBump— pattern d'injection typesetBackButtonRouter, le module epoch reste leaf) ; (2)sessionExpiredcompare l'epoch au snapshot d'entrée de la requête — epoch changé = l'utilisateur a fermé la session volontairement pendant que la requête était en vol → ni logout ni modal (fini le modal fantôme « session expirée » juste après une déconnexion volontaire). - Qui bumpe, qui ne bumpe pas : seul le logout applicatif (
customerStore.logout()) bumpe. Le logout interne du store auth sur rejet définitif du refresh ne bumpe pas — c'est le signal « session morte à l'instant » que litsessionExpiredpour montrer le modal ; s'il bumpait, il supprimerait son propre modal. Corollaire : le logout complet desessionExpiredbumpe, ce qui rend silencieuses les requêtes mourantes concurrentes (un seul modal). - Poll loyalty : le tick de
startLoyaltyReconciliationre-vérifieisLoggedInavant de refetcher (ceinture :revertOptimisticLoyaltydu logout arrête déjà le timer). - Code :
services/session-epoch.ts,stores/customer.ts(logout,fetchCurrentCustomer),stores/bookings.ts(fetchBookings),stores/auth.ts(getRefreshedToken),services/api-client.ts(constructor +sessionExpired). Tests :stores/__tests__/{customer,bookings,auth}.test.ts(courses à mock différé),services/__tests__/api-client.test.ts, e2elogout-race.spec.ts.
Dates sortantes : locales et sans fuseau à la frontière HTTP
Toute Date vive d'un payload sortant est convertie par serializeWireDates (utils/date-formatters.ts) en ISO local, sans fuseau (yyyy-MM-dd'T'HH:mm:ss) — appliqué aux deux frontières HTTP (publicFetch dans services/api.ts, apiClient.request dans services/api-client.ts, sérialisé une fois et réutilisé par le retry 401). Date invalide → null (parité JSON.stringify) ; les strings ne sont jamais réécrites.
- Pourquoi :
Date.toJSON(la sérialisation JSON implicite) émet de l'ISO UTC (…T22:00:00Z). Vérifié dans les deux repos backend : la chaîne website (MobileBookingApiController, System.Text.Json sans config date) → Horizon (Newtonsoft,RoundtripKind) ne normalise jamais vers l'heure locale — une birthdate minuit-local partait en1986-04-11T22:00:00Zet était stockée 1986-04-11 dans Horizon (off-by-one réel, aussi surcheckIn/checkOut). Les formats sans fuseau sont parsésKind=Unspecifiedaux deux hops et gardent la date calendaire — c'est ce que le site web (client qui marche) envoie (yyyy-MM-dd). - Format uniforme, pas d'heuristique : le même format sert les valeurs date-only (minuit local →
T00:00:00) et les vrais datetimes (bookingDate,checkIn/checkOutgardent l'heure murale). Ne jamais émettre de suffixeZou+hh:mmvers le backend. - Conséquence pour les endpoints : passer le body en objet brut — un
body: JSON.stringify(...)contenant desDatefige l'UTC avant la frontière (c'était le bug depaymentApi.initialize, depuis corrigé en passant l'objet). Cf.patterns.md→ « Ajouter un endpoint API ». - Code :
utils/date-formatters.ts(serializeWireDates),services/api.ts,services/api-client.ts. ADR0016. Tests :utils/__tests__/date-formatters.test.ts, assertions wire dans les 3 specs wizard.
Dates serialisées en yyyy-MM-dd
customer.create reformate birthdate avant POST ; customer.update reformate birthdate, clubMembershipStart, clubMembershipEnd avant PUT — seulement quand la date est définie (helper asDateOnly, champ omis sinon). (Depuis la règle « Dates sortantes » ci-dessus, ce pré-formatage est une défense en profondeur — le walker de frontière laisserait de toute façon partir ces dates en local sans fuseau.)
- Pourquoi : le backend attend ce format strict, pas du ISO 8601 datetime.
- Pourquoi le garde :
formatDate(date-fns) throw surundefinedet mappenullsur1970-01-01. Un client hors Club n'a pas declubMembershipStart/End: sans le garde, le « Valider » connecté de la carte Contact du paiement (qui fait toujours un PUT, cf. « Carte “Contact” au paiement ») échouait avant même la requête (toast d'erreur, carte jamais repliée → « Payer » inatteignable), et unnullsur le wire aurait écrit une adhésion Club 1970 sur la fiche.
customer.exists ↔ HTTP 200
Renvoie true si HTTP 200 (email pris), false sinon. Pas de body interprété.
- Pourquoi : endpoint optimisé pour le pre-check d'inscription.
Suppression de compte = demande via formulaire de contact
Le bouton « Demander la suppression de mon compte » (views/DeleteAccount.vue) ne supprime pas le compte côté app — il ouvre le formulaire de contact https://buchard.ch/contact dans l'in-app browser (openTrackedLink, campagne UTM delete-account). La suppression est ensuite traitée manuellement (workflow RGPD) par Buchard à réception de la demande.
- Pourquoi : pas d'endpoint de suppression self-service exposé au mobile ; la demande passe par un humain (vérification d'identité, obligations RGPD). Le libellé du bouton dit « Demander la suppression » et non « Effacer » pour refléter l'action réelle.
- Code :
views/DeleteAccount.vue,utils/trackedBrowser.ts. Cf.modules/customer-membership.md.
Analytics produit
Consentement analytics opt-in préalable (PostHog ne collecte rien sans « Accepter »)
L'analytics PostHog est gouvernée par un consentement explicite et persisté (store analyticsConsent, statuts unknown/accepted/refused). Tant que le statut n'est pas accepted, posthog.init() n'est jamais appelé — aucune requête (pas même la remote-config d'init), aucun cookie/persistence, aucun event. Au premier lancement (statut unknown), le modal système « Améliorer l'application » propose Accepter / Refuser (ouvert depuis le onMounted de BuchardApp, fire-and-forget).
- Accepter → init immédiate (les events de la session courante partent,
identifysi connecté) puis init au cold start des lancements suivants (main.ts, gated surisAccepted). - Refuser → persisté, plus jamais re-demandé par le modal — aucun impact fonctionnel sur l'app.
- Dismiss (backdrop / ✕ / back natif) → reste
unknown: pas de tracking cette session, re-demandé au prochain cold start. - Pourquoi l'init différée : vérifié dans
posthog-js— l'init émet toujours une requête remote-config et pose sa persistence, même enopt_out_capturing_by_default; seul le non-appel garantit « zéro collecte ». Les events émis avant consentement sont perdus (pas de buffer) : c'est le sens du refus. - Code :
stores/analyticsConsent.ts,services/analytics.ts(façade unique),src/BuchardApp.vue,src/main.ts. Cf. ADR0019,patterns.md→ « Émettre de l'analytics produit ». Tests :stores/__tests__/analyticsConsent.test.ts, e2eanalytics-consent.spec.ts.
Consentement analytics modifiable depuis « Autres » (collapsible Confidentialité)
La décision est révocable/modifiable à tout moment depuis l'écran Autres : la ligne « Confidentialité » se déplie (Collapsible, fermée par défaut) sur le texte « Autoriser Buchard à collecter mes informations anonymes à des fins d'amélioration et de statistiques » et deux radios horizontaux Accepter / Refuser reflétant analyticsConsent.status (unknown = aucun coché).
- Les side effects PostHog vivent dans les actions du store, pas dans l'UI :
accept()= persiste +initAnalytics()+identifyUser(id seul) ;refuse()= persiste +disableAnalytics(). Le modal de démarrage et les radios passent par les mêmes actions — un futur point d'entrée doit faire pareil. - Refus en cours de session : PostHog étant déjà initialisé,
disableAnalytics()appelleposthog.opt_out_capturing()— plus rien ne part dès le tap. Le vrai gate reste le store : au prochain cold start, statutrefused⇒initAnalytics()jamais appelé. - Ré-acceptation :
initAnalytics()lève l'opt-out (opt_in_capturing({ captureEventName: null })— pas d'event$opt_inparasite), que ce soit dans la même session (déjà initialisé) ou à une session ultérieure (l'opt-out persisté par le SDK est levé juste après l'init, sinon il bloquerait la capture alors que le consentement est revenu). - Code :
views/Others.vue(consentChoice),stores/analyticsConsent.ts(accept/refuse),services/analytics.ts(disableAnalytics, levée d'opt-out dansinitAnalytics). Tests :services/__tests__/analytics.test.ts(« consent revocation »),stores/__tests__/analyticsConsent.test.ts, e2eothers.spec.ts.
Analytics pseudonymisée : aucune PII ne part vers PostHog
Ce qui part vers PostHog est pseudonyme : l'identify ne transmet que l'id client (UUID Horizon) — jamais le nom ni l'email ; la résolution de l'id vers une personne reste dans le back-office Horizon. Trois défenses posées dans initAnalytics() (services/analytics.ts) :
- Autocapture masquée (
mask_all_text+mask_all_element_attributes) : le texte et les attributs des éléments cliqués ne partent jamais — dans cette app, des données réelles sont cliquables (lignes dePassengerSelectModalavec les noms, résumé de la carte Contact avec l'adresse…). - Hook
before_send(scrubPii) : toute adresse email (brute ou URL-encodée) est remplacée par[email]dans chaque string sortante, récursivement — couvre notamment unApiErrordont l'URL embarque l'email (/customers/email-exists?email=…) capturé par les funnels d'erreur. - Session replay masqué d'avance (
maskAllInputs+maskTextSelector: '*') : si le replay est un jour activé côté projet PostHog, il démarre entièrement masqué.
- Règle pour les nouveaux events : les props d'un
captureEventne portent que des ids/compteurs/montants/slugs — jamais nom, email, téléphone ni texte saisi (letravel_searchedenvoiehas_search_term, pas le terme). - Code :
services/analytics.ts(identifyUser,scrubPii, config d'init). Tests :services/__tests__/analytics.test.ts.
OTA & flavors
Canal Capgo choisi par suffixe de bundle id
.development → canal dev, .staging → staging, sinon production. Lu dans main.ts au démarrage. (Historique : le code testait .dev, qui ne matchait jamais le vrai suffixe .development des projets natifs — les installs dev tombaient silencieusement sur le canal production, sans dégât car le flavor dev a autoUpdate: false. Corrigé juillet 2026.)
- Pourquoi : permet d'installer les trois flavors côte à côte sur le même device avec des canaux OTA distincts.
- Conséquence : un bundle n'est pas promu d'un canal à l'autre — chaque flavor a sa build.

