Skip to content

Module — Bons cadeaux

Synchronisé avec le code. Mettre à jour à chaque changement structurel.

Rôle

Émission de bons cadeaux à montant libre (pas de bons par voyage), payés par l'émetteur via Saferpay/Cembra ou en bureau, utilisables totalement ou partiellement sur de futures réservations.

Emplacement

  • Pages : src/Web/Pages/Gifts/
  • Service : src/Application/Services/Entities/GiftService.cs
  • Templates PDF : Gift.liquid (admin), GiftCustomer.liquid (à destination du bénéficiaire)

Entités principales

  • Gift — bon cadeau.
    • Value : montant initial CHF.
    • RemainingValue : solde restant après utilisation partielle. null tant que le bon a son montant intact (solde effectif = Value). IsUsed = true uniquement quand la totalité du bon est consommée. La liste back-office affiche une colonne "Montant restant" dérivée : IsUsed → 0, sinon RemainingValue ?? Value.
    • Code : code à présenter pour utiliser.
    • StatusGiftStatus : émis / utilisé / etc.
    • SendingStatusGiftSendingStatus : envoyé / en attente / pas envoyé.
    • IncludeEnvelope : option enveloppe physique.
    • ShowEmitter : afficher le nom de l'émetteur sur le bon.
    • EmitterCustomer, BeneficiaryEmail, etc.
    • IsLegacy : import Globe.
    • TransactionId, SwissBillingTransactionDate, IncludeOnPostedInvoice : intégration paiements/NAV.
    • Origin : canal d'émission.
  • Booking.Gifts : association ManyToMany — un bon peut être appliqué à un booking, et une réservation peut combiner plusieurs bons.

Règles métier spécifiques

  • Montant libre uniquement — pas de bons "voyage X". L'émetteur choisit la valeur en CHF.
  • Émission web/mobile : envoi email automatique au bénéficiaire (avec option enveloppe physique).
  • Émission Horizon back-office : PAS d'envoi email automatique — l'agent imprime ou envoie manuellement.
  • Encaissement : un bon peut couvrir totalement ou partiellement une réservation. Solde restant via Gift.RemainingValue, reportable sur une autre réservation.
  • Utilisé / réactivation : Gift.IsUsed marque un bon comme consommé. Posé à true quand un booking référence le bon (BookingService.CreateUpdate) ou via l'action « Marquer comme utilisé » (GiftService.MarkAsUsedAsync). Remis à false automatiquement à l'annulation du booking (BookingService.UpdateAsync, branche Canceled : restaure aussi RemainingValue et détache le booking du bon). Action manuelle « Réactiver » (GiftService.ReactivateAsync) pour les vieux cas non auto-réactivés ou les erreurs : autorisée uniquement si aucun booking non-annulé n'est rattaché (!gift.Bookings.Any(b => b.Status != Canceled)) — sinon refus (anti double-dépense). Un booking annulé encore rattaché (vieux cas) ne bloque pas la réactivation.
  • Retrait d'un bon d'une réservation (juil. 2026) : le bouton poubelle de l'étape Prix (booking/steps/global/Price.vue) marche aussi pour un bon déjà enregistré (avant : seulement les bons pas encore sauvés). À la sauvegarde, BookingService.Map détecte les bons retirés et restaure le montant consommé sur la facture courante de cette résa (Balance sinon Confirmation, cf. dernier point de la section Questions ouvertes) dans RemainingValue (clampé : >= Valuenull = intact ; un bon partagé entre 2 résas ne récupère que la part de celle-ci) et remet IsUsed = false — le bon redevient utilisable/effaçable directement, sans passer par « Réactiver ». Les bons MarketingAction sont exclus (multi-usage, jamais mutés). La facture de la résa se régénère sans la ligne bon (le bloc gifts de la génération n'itère que booking.Gifts). Tests : BookingServiceTest.Map_ShouldRestoreGift_WhenRemovedFromBooking / Map_ShouldRestoreOnlyThisBookingsShare_WhenGiftPartiallyUsedElsewhere.
  • Correction manuelle du montant restant (août 2026, Pages/Gifts/UpdateRemainingValue + GiftService.SetRemainingValueAsync) : action « Màj montant restant » du menu Actions de /Gifts, ouvrant la modale « Montant restant du bon » qui affiche le code, la valeur faciale et les réservations rattachées (n° + mention « (annulée) »), et laisse saisir le solde disponible. Palliatif assumé en attendant ADR 0011 : il permet à la compta de rattraper à la main un bon vidé à tort (cf. dernier point de cette section). Règles : refus si MarketingAction (bon réutilisable, jamais muté), si montant négatif, ou si montant > Value (un bon ne se recharge pas). Écritures : 0IsUsed = true ; >= ValueRemainingValue = null (bon intact) ; entre les deux → RemainingValue = montant, IsUsed = false. ⚠ N'a aucun effet sur la facture de la réservation — la ligne « Bon cadeau N° X » déjà émise n'est pas recalculée, la rectification comptable reste à faire à part. Écriture directe en base (pas GiftService.UpdateAsync) pour ne pas repousser la facture de vente du bon dans BC. Tracé par l'AuditableAndSoftDeleteInterceptor (consultable via /AuditEntities/?entityId=<giftId>). La modale utilise son propre partiel _ModalRemainingValue.cshtml — clone allégé de Default/_ModalCreateUpdate sans « Historique » ni « Notes », avec un seul bouton « Sauvegarder » ; ⚠ sa référence doit continuer à se terminer par _modal_update, c'est ce suffixe qui fait binder le bouton par onCreateUpdateModalShow. Le formulaire remet noteEntity = undefined : sans ça, initNotesDrawerButton plante sur le bouton Notes absent si une autre modale l'a laissé positionné. Tests : GiftServiceTest.SetRemainingValueAsync_*.
  • Effacement bloqué (Pages/Gifts/Delete.cshtml.cs) : refus si IsUsed ou booking rattaché — garde anti note-de-crédit sur un bon dont la valeur est déduite de la facture d'un client. Chemin de déblocage : retirer le bon de la résa (cf. ci-dessus) puis « Effacer ».
  • Facturation associée (GiftService.CreateOrUpdateStandardInvoice + CreateOrUpdateCompensationOrMarketingActionCreditMemo) :
    • Bon standard : une facture de vente InvoiceType.Simple, catégorie Gift, compte 2060.
    • Bon MarketingAction partiellement pris en charge : facture Simple du reste à payer (Value - MarketingActionValue) plus le couple d'écritures internes ci-dessous.
    • Aucune facture Simple n'est créée pour un bon Compensation, ni pour un bon MarketingAction pris en charge à 100 % (Value - MarketingActionValue == 0) : le client ne paie rien, il n'y a donc rien à lui facturer. C'est un return en tête de CreateOrUpdateStandardInvoice. 716 bons en prod sont dans ce cas (359 dédommagements + 357 actions marketing intégrales, chiffres du 31.08.2026).
    • Écritures internes des bons Compensation / MarketingAction : deux Invoice créées, une « facture » et un « avoir », toutes deux de type InvoiceType.BuchardPart, qui se compensent. Comptes de contrepartie : 6605 pour une action marketing, 3906 pour un dédommagement.
    • InvoiceType.BuchardPart couvre aussi les bons offerts en partenariat (ex : bon gagné à la radio, Buchard est rémunérée par le partenaire).
    • InvoiceType.Compensation n'est jamais assigné nulle part dans le code : la ligne qui choisissait le type entre BuchardPart et Compensation est commentée (GiftService.cs:433), et les deux écritures d'un dédommagement partent en BuchardPart. Conséquences : un dédommagement et un bon partenariat sont indiscernables par type, et gift.GetCurrentInvoice(InvoiceType.BuchardPart) renvoie arbitrairement la facture ou l'avoir (même type, tous deux Enabled). Le type reste lu par InvoiceService et NavCreditMemoService, donc l'enum ne peut pas être supprimé. Statut : non tranché — choix assumé ou régression, question ouverte auprès de Buchard.
  • Paiement émetteur :
    • Web/mobile : Saferpay ou CembraPay.
    • Bureau : Cash (5 villes) / CB / Twint / etc.

Points d'attention / pièges

  • Action « Facture » sur un bon sans facture de vente (août 2026, Pages/Gifts/InvoiceUnavailable) : l'action Facture du menu Actions de /Gifts est proposée sur tous les bons, y compris ceux qui n'ont pas de facture Simple (dédommagements, actions marketing intégrales — cf. section Facturation associée). Elle plantait jusque-là sur une NullReferenceException (PdfGeneratorService.CreateGift, GetCurrentInvoice(Simple) renvoyant null quand le bon a des factures mais aucune Simple active) et affichait la page d'erreur brute. Index.OnGetInvoice teste désormais GetCurrentInvoice(InvoiceType.Simple) == null || gift.Customer == null et redirige vers /Gifts/InvoiceUnavailable, page pleine page qui explique la raison au lieu de crasher. Le bouton est volontairement conservé dans le menu (décision utilisateur : expliquer plutôt que masquer). Tests : tests/Web/Pages/Gifts/IndexTest.cs + InvoiceUnavailableTest.cs.
    • Cas non couvert, laissé en l'état : les 6 785 bons Legacy (import Globe) n'ont aucune facture. GetCurrentInvoice retourne alors son new Invoice() de repli (et non null), donc pas de crash et pas de redirection — mais le PDF sort avec un numéro de facture 0 et une référence QR construite dessus. Comportement inchangé par le correctif, à trancher séparément.
  • Ne pas confondre émission (création + paiement par émetteur) et utilisation (encaissement par le bénéficiaire dans une réservation).
  • Deux niveaux de contrôle à l'utilisation, à ne pas confondre — modifié juillet 2026 :
    1. Vérifiable en base : Gift.IsRedeemable() (entité Domain) = non expiré (ValidUntil >= now), !IsUsed, et RemainingValue == null || > 0. Appliqué par GiftService.GetByCode et par BookingService.Map — utiliser cette méthode, ne pas réécrire le prédicat à la main, sinon les deux chemins divergent.
    2. Paiement effectif : GiftService.IsPaid interroge Business Central et exige navInvoice.Remaining_Amount == 0 (exemptions Compensation / MarketingAction / Legacy). Uniquement sur GET /api/gifts/{code} et sur les pages back-office — volontairement pas dans BookingService.Map, car un aller-retour BC par bon sur le chemin de création de résa ferait disparaître silencieusement la remise d'un bon légitime si BC est injoignable (IsPaid renvoie false). Reprise prévue dans le futur module centralisé de calcul de panier.
  • Map marque IsUsed = true au premier rattachement (sauf MarketingAction, réutilisable). Conséquence : un bon déjà attaché à la résa est conservé tel quel lors d'une mise à jour ; la garde IsRedeemable() ne s'applique qu'à un nouvel ajout. Sans cette exception, chaque UpdateAsync d'une résa ferait perdre sa remise.
  • Un bon est résolu par Id dans BookingService.Map (le DTO envoie l'Id, pas le code), donc les contrôles de GET /api/gifts/{code} ne sont pas sur le chemin critique : c'est pour ça que la garde base est dupliquée dans Map.
  • Un bon "émis non utilisé" reste avec Status actif jusqu'à péremption (à vérifier — durée de validité par config ?).
  • RemainingValue peut être > Value ? Non — un bon ne se "recharge" pas.
  • Le montant restitué se lit sur UNE seule facture (BookingService.GetGiftAmountUsedOnBooking, août 2026) : la Balance si elle existe, sinon la Confirmation, toutes deux via GetCurrentInvoice qui filtre déjà sur Enabled. Les trois chemins de restitution (annulation, recalcul avant édition, retrait du bon dans l'éditeur) passent par ce helper. Avant, la somme portait sur booking.Invoices sans filtre : chaque version désactivée d'une facture et le couple Confirmation/Solde répètent la même ligne « Bon cadeau N° X », donc le bon était restitué autant de fois qu'il y avait de lignes. Le clamp >= Value → null masquait le dépassement en le transformant en « bon intégralement libéré » — d'où l'impossibilité de repérer le bug en base (aucun RemainingValue > Value n'a jamais existé). Cas nuisible : une résa qui ne consommait qu'une partie du bon (le reste ayant servi sur une autre résa encore valide) rendait au client la totalité du bon. Mesuré le 04.08.2026 sur la copie de prod : 26 résas annulées sur 74 étaient dans la configuration à risque, mais une seule aurait réellement fuité — les 25 autres avaient consommé le bon en entier, donc la restitution intégrale tombait juste par coïncidence.
  • Le garde-fou d'usage partiel compare au montant brut, pas au solde dû (BookingService.cs:2573-2620, bug ouvert — Asana 1217482583935874). Il ne se déclenche que si le total de la facture devient négatif : un bon posé sur une résa dont l'acompte est déjà payé est donc consommé en totalité, et le trop-perçu devient un avoir au client au lieu de rester sur le bon. Cas réel — résa 3214 : brut 1 446, acompte payé 542.50, bon de 1 200 → 1 446 − 1 200 = +246 > 0 → bon vidé, 296.50 dus à la cliente. Deux autres cas identiques en base (8043, 2888). ⚠ Nuance : en NewInvoiceProcess la somme porte sur FullPrice (montant plein) et le garde-fou ne se déclenche quasiment jamais ; en ancien process elle portait sur Price (part solde) et il se déclenchait par accident, sans jamais tomber juste. Aucun correctif appliqué : décision de traiter le problème par la refonte, cf. ADR 0011. Rattrapage manuel via l'action « Montant restant ».
  • Sync NAV : factures Gift partent en BC comme les autres.
  • Templates : 2 distincts — Gift.liquid (vue admin/comptable) vs GiftCustomer.liquid (le PDF que reçoit le bénéficiaire).
  • GiftCustomer.liquid — décalage de 1cm à répercuter partout (juillet 2026) : le PDF fait 21 × 10 cm (PdfGeneratorService.CreateGiftOnly, soit 793.7 × 378 px) et l'artwork est une image de fond en base64 (.background, rendue 700 × 378 px via background-size: contain). La bande dégradée à gauche de l'artwork tombait dans la zone non-imprimable de l'imprimante ⇒ background-position: 1cm 0. ⚠ Le texte HTML posé par-dessus (montant h1, bloc .gift-details avec « DE LA PART DE », « BON N° », validité, commentaires) n'est pas solidaire du fond : tout décalage de l'image doit être reporté à l'identique sur h1 { margin-left } (60 → 108px) et .gift-details { padding-left } (83 → 131px), sinon le montant passe à gauche du logo et le bloc de droite se désaligne du titre « BON CADEAU ». Contrepartie : .gift-details table { width } doit être réduit d'autant (380 → 332px) pour que le bloc reste dans la cellule (table-layout: fixed) et ne déborde pas de la page. Les 3 valeurs bougent ensemble : +X sur les deux premières, −X sur la troisième. Invariant à vérifier à chaque retouche : 330 + padding-left + width ≤ 793.7 (largeur réelle de la page ; attention, .background est déclarée à 800px, donc les ~6 px de droite sont déjà hors page).
  • GiftCustomer.liquid — pas de marge de sécurité à droite : le bloc .gift-details se termine pile au bord de page (330 + 131 + 332 = 793 px ≈ 21 cm). L'imprimante rognant ~1cm à droite, la fin d'un commentaire long peut être coupée à l'impression (l'écran/PDF, lui, est correct). Si le cas se présente, réduire .gift-details table { width } d'un cm supplémentaire (332 → ~294px).
  • Régression « texte trop long » (bon F982A2B7, 10.08.2026) : lors du décalage de 1cm (juillet 2026), les 3 valeurs ont été poussées à 108/131 mais width a été augmentée (342 → 352) au lieu d'être réduite ⇒ 330 + 131 + 352 = 813 px, soit ~19 px hors page. Le texte se coupait donc en fin de première ligne (« Voici de q|uoi ») alors que les lignes suivantes semblaient correctes : le retour à la ligne se faisait bien à 352 px, mais les 19 derniers px n'étaient jamais rendus. Corrigé en repassant width à 332px.

Conventions locales

  • L'envoi email post-paiement est typiquement déclenché par Worker.DoOneMinuteJob > CheckPendingPayments quand un Gift web est validé.
  • Les bons "legacy" importés de Globe (IsLegacy = true) sont en lecture seule.

Dépendances

  • customer-membership (Customer émetteur).
  • billing-payment (Invoice/Payment associés, sync NAV).
  • booking (utilisation lors d'une réservation).

Contributors

No contributors

Changelog

No recent changes