Skip to content

Module billing-payment (mobile)

Ce fichier doit rester synchronisé avec le code du module. À mettre à jour à chaque changement structurel.

Rôle : encaisser le paiement d'un booking via Saferpay et récupérer la facture PDF. Aligné sur billing-payment backend Horizon.

Code

  • Views : views/PaymentCallback.vue — page de retour Saferpay
  • Components : components/travel-booking/payment/*, travel-booking/TBStepPayment.vue
  • Composables : composables/use-invoice.ts — gère le download/preview de la facture PDF
  • API :
    • paymentApi.initialize(body)POST {VITE_PAYMENT_API_ENDPOINT}mobile/booking/initialize{ bookingId, redirectUrl, success, bookingNumber, message }
    • paymentApi.callback(bookingId)GET .../mobile/booking/payment-callback?bookingId=... (cache LRU pour idempotence sur le bouton retour)
    • protectedApi.bookings.getInvoice(bookingId) → PDF base64
  • Types : types/payment.ts (PaymentInitializeRequest, PaymentInitializeResponse, TransactionStatus, PaymentCallbackResponse), types/pricing.ts
  • Utils : utils/currency-formatters.ts

Entités / contrats

  • PaymentInitializeRequest = { booking: BookingForPayment, customer: Customer, returnUrl: string }
  • TransactionStatus : AUTHORIZED | CAPTURED | CANCELED | ERROR | UNKNOWN
  • PaymentCallbackResponse : { success, transactionStatus, bookingId, bookingNumber, amountPaid, message }

Invariants & règles spécifiques

  • Saferpay est l'unique passerelle de paiement du mobile — toutes les méthodes y passent. La carte « Méthode de paiement » (TBPaymentMethod.vue, scope Regle paymentMethod) propose Facture (PaymentType.CEMBRA_PAY, sans frais — aucun surcoût ajouté, parité site web / Horizon), Carte de crédit (CREDIT_AND_DEBIT_CARD_ONLINE), Twint (TWINT_ONLINE), Chèque Reka (REKA_CHECK) et Carte Reka (REKA_CARD_ONLINE) ; le choix part dans finalBooking.booking.paymentType, et quel que soit le choix, le flow d'encaissement est géré par Saferpay (initialize → redirectUrl → callback). Pas de moyen de paiement hors-Saferpay (virement, on-site) dans le wizard.

  • Origin séparée : la passerelle paiement n'est pas le VITE_API_ENDPOINT principal — c'est VITE_PAYMENT_API_ENDPOINT. Pas d'auth JWT (endpoint public mais lié au booking).

  • Carte « Contact » (fusionnée) → customer du payload : une seule carte de l'étape Paiement alimente le customer envoyé à paymentApi.initialize. TBPaymentContactInfo.vue (dernière section, scope Regle contactInfo typé api_Customer) collecte le profil complet : adresse (name, firstName, address, zip, city, country) + eMail, mobile, contact d'urgence (emergencyName, emergencyFirstname, emergencyPhone) — tous required, pré-remplis depuis le compte si connecté (email readonly), carte toujours ouverte au départ et sans bouton « Annuler ». useBCPricing expose contactInfo et bookingConstructor.finalBooking le spreade : output.customer = { ...output.customer, ...contactInfo }. Côté backend (MobileBookingApiController.ResolveCustomer, repo buchard-website), ce customer sert au prestataire de paiement et, pour un invité seulement (id vide), à la création du client Horizon (AccountUtil.CreateAccountIfNoExistPOST /api/customers) — c'est le seul canal qui persiste email/mobile/contact d'urgence, le booking ne portant que general.customerId. Un utilisateur connecté est renvoyé tel quel par cet endpoint (pas d'UpdateCustomer) — ses éditions sont persistées app-side : le « Valider » de la carte appelle toujours updateCustomer du store customer (protectedApi.customers.update, JWT — adresse incluse) avant de replier la carte, avec une note informant que le compte sera mis à jour. Cf. domain/business-rules.md → « Carte “Contact” au paiement » (y compris les pièges d'observation NAV : Mobile [NotMapped], email invité non poussé vers NAV). (Remplace les deux anciennes cartes « Adresse de facturation » (TBPaymentBillingInfo.vue, scope billingInfo — supprimés) et « Contact ».)

  • « Payer » exige toutes les sections validées (repliées) : paymentReady (useBCPricing) reste !r$.$invalid, mais l'agrégat inclut un scope invisible sectionsValidated enregistré par TBStepPayment.vue — un champ required par carte repliable (paymentAmount en requiredIf(!oneDay), paymentMethod, rebateCode, contactInfo), valant 'validated' carte repliée / undefined carte ouverte (état is-editing lifté par v-model). La carte Bon et promotion démarre repliée et s'auto-replie quand un code valide est appliqué (état appliqué dans son résumé, « Annuler » pour refermer l'éditeur vide). Points de fidélité = résumé-seul, hors gate. Cf. domain/business-rules.md → « “Payer” exige toutes les sections validées ».

  • Le callback est cacheable (cachedFetch) — si l'utilisateur revient sur la page de callback, le résultat est servi du cache LRU au lieu de re-toucher Saferpay.

  • Message de confirmation guest vs connecté (PaymentCallback.vue) : sur succès, le sous-titre et les boutons divergent selon !useCustomerStore().isLoggedIn (pas de flag guest dédié — cf. customer-membership.md). Connecté → « …disponible dans votre espace client » + bouton primaire « Voir ma réservation » (deep-link /booking/details/:id) + secondaire « Retour à l'accueil ». Guest → « …Vous recevrez une confirmation par e-mail dès qu'elle sera validée » + seul « Retour à l'accueil » : la CTA « Voir ma réservation » est retirée car /booking/details/:id est requiresAuth (un guest y serait renvoyé au login). Échec → inchangé. Cf. domain/business-rules.md → « Message de confirmation de paiement selon guest / connecté ».

  • Acompte : montant arrondi 0.1, ligne « Solde », pas de % affiché : depositAmount est clampé ≥ 0 et arrondi 0.1 (exigence client — divergence assumée vs cost.js getDeposit, qui ne ré-arrondit pas) et la chaîne expose balanceAmount (= totalCost − depositAmount, 0 hors acompte) rendu dans le résumé (TBPayment_Summary.vue, ligne « Solde à régler ultérieurement », data-testid="payment-balance", sous la ligne « Acompte »). Le taux n'est plus affiché nulle part (radio « Payer un acompte », ligne « Acompte » — le % déroutait l'utilisateur) ; travel.depositPercentage reste la source du calcul. Cf. domain/business-rules.md → « Acompte ». (Remplace une ancienne note affirmant que le flag PaymentDepositOnly n'était pas posé par le mobile — obsolète depuis le câblage du taux voyage, cf. puce PaymentDepositOnly ci-dessous.)

  • Pas d'acompte pour les courses d'1 jour : la section « Choix de paiement » (totalité vs acompte) est retirée pour un TravelType.ONE_DAY — paiement plein forcé (downPaymentRate retombe à 1.0). Cf. domain/business-rules.md → « Acompte indisponible pour les courses d'1 jour ». Les sections de l'étape Paiement sont d'ailleurs numérotées dynamiquement (contiguës) pour absorber cette section conditionnelle + celle des points de fidélité (invité) — même doc → « Sections de l'étape Paiement numérotées dynamiquement ».

  • Facture PDF : getInvoice renvoie un base64, pas un blob binaire. Le décodage est fait dans use-invoice.ts avant ouverture via @capacitor/file-viewer. L'appel est cache-busté (?_=${Date.now()}, sinon le cache HTTP natif de CapacitorHttp peut resservir indéfiniment un PDF factice téléchargé avant que le worker Horizon n'ait créé l'Invoice), et le bouton « Facture » (BookingDetails.vue) reste visible mais grisé (libellé « Facture bientôt disponible », toast explicatif au tap) tant que le booking est en PENDING_WEB_OR_MOBILE — gate sur l'enum de statut (isInvoicePending), pas sur isPendingWorkerProcess (formule backend non vérifiable). Cf. domain/business-rules.md → « Facture PDF : cache-buster réseau… ».

  • Payload initialize en objet brut, dates sérialisées à la frontière : paymentApi.initialize passe finalBooking sans JSON.stringify — la frontière publicFetch convertit toute Date vive (birthdates, bookingDate, checkIn/checkOut) en ISO local sans fuseau via serializeWireDates. Un pré-stringify émettait de l'UTC (Date.toJSON) que la chaîne website→Horizon stockait un jour trop tôt pour les dates-only. Cf. business-rules.md → « Dates sortantes », ADR 0016.

  • Le montant débité = calcul client, jamais recalculé avant l'encaissement : paymentApi.initialize transmet booking.amountToPay tel quel à Saferpay (le backend du site ne le recalcule pas), et Horizon recalcule le prix de façon autoritaire à la facture. La chaîne mobile (utils/booking-pricing.ts, fonction pure computeBookingPricing) doit donc coller exactement à Horizon : suppléments globaux dans le total, early booking, bon plafonné au sous-total, points plafonnés après le bon, arrondis 0.1. Cf. domain/business-rules.md → « Chaîne de calcul du prix au paiement ».

  • PaymentDepositOnly : flag du booking — quand true, seul l'acompte est facturé (génère un Confirmation invoice côté Horizon). Quand false, totalité (Simple ou Balance). Envoyé par le mobile (finalBooking) quand l'utilisateur choisit l'acompte ; le taux vient de travel.depositPercentage (plus de 50 % hardcodé) et l'option n'est proposée que si 0 < depositPercentage < 100. Formule de l'acompte : business-rules.md → « Acompte ».

  • Club Buchard : pour le moment, l'adhésion Club n'est pas payable depuis le mobile — l'utilisateur est redirigé vers le site web. Le checkout Saferpay pour le Club est prévu en v2, déclenché dès que le client a validé la version actuelle de l'app.

  • Points de fidélité : utilisables comme réduction (pas un moyen de paiement) ; toute la logique est backend — le mobile envoie le booléen consumeLoyaltyPoints (tout-ou-rien) et affiche ce que le backend retourne. Le débit des points (et le crédit des points gagnés ×2) se fait à la création de la facture par le worker une-fois-par-minute, après confirmation du paiement — pas au POST de la résa. Le mobile masque ce délai par un solde optimiste posé au submit (TBPayment_Summary.vueapplyOptimisticLoyalty) puis réconcilié/réverté à la page de confirmation (PaymentCallback.vuestartLoyaltyReconciliation / revertOptimisticLoyalty). Cf. domain/business-rules.md → « Solde optimiste… » et modules/customer-membership.md. Les points gagnés affichés au paiement (« Vous gagnez N points ») viennent du calculateur backend GET /api/loyalty/winning-points?amount= (publicApi.loyalty.winningPoints, on lit mobileWinningPoints) — plus de formule répliquée côté front (divergence d'arrondi floor/ceil). Cf. domain/business-rules.md → « Points gagnés affichés = calculateur backend ».

  • Bon cadeau appliqué → finalBooking.booking.gifts : quand un bon cadeau a été validé (modules/gifts.md), bookingConstructor.ts peuple finalBooking.booking.gifts = [{ id }] avec l'id du bon (le reste du Gift par défaut, ignoré côté backend). Transmis à paymentApi.initialize en même temps que le reste du booking, donc consommé (Gift.IsUsed = true) au même moment que la création de la réservation — pas d'appel séparé.

  • Offres spéciales dans le prix : computeBookingPricing reçoit specialOffers { valueTotal, percentTotal } (sommes pré-résolues, cf. booking.md) et applique la remise en parallèle du code promoValue forfaitaire, Percent sur la base hors suppléments globaux et early booking (base facture Horizon ; divergence assumée vs le cost.js du site qui les inclut). showOnConfirmation: falsen'entame pas l'acompte (réduit total/solde uniquement), affiché en lignes « Offre spéciale… » dans TBPayment_Summary.vue. Le montant est envoyé sur booking.discounts (Horizon ne re-dérive pas). Cf. domain/business-rules.md → « Offres spéciales ».

Dépendances

  • Dépend de : booking (envoie le booking à initializer), customer-membership (customer pour le request)
  • Pas de dépendance sortante : modulo le PDF base64 ouvert via @capacitor/file-viewer

Points d'attention

  • Ne jamais construire l'URL Saferpay côté front — toujours utiliser redirectUrl renvoyé par initialize.
  • Le returnUrl envoyé à initialize doit pointer vers la route payment-callback de l'app mobile (deep link / web URL selon flavor).
  • transactionStatus peut arriver en AUTHORIZED (pré-autorisation) avant CAPTURED — gérer les deux dans l'UI de succès.

Contributors

No contributors

Changelog

No recent changes