Module customer-membership (mobile)
Ce fichier doit rester synchronisé avec le code du module. À mettre à jour à chaque changement structurel.
Rôle : compte client (auth, profil, passagers enregistrés), programme Club Buchard, points de fidélité. Aligné sur customer-membership backend Horizon.
Code
- Views :
- Auth :
Login.vue,Logout.vue,CreateAccount.vue,PasswordForgot.vue,PasswordReset.vue - Profil :
Profile.vue,Account.vue,PersonalInfo.vue,DeleteAccount.vue,MyPassengers.vue - Club / fidélité :
ClubBuchard.vue,LoyaltyPoints.vue
- Auth :
- Components :
components/LoginForm.vue— formulaire de connexion (email + mot de passe + Regle +customerStore.login), extrait deLogin.vue; émetsuccess(aucune navigation interne). Rendu par la page/loginet par le modal système de session expirée (SystemModal, cf.business-rules.md→ « Session expirée = logout + modal de connexion sur place », ADR0018).components/create-account/*(CreateAccountEmail, sous-dossieremail/)components/cards/LoyaltyPointsCard.vuecomponents/overlays-pages/ClubBuchardSubscription.vuecomponents/form-inputs/{ValidatedField,DateInput,PhoneInput}.vue
- Stores :
stores/auth.ts— tokens (token,refreshToken,email) + actions login/refresh, persistedstores/customer.ts— profil client courant (Customer),isLoggedIn
- API :
authApi.{login,refresh,forgot,resetPassword}publicApi.customer.{exists,create}(création compte, vérif email)protectedApi.customers.{getCurrent,update}—getCurrent(GET /customers) est cache-busté (?_=${Date.now()}) pour ramener le solde / points en attente / historique loyalty en live (sinon le cache HTTP natif deCapacitorHttpresservirait par URL — cf.business-rules.md→ «getCurrentcache-busté »). Il envoie toujoursignoreTravels=trueet, par défaut,lighter-response=true(payload allégé :loyaltyTransactions: null, travels skippés côté Horizon) ;getCurrent({ withLoyaltyTransactions: true })demande le plein payload — réservé au mount deLoyaltyPoints.vue(cf.business-rules.md→ « Fetch customer allégé par défaut »).
- Types :
types/personal-info.ts,constants/country-codes.ts,types/extensions.ts(types loyalty ci-dessous)
Entités principales
Customer(api_Customer) — fiche client, avecbirthdate,civility,clubMembershipType(None/Single/Couple),clubMembershipStart/End, etc.Passenger— passagers enregistrés réutilisables au bookingAuthToken={ email, token, refreshToken }(objets nullables tous les trois)- Types loyalty (
types/extensions.ts, absents de@spektrum/horizon-types→ augmentés localement) :LoyaltyTransactionType(enum, ordinaux 0-9 miroir de HorizonDomain/Entities/LoyaltyTransactionType.cs— le wire envoie l'entier),LOYALTY_TRANSACTION_LABELS(libellés FR répliqués des[Display]de buchard-website),LoyaltyTransactionPretty(ligne d'historique) etLoyaltyTransaction(brut, porteexpiresAt/pointsRemaining). Le customer est étendu enCustomerWithLoyaltyPointsavecloyaltyPoints,pendingLoyaltyPoints,loyaltyTransactions,loyaltyTransactionsPretty.
Invariants & règles spécifiques
- Dates sérialisées en
yyyy-MM-dd:customer.createetcustomer.updatereformatentbirthdate,clubMembershipStart,clubMembershipEndviadate-fnsavant POST/PUT. Ne pas envoyer de Date brute. customer.exists(email)renvoietruesur HTTP 200,falsesinon — utilisé pendant la création de compte pour bloquer les doublons d'email.- Refresh transparent : le
apiClientintercepte les 401 et tente un refresh viaauthApi.refreshavant de rejouer la requête. Ne jamais appelerprotectedApi.*directement enCapacitorHttp— passer parapiClient. Si le refresh échoue définitivement (rejet 400/401/403/423 — 400 = la réponse réelle de Horizon pour un token mort, 423 = compte désactivé),apiClientfait le logout complet (customerStore.logout()— customer + bookings + tokens) et ouvre le modal de connexion global sur place ; un échec transitoire (blip réseau / 5xx) ou une absence totale de session ne déclenche ni logout ni modal, et un demi-état désync (customer persisté sans tokens) s'auto-guérit en logout complet + modal (cf.business-rules.md→ « Session expirée = logout complet + modal de connexion sur place », ADR0018). - Epoch de session :
customerStore.logout()bumpe l'epoch (services/session-epoch.ts) ;fetchCurrentCustomer/fetchBookings/getRefreshedTokencapturent l'epoch avant leurawaitet jettent une réponse arrivée après un logout au lieu de repeupler les stores persistés (« la déconnexion ne marche pas », AUTH_BUG.md H1). Toute nouvelle écriture d'état de session post-awaitdoit suivre le même pattern. Cf.business-rules.md→ « Epoch de session ». - Login : le fetch bookings est non-fatal — le gate du login est le fetch customer (échec → logout de nettoyage +
false) ; unGET /bookingsraté ne l'annule plus (Home/MyTravels refetchent de toute façon). Cf.business-rules.md→ « Le fetch bookings n'annule plus le login » (AUTH_BUG.md H6). updateCustomer(action du store) : consommé parPersonalInfo.vue,ClubBuchardSubscription.vueet la carte « Contact » du paiement (TBPaymentContactInfo.vue, carte fusionnée adresse + contact — un utilisateur connecté persiste adresse + mobile + contact d'urgence au « Valider », PUT envoyé même sans édition, cf.business-rules.md→ « Carte “Contact” au paiement »). ⚠️ L'action dérivefullNamedu partiel reçu (pas du merge) — toujours inclurefirstName/namedans l'appel.- Club Buchard :
- Niveaux
None/Single/Couple(ClubMembershipType) - Adhésion annuelle payante avec offres réservées
- Sur ce mobile, l'adhésion/renouvellement est géré sur le site web pour le moment. Le checkout Saferpay Club arrivera dans un second temps. La vue
ClubBuchard.vueaffiche le statut et les avantages. hasActiveClubMemberShip/clubMembershipType(getters du storecustomer) :hasActiveClubMemberShipreflète le flag backend-authoritative d'api_Customer(Horizon : paiement OK + type ≠ None +clubMembershipEndfutur) — distinct declubBuchardSubscription.isMember, qui ne teste que le type et ignore les dates. C'est le gate des offres spéciales membres et le déclencheur du ×2 couple (clubMembershipType === Couple). Cf.business-rules.md→ « Offres spéciales »,modules/travel-catalog.md.
- Niveaux
- Points de fidélité : affichage dans
LoyaltyPoints.vue+LoyaltyPointsCard.vue. La logique d'attribution/utilisation est côté backend — le mobile ne fait que rendre. Toutes les données loyalty arrivent dans le même payloadGET /customers(pas d'endpoint dédié — même endpoint que le site via sonGetCustomer()) : solde (loyaltyPoints), points en attente (pendingLoyaltyPoints), historique (loyaltyTransactionsPretty) et lots qui expirent (loyaltyTransactions). ⚠️ LesloyaltyTransactionsbrutes n'arrivent que sur le fetch plein (withLoyaltyTransactions: true, mount deLoyaltyPoints) — le fetch allégé par défaut les reçoitnullet préserve celles déjà en store (cf.business-rules.md→ « Fetch customer allégé par défaut »). Le storecustomeren dérive trois getters (computed) consommés parLoyaltyPoints.vueviastoreToRefs:pendingLoyaltyPoints— champ serveur des points « en attente » (gagnés, valides au retour du voyage). ⚠️ distinct dependingLoyalty(l'override optimiste des points dépensés, bullet suivant) ;loyaltyHistory—loyaltyTransactionsPrettytrié parcreatedAtdécroissant (copie défensive.slice(): Horizon renvoie non trié, et.sort()mute en place) ;nextExpiringLoyalty— le prochain groupe de points qui expire (loyaltyTransactionsfiltrésexpiresAtfutur +pointsRemaining > 0, groupés par jour, sommés, le plus tôt) ounull— sans borne temporelle (comme le site). L'écran rend à partir de ces getters : carte de solde (grand total + équivalent CHF + pastille « en attente » repliable + alerte d'expiration), barre de progression vers le prochain palier de 100 CHF (modulo 1000, cf.business-rules.md→ « Barre de progression du palier »), et historique (3 max + « Voir tout » inline, filet gauche vert/rouge/orange selon crédité/débit/en-attente). Enum et labels répliqués manuellement → contrat implicite par entier, à resynchroniser si Horizon réordonneLoyaltyTransactionType. Tests :stores/__tests__/customer.test.ts.
- Solde optimiste après une résa payée avec points : le store
customerporte unpendingLoyalty = { bookingId, baseline, optimistic } | null(persisté) et un getterloyaltyPointsqui afficheoptimisticquand un override est posé, sinon la vraie valeur serveur (customer.value.loyaltyPoints ?? 0). Trois actions pilotent le cycle :applyOptimisticLoyalty(bookingId, pointsConsumed)— pose l'overrideoptimistic = max(0, baseline - pointsConsumed)(appelée au submit du paiement, seulement sieffectivePointsUsed > 0) ;revertOptimisticLoyalty()— retire l'override + stoppe le poll (échec de paiement, logout, ou backend rattrapé) ;startLoyaltyReconciliation()— pollfetchCurrentCustomer()toutes les 10 s jusqu'à ce quecustomer.value.loyaltyPointsdiffère dubaseline(le worker facture a débité), puis retire l'override ; abandon aprèsMAX_RECONCILE_ATTEMPTS = 18(~3 min) en retombant sur la valeur serveur.- Pourquoi : Horizon ne débite les points qu'à la création de la facture (worker une-fois-par-minute, après confirmation Saferpay), donc
GET /api/customersrenvoie l'ancien solde ~1 min. Voirbusiness-rules.md→ « Solde optimiste… ».main.tsrelancestartLoyaltyReconciliation()au cold start si unpendingLoyaltypersisté traîne (app tuée pendant le round-trip paiement).
- Rechargement du customer à l'arrivée Home / au resume de l'app : le store
customerétant persisté, un cold start réaffiche l'ancien solde tant qu'aucun refetch n'a lieu. Pour que les points crédités côté backend pendant que l'app était fermée/en arrière-plan remontent,fetchCurrentCustomer()est rejoué (fire-and-forget, gardé parisLoggedIn, erreurs enlogError(_, 'customer')) à deux endroits :Home.vueonActivated(couvre l'ouverture d'app fermée → atterrissage Home et chaque navigation in-app vers Home) etBuchardApp.vuelistenerAppresume(couvre le retour au premier plan après mise en arrière-plan, quelle que soit la page). Orthogonal au solde optimiste (l'overridependingLoyaltymasque toujours la valeur serveur tant qu'il est posé). Cf.business-rules.md→ « Rechargement du customer… ». - Guest customer (concept backend, voir glossaire Horizon) : pas pertinent ici, l'app mobile exige la création d'un compte standard.
DeleteAccount: le mobile ne supprime pas le compte lui-même — le bouton « Demander la suppression de mon compte » ouvre le formulaire de contacthttps://buchard.ch/contact(in-app browser, viaopenTrackedLink, campagne UTMdelete-account). La suppression est traitée manuellement (workflow RGPD) côté Buchard après la demande. Cf.business-rules.md→ « Suppression de compte = demande via formulaire de contact ».CustomerType.Agencyexiste côté backend mais le flow mobile supposePrivate— pas d'UI dédiée aux agences partenaires.
Dépendances
- Aucune dépendance entrante (module socle).
- Consommé par : tous les modules protégés (
booking,billing-payment,comments-moderation), les guardsrequiresAuth/requiresUnauth, lebookingConstructor(le 1er passager peut être pré-rempli avec leCustomer).
Points d'attention
- Le store
authest persisted — un redémarrage de l'app ré-hydrate les tokens. Penser au cas du token expiré au démarrage :getCurrentpeut déclencher un refresh ou un logout silencieux. setBackButtonRouter(router)dansmain.tsest appelé avantapp.use(router)pour casser un cycle d'import (le modulestores/backButton.tsne peut pas importer@/routerstatiquement).- La validation des formulaires utilise Regle —
utils/regle-validators.tscentralise les règles métier (CH téléphones viav-phone-input, dates de naissance plausibles, etc.).
Contributors
No contributors
Changelog
No recent changes

