Skip to content

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
  • Components :
    • components/LoginForm.vue — formulaire de connexion (email + mot de passe + Regle + customerStore.login), extrait de Login.vue ; émet success (aucune navigation interne). Rendu par la page /login et par le modal système de session expirée (SystemModal, cf. business-rules.md → « Session expirée = logout + modal de connexion sur place », ADR 0018).
    • components/create-account/* (CreateAccountEmail, sous-dossier email/)
    • components/cards/LoyaltyPointsCard.vue
    • components/overlays-pages/ClubBuchardSubscription.vue
    • components/form-inputs/{ValidatedField,DateInput,PhoneInput}.vue
  • Stores :
    • stores/auth.ts — tokens (token, refreshToken, email) + actions login/refresh, persisted
    • stores/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 de CapacitorHttp resservirait par URL — cf. business-rules.md → « getCurrent cache-busté »). Il envoie toujours ignoreTravels=true et, 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 de LoyaltyPoints.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, avec birthdate, civility, clubMembershipType (None/Single/Couple), clubMembershipStart/End, etc.
  • Passenger — passagers enregistrés réutilisables au booking
  • AuthToken = { 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 Horizon Domain/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) et LoyaltyTransaction (brut, porte expiresAt/pointsRemaining). Le customer est étendu en CustomerWithLoyaltyPoints avec loyaltyPoints, pendingLoyaltyPoints, loyaltyTransactions, loyaltyTransactionsPretty.

Invariants & règles spécifiques

  • Dates sérialisées en yyyy-MM-dd : customer.create et customer.update reformatent birthdate, clubMembershipStart, clubMembershipEnd via date-fns avant POST/PUT. Ne pas envoyer de Date brute.
  • customer.exists(email) renvoie true sur HTTP 200, false sinon — utilisé pendant la création de compte pour bloquer les doublons d'email.
  • Refresh transparent : le apiClient intercepte les 401 et tente un refresh via authApi.refresh avant de rejouer la requête. Ne jamais appeler protectedApi.* directement en CapacitorHttp — passer par apiClient. 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é), apiClient fait 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 », ADR 0018).
  • Epoch de session : customerStore.logout() bumpe l'epoch (services/session-epoch.ts) ; fetchCurrentCustomer/fetchBookings/getRefreshedToken capturent l'epoch avant leur await et 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-await doit 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) ; un GET /bookings raté 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é par PersonalInfo.vue, ClubBuchardSubscription.vue et 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érive fullName du partiel reçu (pas du merge) — toujours inclure firstName/name dans 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.vue affiche le statut et les avantages.
    • hasActiveClubMemberShip / clubMembershipType (getters du store customer) : hasActiveClubMemberShip reflète le flag backend-authoritative d'api_Customer (Horizon : paiement OK + type ≠ None + clubMembershipEnd futur) — distinct de clubBuchardSubscription.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.
  • 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 payload GET /customers (pas d'endpoint dédié — même endpoint que le site via son GetCustomer()) : solde (loyaltyPoints), points en attente (pendingLoyaltyPoints), historique (loyaltyTransactionsPretty) et lots qui expirent (loyaltyTransactions). ⚠️ Les loyaltyTransactions brutes n'arrivent que sur le fetch plein (withLoyaltyTransactions: true, mount de LoyaltyPoints) — le fetch allégé par défaut les reçoit null et préserve celles déjà en store (cf. business-rules.md → « Fetch customer allégé par défaut »). Le store customer en dérive trois getters (computed) consommés par LoyaltyPoints.vue via storeToRefs :
    • pendingLoyaltyPoints — champ serveur des points « en attente » (gagnés, valides au retour du voyage). ⚠️ distinct de pendingLoyalty (l'override optimiste des points dépensés, bullet suivant) ;
    • loyaltyHistoryloyaltyTransactionsPretty trié par createdAt décroissant (copie défensive .slice() : Horizon renvoie non trié, et .sort() mute en place) ;
    • nextExpiringLoyalty — le prochain groupe de points qui expire (loyaltyTransactions filtrés expiresAt futur + pointsRemaining > 0, groupés par jour, sommés, le plus tôt) ou nullsans 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éordonne LoyaltyTransactionType. Tests : stores/__tests__/customer.test.ts.
  • Solde optimiste après une résa payée avec points : le store customer porte un pendingLoyalty = { bookingId, baseline, optimistic } | null (persisté) et un getter loyaltyPoints qui affiche optimistic quand un override est posé, sinon la vraie valeur serveur (customer.value.loyaltyPoints ?? 0). Trois actions pilotent le cycle :
    • applyOptimisticLoyalty(bookingId, pointsConsumed) — pose l'override optimistic = max(0, baseline - pointsConsumed) (appelée au submit du paiement, seulement si effectivePointsUsed > 0) ;
    • revertOptimisticLoyalty() — retire l'override + stoppe le poll (échec de paiement, logout, ou backend rattrapé) ;
    • startLoyaltyReconciliation() — poll fetchCurrentCustomer() toutes les 10 s jusqu'à ce que customer.value.loyaltyPoints diffère du baseline (le worker facture a débité), puis retire l'override ; abandon après MAX_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/customers renvoie l'ancien solde ~1 min. Voir business-rules.md → « Solde optimiste… ». main.ts relance startLoyaltyReconciliation() au cold start si un pendingLoyalty persisté 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é par isLoggedIn, erreurs en logError(_, 'customer')) à deux endroits : Home.vue onActivated (couvre l'ouverture d'app fermée → atterrissage Home et chaque navigation in-app vers Home) et BuchardApp.vue listener App resume (couvre le retour au premier plan après mise en arrière-plan, quelle que soit la page). Orthogonal au solde optimiste (l'override pendingLoyalty masque 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 contact https://buchard.ch/contact (in-app browser, via openTrackedLink, campagne UTM delete-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.Agency existe côté backend mais le flow mobile suppose Private — 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 guards requiresAuth/requiresUnauth, le bookingConstructor (le 1er passager peut être pré-rempli avec le Customer).

Points d'attention

  • Le store auth est persisted — un redémarrage de l'app ré-hydrate les tokens. Penser au cas du token expiré au démarrage : getCurrent peut déclencher un refresh ou un logout silencieux.
  • setBackButtonRouter(router) dans main.ts est appelé avant app.use(router) pour casser un cycle d'import (le module stores/backButton.ts ne peut pas importer @/router statiquement).
  • La validation des formulaires utilise Regleutils/regle-validators.ts centralise les règles métier (CH téléphones via v-phone-input, dates de naissance plausibles, etc.).

Contributors

No contributors

Changelog

No recent changes