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 | UNKNOWNPaymentCallbackResponse:{ 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 ReglepaymentMethod) 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 dansfinalBooking.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_ENDPOINTprincipal — c'estVITE_PAYMENT_API_ENDPOINT. Pas d'auth JWT (endpoint public mais lié au booking).Carte « Contact » (fusionnée) →
customerdu payload : une seule carte de l'étape Paiement alimente lecustomerenvoyé àpaymentApi.initialize.TBPaymentContactInfo.vue(dernière section, scope ReglecontactInfotypéapi_Customer) collecte le profil complet : adresse (name,firstName,address,zip,city,country) +eMail,mobile, contact d'urgence (emergencyName,emergencyFirstname,emergencyPhone) — tousrequired, pré-remplis depuis le compte si connecté (emailreadonly), carte toujours ouverte au départ et sans bouton « Annuler ».useBCPricingexposecontactInfoetbookingConstructor.finalBookingle 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 (idvide), à la création du client Horizon (AccountUtil.CreateAccountIfNoExist→POST /api/customers) — c'est le seul canal qui persiste email/mobile/contact d'urgence, le booking ne portant quegeneral.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 toujoursupdateCustomerdu 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, scopebillingInfo— 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 invisiblesectionsValidatedenregistré parTBStepPayment.vue— un champrequiredpar carte repliable (paymentAmountenrequiredIf(!oneDay),paymentMethod,rebateCode,contactInfo), valant'validated'carte repliée /undefinedcarte ouverte (étatis-editinglifté parv-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/:idestrequiresAuth(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é :
depositAmountest clampé ≥ 0 et arrondi 0.1 (exigence client — divergence assumée vs cost.jsgetDeposit, qui ne ré-arrondit pas) et la chaîne exposebalanceAmount(=totalCost − depositAmount,0hors 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.depositPercentagereste la source du calcul. Cf.domain/business-rules.md→ « Acompte ». (Remplace une ancienne note affirmant que le flagPaymentDepositOnlyn'était pas posé par le mobile — obsolète depuis le câblage du taux voyage, cf. pucePaymentDepositOnlyci-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é (downPaymentRateretombe à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 :
getInvoicerenvoie un base64, pas un blob binaire. Le décodage est fait dansuse-invoice.tsavant ouverture via@capacitor/file-viewer. L'appel est cache-busté (?_=${Date.now()}, sinon le cache HTTP natif deCapacitorHttppeut 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 enPENDING_WEB_OR_MOBILE— gate sur l'enum de statut (isInvoicePending), pas surisPendingWorkerProcess(formule backend non vérifiable). Cf.domain/business-rules.md→ « Facture PDF : cache-buster réseau… ».Payload
initializeen objet brut, dates sérialisées à la frontière :paymentApi.initializepassefinalBookingsansJSON.stringify— la frontièrepublicFetchconvertit touteDatevive (birthdates,bookingDate,checkIn/checkOut) en ISO local sans fuseau viaserializeWireDates. 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 », ADR0016.Le montant débité = calcul client, jamais recalculé avant l'encaissement :
paymentApi.initializetransmetbooking.amountToPaytel 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 purecomputeBookingPricing) 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 — quandtrue, seul l'acompte est facturé (génère unConfirmationinvoice côté Horizon). Quandfalse, totalité (SimpleouBalance). Envoyé par le mobile (finalBooking) quand l'utilisateur choisit l'acompte ; le taux vient detravel.depositPercentage(plus de 50 % hardcodé) et l'option n'est proposée que si0 < 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.vue→applyOptimisticLoyalty) puis réconcilié/réverté à la page de confirmation (PaymentCallback.vue→startLoyaltyReconciliation/revertOptimisticLoyalty). Cf.domain/business-rules.md→ « Solde optimiste… » etmodules/customer-membership.md. Les points gagnés affichés au paiement (« Vous gagnez N points ») viennent du calculateur backendGET /api/loyalty/winning-points?amount=(publicApi.loyalty.winningPoints, on litmobileWinningPoints) — plus de formule répliquée côté front (divergence d'arrondifloor/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.tspeuplefinalBooking.booking.gifts = [{ id }]avec l'id du bon (le reste duGiftpar défaut, ignoré côté backend). Transmis àpaymentApi.initializeen 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 :
computeBookingPricingreçoitspecialOffers { valueTotal, percentTotal }(sommes pré-résolues, cf.booking.md) et applique la remise en parallèle du code promo —Valueforfaitaire,Percentsur la base hors suppléments globaux et early booking (base facture Horizon ; divergence assumée vs lecost.jsdu site qui les inclut).showOnConfirmation: false→ n'entame pas l'acompte (réduit total/solde uniquement), affiché en lignes « Offre spéciale… » dansTBPayment_Summary.vue. Le montant est envoyé surbooking.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
redirectUrlrenvoyé parinitialize. - Le
returnUrlenvoyé àinitializedoit pointer vers la routepayment-callbackde l'app mobile (deep link / web URL selon flavor). transactionStatuspeut arriver enAUTHORIZED(pré-autorisation) avantCAPTURED— gérer les deux dans l'UI de succès.

