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/(dontAbandonedClub.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.Type∈CustomerType:Private(particulier),Company(société),Agency(agence partenaire).Civility: string ("Madam"/"Sir"/"Other") — même pattern quePassenger.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 dansInterestreferential) pour mailing/marketing.CustomerSubscription: abonnements (newsletter,Subscriptionreferential).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.Discountvalue). 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) ouCouple. - Donne accès à des
SpecialOfferfiltrées et à un discount auto-appliqué côté Vue (cf.ADD_CLUB_MEMBERSHIP_BOOKING_DISCOUNTmutation : siCouple,value × 2, sinonvalue). - Renouvellement annuel →
InvoiceCategory.MembershipRenew.
- Adhésion annuelle, niveaux
- 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érentielSubscription: 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 lignesCustomerSubscriptionsdès sa création, la coche n'étant que le booléenEnabled.- Le catalogue Principal est pré-coché à la création (
Customers/Create.OnGet, août 2026, demande Buchard) via la constanteSubscription.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.OnGetreprend 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'abonnement —
CustomerController.CreateetLoginController(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).
- Le catalogue Principal est pré-coché à la création (
- Sync NAV : à chaque création/MAJ d'un Customer,
NavCustomerService.CreateAsync/UpdateAsyncenvoie les données vers BC.Customer.NavNorevient. - Civility : utilisé en fallback pour le
Passenger.Civilityquand 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 unTravelDtocomplet (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.GetCustomerappellecustomerstout 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=true— ajouté août 2026 (Asana 1216224721212421) :travelsne contient plus queId,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 queCustomerService.Getcharge déjà ⇒ aucun chargement de voyage. Le brutloyaltyTransactionsest également omis (loyaltyTransactionsPrettyet 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 photoIsCard(celle que le front utilise déjà pour illustrer un voyage, cf.TravelCard.vue), à défaut la première parOrder.picturesest toujours une liste (vide si le voyage n'a aucune photo), jamaisnull: 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).IsGuestpeut ê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,BillServiceou 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.
- ⚠
ClubMembershipStartne bouge JAMAIS au renouvellement — seulClubMembershipEndavance 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, regarderClubMembershipEndet la factureInvoiceCategory.MembershipRenew(= 3, pas 1 :0 Booking / 1 Gift / 2 Membership / 3 MembershipRenew). - ⚠ État transitoire « adhésion pas encore activée » :
ClubMembershipType != NoneAVECClubMembershipStart == DateTime.MinValue. C'est l'état d'une souscription en ligne en attente de paiement :OnlinePaymentService:538-540remet volontairementStart/EndàMinValueet ne repose les dates (viaCustomerService.CreateClubMembershipInvoice) qu'une fois le paiement approuvé. Le back-office, lui, forceClubMembershipCreateInvoice = 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 deCheckPendingPayments) laisse donc le client dans cet état indéfiniment.- Bug corrigé juil. 2026 :
RenewMembershipAsyncne testait queClubMembershipEnd < limit, orMinValue < limitest 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 = factureMembershipRenewà 06:00 pile le jour même de la factureMembership. Le filtre exclut désormaisClubMembershipStart == DateTime.MinValue. - ⚠ Piège résiduel non corrigé :
OnlinePaymentService:538-544remetStart/EndàMinValueinconditionnellement mais ne metClubMembershipCreateInvoice = trueque sicreateInvoiceest vrai. Dans la branche « facture existante encore partiellement due » (remainingAmount > 0⇒createInvoice == false), les dates sont donc effacées sans être reposées — le client retombe dans l'état « jamais activée ».
- Bug corrigé juil. 2026 :
- Écran « Clients > Adhésions club abandonnées » (
Customers/AbandonedClub, juil. 2026) — rattrapage manuel des adhésions Club restées sans paiement. Jusque-là, seulsBookingetGiftavaient un bouton « Relancer » ; l'adhésion n'en avait aucun, et un client bloqué après 4 échecs deCheckPendingPaymentsne 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 :
ClubMembershipType != None && ClubMembershipStart == MinValue→ souscription web/app jamais payée.ClubMembershipCreateInvoicen'étant mis àtrueque par le back-office (Create.cshtml.cs:51,Update.cshtml.cs:361) et parOnlinePaymentService:544aprè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.!PaymentSuccessful && (TransactionId renseigné || SwissBillingTransactionDate != MinValue)→ tentative de capture en cours ou abandonnée (périmètre exact du scanCheckMembershipClub). Relançable.
- ⚠ Relance interdite si
ClubMembershipType == None— garde UI et serveur (RelaunchClubMembershipPaymentAsync).IsPaymentApproved(Customer)calculeType == Single ? 39 : 59(OnlinePaymentService:883, montants codés en dur) : unNonetombe dans la brancheelseet capturerait 59.– pour un client sans adhésion. Ce cas existe en base (Abdou Olgatte, 135289 : transaction Saferpay abandonnée, typeNone). - Colonne « Statut paiement » : même échelle que bons cadeaux et réservations —
0vérification en cours,1abandonnée (OnlineBookingPaymentStatusCheckCount == int.MaxValue, seul cas où « Relancer » s'affiche),2aucune tentative. - ⚠
PaymentSuccessful == falsene 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 avecClubMembershipStart == MinValueou avec une transaction pendante qui fait sens. - « Annuler » (
CustomerService.CancelClubMembershipAsync) remetClubMembershipTypeàNoneet vide code, dates,TransactionId,SwissBillingTransactionDate, compteur etPaymentSuccessful. But : rendre le client définitivement invisible pourRenewMembershipAsync, en complément de la gardeClubMembershipStart != MinValue.PaymentTypeest 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 leStartn'a jamais été posé mais dont leEndest dans le futur y apparaît, avec le statut « Aucune tentative » puisqu'il n'y a pas deTransactionId. 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 gardeClubMembershipStart != MinValue— « renouvelle » Girard Bernard (Type = Couple,Start = End = MinValue). Branche « expirée depuis longtemps » ⇒End = Now.AddYears(1)= 20.07.2027, factureMembershipRenew127836 de 59.– (NAV 26122659),Startjamais 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 ontStartetEndrenseignés ; 1 aStartvide +Endrenseigné — Girard. Et 0 client aType = Noneavec unEndfutur. 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 à
Nonedans le formulaire client (Update.cshtml.cs:363efface aussi code et dates). Seul discriminant partiel :RenewMembershipAsyncconserveClubMembershipCodealors queCancelClubMembershipAsyncl'efface — insuffisant, beaucoup de fiches n'ont jamais eu de code. - ✅ Garde posée (août 2026) :
CancelClubMembershipAsyncrefuse désormais quandClubMembershipEnd > 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'OnlinePaymentServiceci-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 desEndfuturs serait redondant). - Effet du merge de
feature/customer-fusion(f718aac8) : neutre. Le commit remplace== MinValuepar le seuilClubMembershipDates.Unsetdes 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 toutesType = Noneet restent donc hors du filtre : 0 ligne ajoutée.CustomerMergeService.ApplyClubMembershiptraite 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-542remetCode/Start/Endà vide inconditionnellement et ne reposeClubMembershipCreateInvoiceque sicreateInvoiceest vrai. Dans la branche « facture existante encore partiellement due » (remainingAmount > 0), un client qui vient de payer se retrouveType != Noneavec 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 queCancelClubMembershipAsyncn'a pas de garde serveur, l'incident reste reproductible par cette route.
- Chronologie vérifiée : le lundi 20.07.2026 à 06:00,
- ⚠ 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 clientIsGuestsans 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 dansCustomerService.SearchAsync(pas de méthode de recherche dédiée). Quand le drapeau est levé,SearchAsyncretourne 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érencekt_abandoned_club_modal_delete) pour être câblée paronDeleteModalShow/remove()sans toucher àDatatables.js(qui exigerait ungulpsurFrontend/Custom).remove()fait un GET et recharge la datatable.
- Deux populations distinctes dans la même liste, car elles appellent des actions opposées :
- Le renouvellement anticipe de 30 jours :
RenewMembershipAsync(worker hebdo, lundi 06:00,DoWeeklyJob) sélectionneClubMembershipEnd < 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 clientEnabled = falsen'est jamais renouvelé, silencieusement.
Conventions locales
- Onglet « Points de fidelité » (fiche client back-office) :
Customers/Form.cshtmlaffiche 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éeCreatedAtDESC. Elle est construite parILoyaltyService.BuildLoyaltyTransactionsPretty(customer.LoyaltyTransactions)(méthode partagée par l'API mobileCustomerController.Getet la PageModel back-officeCustomers/Update), puis passée au partialForm.cshtmlviaViewData["LoyaltyTransactionsPretty"]. La méthode ne lit que des champs scalaires → le simpleInclude(c => c.LoyaltyTransactions)deCustomerService.Getsuffit (pas besoin des navigationsBooking/LinkedTransaction).- Date affichée = date d'achat réelle (pas le recalcul) : quand une résa est modifiée après coup,
InvoiceServicere-déclenche un cycle refund → recrédit, ce qui crée un nouveauBookingPurchasedaté du recalcul (CreatedAt). L'ancienBookingPurchasesubsiste (table append-only,PointsRemainingremis à 0). Pour que la ligne pretty affiche/trie sur la vraie date d'achat,BuildLoyaltyTransactionsPrettyprendpurchaseDate = Min(CreatedAt)parmi lesBookingPurchasedu groupe et le met dansLoyaltyTransactionPrettyDto.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 unBookingPurchase, la ligne est masquée) reste, elle, sur leCreatedAtré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 queValidFromest dans le futur, ils comptent dansCustomer.PendingLoyaltyPoints(calc. viaGetPendingPointsAsync) et pas dansCustomer.LoyaltyPoints(solde disponible,GetAvailablePointsAsync) — les deux sont peuplés dansCustomerService.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 viaValidFrom). Les tooltips sont initialisés dansonMapLoadeddeForm.cshtml(new bootstrap.Tooltip(...)). - Libellés :
LoyaltyTransactionTypeporte des[Display(Name=...)](français), affichés viaGetDisplayName(). ⚠EnumExtensions.GetDisplayNamen'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.BookingDateest 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 viaInvoiceService). Le garde-fou est centralisé dansLoyaltyService(constanteLoyaltyProgramStartDate+ helperIsBookingEligibleForLoyalty, surchargé surBookinget surDateTime) et appliqué en tête deCreditBookingPurchaseAsync(→Failed),DebitBookingPointsAsyncetRefundBookingPointsAsync(→Succeed([]), no-op) etGetConsumableBookingPointsAsync(→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. UnbookingIdinconnu retombe surDateTime.MinValue→ inéligible →0(fail-safe). Le seuil s'appuie surBookingDate(date métier de la résa, cohérent avec le reste du calcul loyalty) et non surCreatedAt→ 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.Agencyne génère aucun mouvement de points et affiche un solde à 0 — gardeIsAgencyAsyncappliqué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 passeBookingService.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 :
DebitBookingPointsAsyncest appelée une fois par facture (confirmation puis solde,InvoiceService:417et: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.BookingDatepour 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 dansBookingService.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) appelleRefundBookingPointsAsync, qui marque les débitsIsRefunded— donc exclus du « déjà débité » — avant de re-débiter.
- 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
- Date affichée = date d'achat réelle (pas le recalcul) : quand une résa est modifiée après coup,
- Newsletter / preferences via
CustomerSubscriptiontable (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 aussiBookings(= dernière résa des 12 derniers mois, pour pré-remplir le voyage/occurrence) etFormerPassengers(= tous les passagers distincts de toutes les résas du client, viaBookingService.GetCustomerPassengers, dédup serveur) qui alimente la modal « Passagers précédents » deGeneral.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 parForm.cshtmluniquement siId != 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.CountryNameest un champ display-only (mappé par convention depuis le référentielCountries, peuplé parCustomerService) ; le pays n'est affiché que s'il diffère de « Suisse » (même convention quePdfGeneratorService). Le bandeau est rafraîchi sans recharger tout le formulaire après un « Sauvegarder » (modal ouverte) :Update.OnPostrenvoie le partial_SummaryBanner(au lieu deOkResult) et lesave()générique deDatatables.jsremplace#customer-summarypar le corps de la réponse. Pièges : (1)Customer.Mobileest[NotMapped]etCustomerService.Mergeécrase Address/Phone/Mobile/Email depuis NAV → relire viaGet()juste après l'écriture renvoie des valeurs NAV potentiellement périmées (read-after-write lag) ; le bandeau est donc rendu à partir deInput(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

