Skip to content

Module — Clients & Adhésions

Synchronisé avec le code. Mettre à jour à chaque changement structurel.

Rôle

Fiche client (Particulier / Société / Agence partenaire), Club Buchard (adhésion annuelle Single ou Couple), abonnements newsletter, centres d'intérêt, authentification API pour le site web et l'app mobile.

Emplacement

  • Pages : src/Web/Pages/Customers/ (dont AbandonedClub.cshtml — paniers abandonnés Club)
  • Services : src/Application/Services/Entities/CustomerService.cs, MembershipService.cs, SubscriptionService.cs, InterestService.cs, AuthService.cs, LoyaltyService.cs (points de fidélité)
  • API : src/Web/Areas/Api/CustomerController.cs, LoginController.cs
  • Templates PDF : ClubMembershipSubscription.liquid, ClubMembershipRenewal.liquid
  • Templates email Razor : src/Application/Templates/

Entités principales

  • Customer — fiche client.
    • TypeCustomerType : Private (particulier), Company (société), Agency (agence partenaire).
    • Civility : string ("Madam"/"Sir"/"Other") — même pattern que Passenger.Civility.
    • ClubMembershipType : None/Single/Couple.
    • ClubMembershipCode : code adhérent (numéro d'identification).
    • PaymentType : ⚠ stocke ici uniquement le moyen de paiement utilisé pour souscrire au Club en ligne. Pas une préférence générale.
    • DiscountType : remise applicable (utilisé pour les agences avec rabais négocié).
    • NavNo : lien BC (compte client).
    • Beaucoup de coordonnées (Address, Zip, City, Phone, Mobile, Email…).
    • IsGuest : compte créé pour finaliser une réservation web sans inscription.
    • Points de fidélité : un Customer porte la navigation LoyaltyTransactions ; son solde est exposé à l'API (LoyaltyPoints = disponibles, PendingLoyaltyPoints = pas encore valides). L'adhésion au Club crédite 100 pts (1×, LoyaltyTransactionType.ClubMembershipJoin). Détail : .claude/docs/modules/loyalty-points.md.
  • CustomerInterest : centres d'intérêt (sélection dans Interest referential) pour mailing/marketing.
  • CustomerSubscription : abonnements (newsletter, Subscription referential).
  • CustomerRefreshToken : token de refresh JWT pour l'API.
  • Country : pays référentiel (utilisé sur Customer + Travel).
  • Subscription, Interest : référentiels pour les selects.

Règles métier spécifiques

  • CustomerType.Agency = agences de voyage partenaires. Même flow qu'un Private/Company, mais certaines agences ont une remise négociée (Customer.DiscountType + Customer.Discount value). Deux exceptions au « même flow » : aucune assurance annulation et aucun point de fidélité (cf. domain/business-rules.md).
  • Club Buchard :
    • Adhésion annuelle, niveaux Single (individuel) ou Couple.
    • Donne accès à des SpecialOffer filtrées et à un discount auto-appliqué côté Vue (cf. ADD_CLUB_MEMBERSHIP_BOOKING_DISCOUNT mutation : si Couple, value × 2, sinon value).
    • Renouvellement annuel → InvoiceCategory.MembershipRenew.
  • Guest customer : créé automatiquement côté site web pour finaliser une réservation sans inscription. Worker.CleanGuestCustomers (daily) supprime les guests sans booking, gift ni membership rattaché.
  • Abonnements catalogues (CustomerSubscription) — 5 entrées au référentiel Subscription : Principal, Balnéaire, Automne-hiver-printemps, Majorque, Voyages d'une vie. Le formulaire back-office (Customers/Form.cshtml, bloc « Abonnement catalogues ») poste toujours les 5 lignes, cochées ou non — un client créé en back-office a donc 5 lignes CustomerSubscriptions dès sa création, la coche n'étant que le booléen Enabled.
    • Le catalogue Principal est pré-coché à la création (Customers/Create.OnGet, août 2026, demande Buchard) via la constante Subscription.MainCatalogId. Motif : les collègues devaient cocher à la main et l'oubliaient — sur les clients créés en back-office en 2026, 32 % seulement portaient la coche, les autres ne recevaient jamais le catalogue. La modification d'un client ne touche à rien (Update.OnGet reprend l'existant tel quel) : la valeur par défaut ne s'applique qu'aux nouveaux clients, aucun rattrapage rétroactif n'a été fait.
    • Les créations via l'API (site web / app) ne créent aucune ligne d'abonnementCustomerController.Create et LoginController (inscription) mappent le DTO sans toucher à CustomerSubscriptions. Sur 2026, 2654 clients sont dans ce cas contre 2610 créés en back-office. Le pré-cochage ci-dessus ne les concerne donc pas : un client web n'est jamais abonné au catalogue papier tant qu'un collègue n'ouvre pas sa fiche. Question laissée ouverte (consentement marketing) — cf. tâche Asana 1217144739079198.
    • La liste des catalogues cochés alimente la lettre catalogue (PdfGeneratorService, CustomerCatalogNames → lien « Lettre catalogue » de la fiche).
  • Sync NAV : à chaque création/MAJ d'un Customer, NavCustomerService.CreateAsync/UpdateAsync envoie les données vers BC. Customer.NavNo revient.
  • Civility : utilisé en fallback pour le Passenger.Civility quand non renseigné (cf. Helvetic salutation).

Points d'attention / pièges

  • GET /api/customers — la réponse porte les voyages, et c'est le poste lourd. Pour chaque réservation du client, la réponse embarque un TravelDto complet (52 champs, 12 collections : jours, photos, lignes, trajets, avis, offres spéciales, occurrences…). Deux options pour l'alléger, toutes deux opt-in :
    • ?ignoreTravels=true — historique, utilisé par personne : ni le site (HorizonApiConsumer.GetCustomer appelle customers tout court, méthode elle-même « used a lot ») ni l'app. Le bon réflexe sur tout écran qui n'affiche pas de voyage.
    • ?lighter-response=trueajouté août 2026 (Asana 1216224721212421) : travels ne contient plus que Id, Type, Name, Slug, Subtitle, Duration, DepositPercentage, une seule image et les seules occurrences réservées par ce client (Id/Start/End), avec une entrée par voyage au lieu d'une par réservation. Construit à partir de ce que CustomerService.Get charge déjà ⇒ aucun chargement de voyage. Le brut loyaltyTransactions est également omis (loyaltyTransactionsPretty et le solde restent).
    • L'image vient de ITravelService.GetMainPictures(travelIds)une seule requête pour tous les voyages de la réponse, jamais une par voyage. Sélection : la photo IsCard (celle que le front utilise déjà pour illustrer un voyage, cf. TravelCard.vue), à défaut la première par Order. pictures est toujours une liste (vide si le voyage n'a aucune photo), jamais null : le front itère sans garde.
    • Le mode par défaut a par ailleurs perdu son N+1 le plus bête : chaque voyage n'est plus chargé qu'une fois même si le client a plusieurs réservations dessus (la réponse, elle, garde une entrée par réservation — contrat inchangé). Cas réel : jusqu'à 54 réservations sur un même client en prod.
  • Customer.PaymentType ≠ préférence générale : ce champ a été utilisé "ad-hoc" pour stocker le moyen de paiement utilisé lors de la souscription Club en ligne. Ne pas l'interpréter comme une préférence du client.
  • Mot de passe : double hash possible. Les anciens comptes (migrés de WordPress) ont leur password en hash WP, fallback dans WordPressPasswordHasher. Les nouveaux comptes utilisent BCrypt.
  • CustomerRefreshToken : penser à invalider à la déconnexion + à expirer côté DB (TTL).
  • IsGuest peut être recyclé : si un guest finalise enfin une inscription, le compte devient régulier (à vérifier en code).
  • Champs adresse multilignes : un Customer peut avoir des champs Address1/Address2/Zip/City/... ; pour les factures/emails, BillService ou les templates Liquid gèrent la concaténation.
  • CleanGuestCustomers timing : daily — un guest qui aurait quelques heures de battement avant validation reste protégé. Mais un crash worker peut décaler le nettoyage.
  • ClubMembershipStart ne bouge JAMAIS au renouvellement — seul ClubMembershipEnd avance d'un an (MembershipService.RenewMembershipAsync, commentaire « préserver la date anniversaire »). Le « Depuis le … » du bandeau client affiche donc la date d'adhésion initiale, pas la période en cours : un membre depuis 2025 affichera « Depuis le 05.08.2025 » indéfiniment. Ça a déjà fait croire à un renouvellement raté (juil. 2026) → le bandeau affiche désormais aussi « Valable jusqu'au » (ClubMembershipEnd, Customers/_SummaryBanner.cshtml). Pour vérifier qu'un renouvellement a bien eu lieu, regarder ClubMembershipEnd et la facture InvoiceCategory.MembershipRenew (= 3, pas 1 : 0 Booking / 1 Gift / 2 Membership / 3 MembershipRenew).
  • ⚠ État transitoire « adhésion pas encore activée » : ClubMembershipType != None AVEC ClubMembershipStart == DateTime.MinValue. C'est l'état d'une souscription en ligne en attente de paiement : OnlinePaymentService:538-540 remet volontairement Start/End à MinValue et ne repose les dates (via CustomerService.CreateClubMembershipInvoice) qu'une fois le paiement approuvé. Le back-office, lui, force ClubMembershipCreateInvoice = true (Create.cshtml.cs:51, Update.cshtml.cs:361) donc y pose toujours dates + facture immédiatement. Un paiement en ligne jamais confirmé (abandon, RekaCheck envoyé par poste, > 4 tentatives de CheckPendingPayments) laisse donc le client dans cet état indéfiniment.
    • Bug corrigé juil. 2026 : RenewMembershipAsync ne testait que ClubMembershipEnd < limit, or MinValue < limit est toujours vrai → une adhésion jamais commencée était traitée comme une adhésion expirée et « renouvelée ». Résultat : facture de renouvellement émise (et postée dans BC) pour un client qui n'avait pas encore adhéré, puis seconde facture d'adhésion quand la souscription se finalisait. 8 clients touchés entre mars et juil. 2026, signature typique = facture MembershipRenew à 06:00 pile le jour même de la facture Membership. Le filtre exclut désormais ClubMembershipStart == DateTime.MinValue.
    • ⚠ Piège résiduel non corrigé : OnlinePaymentService:538-544 remet Start/End à MinValue inconditionnellement mais ne met ClubMembershipCreateInvoice = true que si createInvoice est vrai. Dans la branche « facture existante encore partiellement due » (remainingAmount > 0createInvoice == false), les dates sont donc effacées sans être reposées — le client retombe dans l'état « jamais activée ».
  • Écran « Clients > Adhésions club abandonnées » (Customers/AbandonedClub, juil. 2026) — rattrapage manuel des adhésions Club restées sans paiement. Jusque-là, seuls Booking et Gift avaient un bouton « Relancer » ; l'adhésion n'en avait aucun, et un client bloqué après 4 échecs de CheckPendingPayments ne pouvait plus être débloqué que par un nouveau tunnel de paiement depuis le site.
    • Deux populations distinctes dans la même liste, car elles appellent des actions opposées :
      1. ClubMembershipType != None && ClubMembershipStart == MinValue → souscription web/app jamais payée. ClubMembershipCreateInvoice n'étant mis à true que par le back-office (Create.cshtml.cs:51, Update.cshtml.cs:361) et par OnlinePaymentService:544 après approbation, l'API ne crée ni dates ni facture : le client reste dans cet état indéfiniment s'il ferme l'onglet avant de payer. Aucune relance possible — il n'y a aucune transaction à recapturer.
      2. !PaymentSuccessful && (TransactionId renseigné || SwissBillingTransactionDate != MinValue)tentative de capture en cours ou abandonnée (périmètre exact du scan CheckMembershipClub). Relançable.
    • Relance interdite si ClubMembershipType == None — garde UI et serveur (RelaunchClubMembershipPaymentAsync). IsPaymentApproved(Customer) calcule Type == Single ? 39 : 59 (OnlinePaymentService:883, montants codés en dur) : un None tombe dans la branche else et capturerait 59.– pour un client sans adhésion. Ce cas existe en base (Abdou Olgatte, 135289 : transaction Saferpay abandonnée, type None).
    • Colonne « Statut paiement » : même échelle que bons cadeaux et réservations — 0 vérification en cours, 1 abandonnée (OnlineBookingPaymentStatusCheckCount == int.MaxValue, seul cas où « Relancer » s'affiche), 2 aucune tentative.
    • PaymentSuccessful == false ne signifie PAS « n'a pas payé » : il ne trace que la capture d'un paiement en ligne. Toute adhésion créée en back-office laisse ce flag à false à vie — en juil. 2026, 209 membres actifs sur 210 étaient dans ce cas. Ne jamais bâtir un filtre « impayé » dessus seul ; c'est la combinaison avec ClubMembershipStart == MinValue ou avec une transaction pendante qui fait sens.
    • « Annuler » (CustomerService.CancelClubMembershipAsync) remet ClubMembershipType à None et vide code, dates, TransactionId, SwissBillingTransactionDate, compteur et PaymentSuccessful. But : rendre le client définitivement invisible pour RenewMembershipAsync, en complément de la garde ClubMembershipStart != MinValue. PaymentType est laissé tel quel (l'enum n'a pas de valeur neutre, le remettre à 0 écrirait « CashLeytron »).
    • La liste ne filtre pas les adhésions réellement actives — « Annuler » peut donc détruire une adhésion payée (incident CGR10710, août 2026, Asana 1217138985676730). La branche 1 ne teste que ClubMembershipStart == MinValue : une adhésion dont le Start n'a jamais été posé mais dont le End est dans le futur y apparaît, avec le statut « Aucune tentative » puisqu'il n'y a pas de TransactionId. Le collègue la prend pour une trace obsolète et clique « Annuler », qui efface type, dates et code d'un membre à jour de cotisation.
      • Chronologie vérifiée : le lundi 20.07.2026 à 06:00, RenewMembershipAsync — encore dépourvue de la garde ClubMembershipStart != MinValue — « renouvelle » Girard Bernard (Type = Couple, Start = End = MinValue). Branche « expirée depuis longtemps » ⇒ End = Now.AddYears(1) = 20.07.2027, facture MembershipRenew 127836 de 59.– (NAV 26122659), Start jamais reposé. La garde et l'écran des adhésions abandonnées arrivent le lendemain (b3e75c92, 21.07.2026) : il y figure aussitôt. Le client paie les 59.– le 29.07 (BVR153007), puis un « Annuler » le 04.08 vide sa fiche. Il a donc payé une adhésion qu'il n'a plus.
      • Population concernée : 1 seul client. Sur 929 membres (Type != None), 928 ont Start et End renseignés ; 1 a Start vide + End renseigné — Girard. Et 0 client a Type = None avec un End futur. Le cas n'est donc pas systémique : c'est le dernier résidu du bug de renouvellement, la garde du 21.07 empêchant tout nouveau cas.
      • Détecter les victimes passées est impossible : l'annulation n'écrit aucune trace (pas de table d'historique) et laisse exactement le même état qu'un « Ne pas renouveler » traité par le worker ou qu'une remise à None dans le formulaire client (Update.cshtml.cs:363 efface aussi code et dates). Seul discriminant partiel : RenewMembershipAsync conserve ClubMembershipCode alors que CancelClubMembershipAsync l'efface — insuffisant, beaucoup de fiches n'ont jamais eu de code.
      • Garde posée (août 2026) : CancelClubMembershipAsync refuse désormais quand ClubMembershipEnd > DateTime.Now, et renvoie l'échéance dans le message. Symétrique de la garde « Relancer » (Type == None), et volontairement placée côté service et non dans le filtre de la liste : elle protège quelle que soit la voie par laquelle un membre actif atterrit dans l'écran, y compris celle d'OnlinePaymentService ci-dessous. Retirer une adhésion active reste possible, mais par la fiche client, où l'on voit ce qu'on efface. Le filtre de la liste est donc laissé tel quel (l'exclure des End futurs serait redondant).
      • Effet du merge de feature/customer-fusion (f718aac8) : neutre. Le commit remplace == MinValue par le seuil ClubMembershipDates.Unset des deux côtés — renouvellement (MembershipService) et filtre de la liste (CustomerService.SearchAsync) — donc pas de divergence. Le seuil élargit la liste aux dates à l'epoch 1970, mais les 79 fiches concernées ont toutes Type = None et restent donc hors du filtre : 0 ligne ajoutée. CustomerMergeService.ApplyClubMembership traite par ailleurs le Club comme un bloc (type + code + start + end recopiés ensemble) — une fusion ne peut donc pas fabriquer l'état « type posé, dates vides », seulement recopier celui d'une fiche source qui l'aurait déjà.
      • La voie d'entrée n'est pas totalement fermée : OnlinePaymentService:538-542 remet Code/Start/End à vide inconditionnellement et ne repose ClubMembershipCreateInvoice que si createInvoice est vrai. Dans la branche « facture existante encore partiellement due » (remainingAmount > 0), un client qui vient de payer se retrouve Type != None avec les deux dates vides — donc listé comme abandonné, visuellement indiscernable d'une souscription jamais activée. 0 cas en base au 30.07.2026, mais tant que CancelClubMembershipAsync n'a pas de garde serveur, l'incident reste reproductible par cette route.
    • Les factures ne sont pas touchées par l'annulation. Une facture Club déjà postée dans BC reste due ; seule la modale de confirmation le rappelle (une colonne « Facture Club » a existé brièvement puis a été retirée — l'écran ne montre donc plus si une facture existe). Cas concret : Girard Bernard (CGR10710) n'a aucune facture d'adhésion mais porte la facture de renouvellement 127836 émise par le bug.
    • Effet de bord CleanGuestCustomers : annuler l'adhésion d'un client IsGuest sans booking ni bon le rend éligible au nettoyage quotidien, qui le supprime aussi de NAV (_navCustomerService.Delete).
    • Le filtre est porté par CustomerSearchModel.AbandonedClubMembership, appliqué directement dans CustomerService.SearchAsync (pas de méthode de recherche dédiée). Quand le drapeau est levé, SearchAsync retourne avant le merge NAV : ce dernier écarte tout client absent de NAV et renvoie une liste vide si NAV échoue, or un panier abandonné est souvent un compte tout juste créé.
    • La modale de confirmation réutilise le contrat du modal de suppression (form#modal-form-delete, #modal-form-delete-error, button.btn-danger, référence kt_abandoned_club_modal_delete) pour être câblée par onDeleteModalShow/remove() sans toucher à Datatables.js (qui exigerait un gulp sur Frontend/Custom). remove() fait un GET et recharge la datatable.
  • Le renouvellement anticipe de 30 jours : RenewMembershipAsync (worker hebdo, lundi 06:00, DoWeeklyJob) sélectionne ClubMembershipEnd < Now.Date.AddDays(30). Une adhésion échéant le 05.08 est donc renouvelée début juillet — chercher la facture à la date d'échéance exacte ne donne rien. Filtre complet : ClubMembershipEnd < limit && ClubMembershipType != None && c.Enabled — ⚠ un client Enabled = false n'est jamais renouvelé, silencieusement.

Conventions locales

  • Onglet « Points de fidelité » (fiche client back-office) : Customers/Form.cshtml affiche l'historique des points de fidélité dans un onglet dédié. La liste est la version « pretty » des transactions (LoyaltyTransactionPrettyDto) — 1 ligne par transaction hors-booking marquée [LoyaltyPretty], + 1 ligne agrégée par booking (points gagnés à l'achat + somme des points dépensés non remboursés), triée CreatedAt DESC. Elle est construite par ILoyaltyService.BuildLoyaltyTransactionsPretty(customer.LoyaltyTransactions) (méthode partagée par l'API mobile CustomerController.Get et la PageModel back-office Customers/Update), puis passée au partial Form.cshtml via ViewData["LoyaltyTransactionsPretty"]. La méthode ne lit que des champs scalaires → le simple Include(c => c.LoyaltyTransactions) de CustomerService.Get suffit (pas besoin des navigations Booking/LinkedTransaction).
    • Date affichée = date d'achat réelle (pas le recalcul) : quand une résa est modifiée après coup, InvoiceService re-déclenche un cycle refund → recrédit, ce qui crée un nouveau BookingPurchase daté du recalcul (CreatedAt). L'ancien BookingPurchase subsiste (table append-only, PointsRemaining remis à 0). Pour que la ligne pretty affiche/trie sur la vraie date d'achat, BuildLoyaltyTransactionsPretty prend purchaseDate = Min(CreatedAt) parmi les BookingPurchase du groupe et le met dans LoyaltyTransactionPrettyDto.CreatedAt (les fronts web + mobile consomment ce champ inchangé). ⚠ La détection d'annulation (latest = OrderByDescending(CreatedAt).First() → si le dernier mouvement n'est pas un BookingPurchase, la ligne est masquée) reste, elle, sur le CreatedAt réel — ne pas basculer cette détection sur la date d'achat, sinon une résa active recréditée passerait pour annulée. C'est un choix display-only : aucun champ stocké, aucune migration.
    • Points « en attente » : les points gagnés à l'achat d'un voyage (BookingPurchase) ne sont valides qu'à partir de la date de retour du voyage (LoyaltyTransaction.ValidFrom = occurrence.End). Tant que ValidFrom est dans le futur, ils comptent dans Customer.PendingLoyaltyPoints (calc. via GetPendingPointsAsync) et pas dans Customer.LoyaltyPoints (solde disponible, GetAvailablePointsAsync) — les deux sont peuplés dans CustomerService.Get. Dans l'onglet, l'en-tête affiche « Solde de points » + un badge « X en attente » (si > 0), et chaque ligne dont les points gagnés ne sont pas encore valides porte un badge « En attente » ; les deux ont un tooltip Bootstrap précisant qu'ils le deviendront à la date de retour du voyage (badge de ligne : date exacte via ValidFrom). Les tooltips sont initialisés dans onMapLoaded de Form.cshtml (new bootstrap.Tooltip(...)).
    • Libellés : LoyaltyTransactionType porte des [Display(Name=...)] (français), affichés via GetDisplayName(). ⚠ EnumExtensions.GetDisplayName n'a pas de null-check → tout membre de l'enum doit avoir un [Display].
    • Date de lancement du programme (01.07.2026) : le programme de fidélité a été mis en prod le 01.07.2026. Une réservation ne génère aucun mouvement de points (crédit à l'achat, débit/consommation, remboursement) si sa Booking.BookingDate est antérieure à cette date — y compris lorsqu'une vieille résa est modifiée après le lancement (la modification re-déclenche sinon le credit/debit via InvoiceService). Le garde-fou est centralisé dans LoyaltyService (constante LoyaltyProgramStartDate + helper IsBookingEligibleForLoyalty, surchargé sur Booking et sur DateTime) et appliqué en tête de CreditBookingPurchaseAsync (→ Failed), DebitBookingPointsAsync et RefundBookingPointsAsync (→ Succeed([]), no-op) et GetConsumableBookingPointsAsync (→ 0), donc aucun point d'entrée ne peut créer de transaction ni accorder de rabais pour une résa pré-lancement. Ce dernier garde-fou couvre les résas antérieures au 01.07.2026 mais pas encore facturées : sans lui, leur facture porterait une ligne « Points fidélité » qu'aucun débit ne financerait. Un bookingId inconnu retombe sur DateTime.MinValue → inéligible → 0 (fail-safe). Le seuil s'appuie sur BookingDate (date métier de la résa, cohérent avec le reste du calcul loyalty) et non sur CreatedAt → une résa antidatée avant le 01.07 serait exclue même si créée après.
    • Agences gelées (août 2026) : un client CustomerType.Agency ne génère aucun mouvement de points et affiche un solde à 0 — garde IsAgencyAsync appliquée aux mêmes points d'entrée que le garde-fou de date de lancement. L'onglet reste accessible (historique d'archive) mais le bloc « Solde de points » et les badges d'expiration sont masqués au profit d'un bandeau d'explication (Customers/Form.cshtml). Détail complet + script de purge du stock antérieur : domain/business-rules.md.
    • Plafond de consommation — le rabais ne dépasse jamais la valeur de la résa : le nombre de points qu'une réservation peut consommer est calculé par ILoyaltyService.GetConsumableBookingPointsAsync = min(ceil(FinalAmountWithoutLoyalityPoints × 10), points déjà débités non remboursés pour cette résa + solde disponible à la BookingDate). Cette valeur unique alimente les trois usages, tous produits dans la même passe BookingService.CreateOrUpdateInvoice : le montant (FinalAmount, l.2016), la ligne de facture « Points fidélité (X pts) » (l.2438) et le débit réel (DebitBookingPointsAsync, qui ne débite plus que l'écart avec ce qui a déjà été pris).
      • Le plafond évite d'accorder un rabais supérieur à ce qui sera débité. Avant août 2026 il n'était appliqué qu'au débit : un client à 600 pts (60 CHF) sur un voyage à 40 CHF obtenait une ligne de rabais de −60 alors que le débit s'arrêtait à 400 pts, l'excédent partant sur le compte 2061 via la ligne « Balance à zéro » (l.2616) — écriture parasite sur le passif bons cadeaux. C'est le pendant du traitement déjà en place pour les bons cadeaux (l.2568-2604), qui rabotent leur ligne et conservent le reliquat dans Gift.RemainingValue.
      • Le terme « déjà débités » garde le total stable d'une facture à l'autre : DebitBookingPointsAsync est appelée une fois par facture (confirmation puis solde, InvoiceService:417 et :540). Sans lui, le second passage reprenait les points volontairement laissés au client, qui perdait donc l'excédent.
      • ⚠ La date de référence est figée à Booking.BookingDate pour les trois lectures : les points gagnés après la réservation ne peuvent jamais être consommés par celle-ci, et ceux dépensés entre-temps sur une autre résa font baisser rabais et débit ensemble. Il n'y a donc rien à contrôler dans BookingService.Validate — la question a été tranchée en août 2026, vérification en base à l'appui (0 divergence sur 31 résas facturées).
      • Une modification de facture repart de zéro : InternalUpdateAsync (InvoiceService:488) appelle RefundBookingPointsAsync, qui marque les débits IsRefunded — donc exclus du « déjà débité » — avant de re-débiter.
  • Newsletter / preferences via CustomerSubscription table (cf. MembershipService ?).
  • Recherche client typeahead utilisée dans la SPA booking (/Bookings/CreateUpdate/Customer?id={id} retourne les données client en JSON). Ce endpoint renvoie aussi Bookings (= dernière résa des 12 derniers mois, pour pré-remplir le voyage/occurrence) et FormerPassengers (= tous les passagers distincts de toutes les résas du client, via BookingService.GetCustomerPassengers, dédup serveur) qui alimente la modal « Passagers précédents » de General.vue (colonnes nom / prénom / date de naissance / téléphone). Avant juin 2026, la modal ne listait que les passagers de la dernière résa.
  • Bandeau résumé modale d'édition : partial Customers/_SummaryBanner.cshtml (rendu par Form.cshtml uniquement si Id != Guid.Empty) — récap « en un clin d'œil » : identité, adresse, N° client, contact (mobile + fixe empilés), badges statut (Inactif / Mauvais payeur / Interdit de voyage) et statut Club Buchard (+ date d'inscription). CustomerModel.CountryName est un champ display-only (mappé par convention depuis le référentiel Countries, peuplé par CustomerService) ; le pays n'est affiché que s'il diffère de « Suisse » (même convention que PdfGeneratorService). Le bandeau est rafraîchi sans recharger tout le formulaire après un « Sauvegarder » (modal ouverte) : Update.OnPost renvoie le partial _SummaryBanner (au lieu de OkResult) et le save() générique de Datatables.js remplace #customer-summary par le corps de la réponse. Pièges : (1) Customer.Mobile est [NotMapped] et CustomerService.Merge écrase Address/Phone/Mobile/Email depuis NAV → relire via Get() juste après l'écriture renvoie des valeurs NAV potentiellement périmées (read-after-write lag) ; le bandeau est donc rendu à partir de Input (valeurs postées, source de vérité pour un téléphone vidé), seuls NavNo / CountryName / champs Club étant lus en base. (2) Pas de reload complet du formulaire car le <script> Google Maps (callback=onMapLoaded) ne se redéclenche pas au 2e chargement → téléphones / autocomplete / DataTables des onglets non réinitialisés.

Dépendances

  • booking (Customer = client de la réservation).
  • loyalty-points (solde de points rattaché au Customer, bonus adhésion Club).
  • billing-payment (envois NAV, factures Membership).
  • gifts (un client peut émettre/recevoir des bons).
  • architecture/identity-and-auth.md (auth Customer via JWT + WP fallback).

Contributors

No contributors

Changelog

No recent changes