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.nulltant que le bon a son montant intact (solde effectif =Value).IsUsed = trueuniquement quand la totalité du bon est consommée. La liste back-office affiche une colonne "Montant restant" dérivée :IsUsed → 0, sinonRemainingValue ?? Value.Code: code à présenter pour utiliser.Status∈GiftStatus: émis / utilisé / etc.SendingStatus∈GiftSendingStatus: 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.IsUsedmarque un bon comme consommé. Posé àtruequand un booking référence le bon (BookingService.CreateUpdate) ou via l'action « Marquer comme utilisé » (GiftService.MarkAsUsedAsync). Remis àfalseautomatiquement à l'annulation du booking (BookingService.UpdateAsync, brancheCanceled: restaure aussiRemainingValueet 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.Mapdé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) dansRemainingValue(clampé :>= Value→null= intact ; un bon partagé entre 2 résas ne récupère que la part de celle-ci) et remetIsUsed = false— le bon redevient utilisable/effaçable directement, sans passer par « Réactiver ». Les bonsMarketingActionsont 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 quebooking.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 siMarketingAction(bon réutilisable, jamais muté), si montant négatif, ou si montant >Value(un bon ne se recharge pas). Écritures :0→IsUsed = true;>= Value→RemainingValue = 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 (pasGiftService.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é deDefault/_ModalCreateUpdatesans « 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 paronCreateUpdateModalShow. Le formulaire remetnoteEntity = undefined: sans ça,initNotesDrawerButtonplante sur le bouton Notes absent si une autre modale l'a laissé positionné. Tests :GiftServiceTest.SetRemainingValueAsync_*. - Effacement bloqué (
Pages/Gifts/Delete.cshtml.cs) : refus siIsUsedou 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égorieGift, compte 2060. - Bon
MarketingActionpartiellement pris en charge : factureSimpledu reste à payer (Value - MarketingActionValue) plus le couple d'écritures internes ci-dessous. - ⚠ Aucune facture
Simplen'est créée pour un bonCompensation, ni pour un bonMarketingActionpris en charge à 100 % (Value - MarketingActionValue == 0) : le client ne paie rien, il n'y a donc rien à lui facturer. C'est unreturnen tête deCreateOrUpdateStandardInvoice. 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: deuxInvoicecréées, une « facture » et un « avoir », toutes deux de typeInvoiceType.BuchardPart, qui se compensent. Comptes de contrepartie : 6605 pour une action marketing, 3906 pour un dédommagement. InvoiceType.BuchardPartcouvre aussi les bons offerts en partenariat (ex : bon gagné à la radio, Buchard est rémunérée par le partenaire).- ⚠
InvoiceType.Compensationn'est jamais assigné nulle part dans le code : la ligne qui choisissait le type entreBuchardPartetCompensationest commentée (GiftService.cs:433), et les deux écritures d'un dédommagement partent enBuchardPart. Conséquences : un dédommagement et un bon partenariat sont indiscernables par type, etgift.GetCurrentInvoice(InvoiceType.BuchardPart)renvoie arbitrairement la facture ou l'avoir (même type, tous deuxEnabled). Le type reste lu parInvoiceServiceetNavCreditMemoService, donc l'enum ne peut pas être supprimé. Statut : non tranché — choix assumé ou régression, question ouverte auprès de Buchard.
- Bon standard : une facture de vente
- 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/Giftsest proposée sur tous les bons, y compris ceux qui n'ont pas de factureSimple(dédommagements, actions marketing intégrales — cf. section Facturation associée). Elle plantait jusque-là sur uneNullReferenceException(PdfGeneratorService.CreateGift,GetCurrentInvoice(Simple)renvoyantnullquand le bon a des factures mais aucuneSimpleactive) et affichait la page d'erreur brute.Index.OnGetInvoiceteste désormaisGetCurrentInvoice(InvoiceType.Simple) == null || gift.Customer == nullet 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.GetCurrentInvoiceretourne alors sonnew Invoice()de repli (et nonnull), donc pas de crash et pas de redirection — mais le PDF sort avec un numéro de facture0et une référence QR construite dessus. Comportement inchangé par le correctif, à trancher séparément.
- ⚠ Cas non couvert, laissé en l'état : les 6 785 bons
- 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 :
- Vérifiable en base :
Gift.IsRedeemable()(entité Domain) = non expiré (ValidUntil >= now),!IsUsed, etRemainingValue == null || > 0. Appliqué parGiftService.GetByCodeet parBookingService.Map— utiliser cette méthode, ne pas réécrire le prédicat à la main, sinon les deux chemins divergent. - Paiement effectif :
GiftService.IsPaidinterroge Business Central et exigenavInvoice.Remaining_Amount == 0(exemptionsCompensation/MarketingAction/Legacy). Uniquement surGET /api/gifts/{code}et sur les pages back-office — volontairement pas dansBookingService.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 (IsPaidrenvoiefalse). Reprise prévue dans le futur module centralisé de calcul de panier.
- Vérifiable en base :
MapmarqueIsUsed = trueau premier rattachement (saufMarketingAction, réutilisable). Conséquence : un bon déjà attaché à la résa est conservé tel quel lors d'une mise à jour ; la gardeIsRedeemable()ne s'applique qu'à un nouvel ajout. Sans cette exception, chaqueUpdateAsyncd'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 deGET /api/gifts/{code}ne sont pas sur le chemin critique : c'est pour ça que la garde base est dupliquée dansMap. - Un bon "émis non utilisé" reste avec
Statusactif jusqu'à péremption (à vérifier — durée de validité par config ?). RemainingValuepeut ê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 viaGetCurrentInvoicequi filtre déjà surEnabled. Les trois chemins de restitution (annulation, recalcul avant édition, retrait du bon dans l'éditeur) passent par ce helper. Avant, la somme portait surbooking.Invoicessans 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 → nullmasquait le dépassement en le transformant en « bon intégralement libéré » — d'où l'impossibilité de repérer le bug en base (aucunRemainingValue > Valuen'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 : enNewInvoiceProcessla somme porte surFullPrice(montant plein) et le garde-fou ne se déclenche quasiment jamais ; en ancien process elle portait surPrice(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) vsGiftCustomer.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 viabackground-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 (montanth1, bloc.gift-detailsavec « 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 surh1 { 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 :+Xsur les deux premières,−Xsur la troisième. Invariant à vérifier à chaque retouche :330 + padding-left + width ≤ 793.7(largeur réelle de la page ; attention,.backgroundest 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-detailsse 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
widtha é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 repassantwidthà 332px.
Conventions locales
- L'envoi email post-paiement est typiquement déclenché par
Worker.DoOneMinuteJob > CheckPendingPaymentsquand 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

