Module — Facturation & Paiement
Synchronisé avec le code. Mettre à jour à chaque changement structurel.
Rôle
Génération des factures (7 types), gestion des paiements multi-canal (cash bureau, CB en ligne, CembraPay échelonné, Twint, Reka), QR-bill suisse, sync vers NAV/BC.
Vue d'ensemble métier consolidée :
RAPPORT_FACTURATION.md(racine du repo, 24.08.2026) décrit en langage Buchard le cycle de vie complet d'une réservation côté facturation — acompte, ajout/retrait de bon à chaque étape, points, supplément global, annulation partielle et totale — avec 13 anomalies recensées et 10 arbitrages en attente de Buchard. C'est le cahier des charges de la remise à plat prévue par ADR 0011. Document destiné au client : à relire avant tout refactor deCreateOrUpdateInvoice.
Emplacement
- Pages :
src/Web/Pages/Invoices/,src/Web/Pages/Payments/ - Services :
BillService.cs,InvoiceService.cs,PaymentService.cs,OnlinePaymentService.cs,CembraPayService.cs,PdfGeneratorService.cs - NAV wrappers :
Application/Services/ERP/Nav/NavSalesInvoiceService,NavPostDocument,NavPaymentService,NavCreditMemoService - Templates PDF :
Confirmation.liquid,Balance.liquid,Cancellation.liquid,CancellationInsurance.liquid,Estimate.liquid,Catalog.liquid,QrBillConfirmationBalance.liquid,QrBill.liquid,Header.liquid,Footer.liquid,TransportAndAccommodation.liquid
Entités principales
Invoice— facture émise. Champs clés :Type∈InvoiceType:Confirmation/Balance/Simple/Cancellation/CancellationInsurance/BuchardPart/Compensation.Category∈InvoiceCategory:Booking/Gift/Membership/MembershipRenew.Status:None/Pending/OnGoing.Enabled: flag de versioning (false = ancienne version remplacée).NavNo: numéro de facture posté dans BC.Total,InvoiceItems: ligne par ligne.IsCreditMemo.
InvoiceItem— ligne de facture (Description,Price,FullPrice,Quantity, etc.).InvoiceSplit— répartition d'un paiement entre plusieurs payeurs.Payment— encaissement.PaymentTypeindique le moyen (cf. enum).SettingVatRate— taux de TVA daté.
Règles métier spécifiques
- InvoiceType — usage :
Confirmation: facture d'acompte (premier paiement).Balance: facture de solde (après acompte).Simple: facture totale hors-réservation, typiquement pour adhésion Club Buchard sans booking.Cancellation: facture d'annulation (frais retenus client) — passagers sans assurance ou avec une assurance externe, comptes3010/3011.CancellationInsurance: même contenu mais à destination de l'assurance — passagers avec une assurance interne, compte3022,CustomerIdnul. Le client ne porte alors aucun frais : c'est ce qui décide du remboursement de la prime d'assurance annulation, cf.domain/business-rules.md§ « Annulation totale : la prime d'assurance annulation… ».BuchardPart: factures internes pour les bons offerts en partenariat (Buchard est rémunérée par le partenaire).Compensation: geste commercial (utilisé pour bons cadeaux).
- Versioning
Invoice.Enabled: chaque réémission crée une nouvelle Invoice ; l'ancienne passeEnabled = false. Lecture courante :booking.Invoices.FirstOrDefault(i => i.Type == X && i.Enabled). HelperBooking.GetCurrentInvoice(InvoiceType). - Numérotation & référence QR :
Invoice.Numberest unintattribué en C# par séquence_db.Invoices.Max(i => i.Number) + 1(départ100000), pas une identity SQL — donc non concurrent-safe (même fragilité queBooking.Number). CeNumberpart dans BC commeYour_Reference(InvoiceService.cs:597, pas l'identité du document BC qui est leNavNo) et sert de base à la référence QR :InvoiceReference.Generate(Number)="21"+Numbercadré à droite sur 24 chiffres + clé de contrôle mod-10 récursif (table{0,9,4,6,8,2,7,1,3,5}) → 27 chiffres (QRR standard). Règles d'attribution (BookingService.CreateOrUpdateInvoice, modeNewInvoiceProcesscourant) :- Confirmation →
Max+1. - Confirmé→Facturé (1ʳᵉ Balance) → réutilise le
Numberet leNavNode la Confirmation uniquement si le montant n'a pas changé (booking.FinalAmount == confirmationInvoice.BookingTotal,:1959). Si le montant a changé (ex. supplément ajouté avant facturation), on poste une nouvelle facture BC + avoir sur la confirmation (:2498-2502) et on attribue un nouveauNumber/référence (Max+1) — cohérent avec le flux d'édition. (Avant mai 2026 : leNumberétait réutilisé même en cas de changement de montant → incohérence corrigée.) - Édition d'une facture existante (Confirmation ou Balance) avec changement de montant →
Max+1+UpdateAsync(ancienneEnabled=false+ avoir + nouvelle facture BC). - Le rattachement des paiements au changement de facture se fait explicitement par
NavNo/DCLE (unapply→repost→reapply, cf.InternalCreateAsync/InternalUpdateAsync), jamais via la référence QR.
- Confirmation →
- Update d'une Invoice avec changement de montant (
InvoiceService.InternalUpdateAsync) : si le total change, on (1) délettre les paiements de l'ancienne facture dans BC viaUnapplySpecificApplicationAsync(paymentEntryNo, invoiceEntryNo), (2) crée un credit memo BC pour l'ancienne facture, (3) crée la nouvelle facture BC, (4) re-lettre chaque paiement sur la nouvelle facture viaApplyPaymentToInvoicesAsync(paymentEntryNo, [newInvoiceNavNo], DateTime.Today). La codeunit BC customceuHorizon(déployée chez l'intégrateur SaaS) expose ces opérations. Conséquence surInvoice.TotalPaid: BC est désormais la source de vérité du "déjà payé", doncTotalPaidn'est PLUS carry-forward (pas de+= paidAmountdepuis l'ancienne facture).RemainingDueAmountse calcule en déduisant leRemaining_AmountBC duBookingTotal. Les données antérieures à cette feature restent cohérentes : elles ontTotalPaid != 0mais BC remaining = total (paiement orphelin sur facture annulée), donc la formuleBookingTotal - TotalPaid - (BookingTotal - BCremaining)donne le bon résultat dans les deux mondes. - Détection des paiements appliqués à une facture BC (
NavDetCustEntryService.GetAppliedPaymentsForInvoice) : doit lire les DCLE Application dans les deux sens. Quand l'apply est fait depuis Horizon (ApplyPaymentToInvoices), le DCLE est stocké avecCust_Ledger_Entry_No = invoice/Applied = payment. Quand l'apply est fait directement dans BC (bank rec, BVR, apply manuel), c'est l'inverse :Cust_Ledger_Entry_No = payment/Applied = invoice. Si on ne lit qu'un seul sens, les paiements importés par BVR restent invisibles pendant un update d'invoice → l'unapply ne tourne pas, le paiement reste lettré à l'ancienne facture, le credit memo s'auto-applique dessus, et la nouvelle facture reste totalement ouverte. - Ordre LIFO du délettrage multi-paiements (
GetAppliedPaymentsForInvoice) : quand plusieurs paiements sont lettrés à la même facture (ex. client qui paie une partie par BVR, ça bug, il en remet un second), BC n'autorise le délettrage que de l'application la plus récente sur la CLE. Si on délettre le paiement le plus ancien d'abord, BC échoue en LIFO ("you must first unapply all application entries ... posted after this entry"). La méthode retourne donc lespaymentEntryNostriés par n° d'écriture détaillée d'application décroissant (le plus récent en premier) pour que la boucle de délettrage deInvoiceServicerespecte le LIFO. Pur C#, pas de redeploy BC. - Cascade BC unapply — Application No. partagé (
InvoiceService.InternalCreateAsync/InternalUpdateAsync, boucles de délettrage) : quand plusieurs paiements ont été lettrés à une même facture en un seul "Apply Entries" batch côté BC (cas typique : comptable qui sélectionne 3 paiements et clique Post Application en un coup, ou auto-match du bank-rec CH/BVR), BC partage un seul Application No. entre toutes les DCLE Application produites.PostUnApplyCustomerreverse toutes les DCLE partageant cet Application No. en une fois — donc dès la 1ʳᵉ itération de notre boucle, les liens des paiements suivants n'existent plus, et la 2ᵉ itération échoue avec"No active application linking payment entry X and invoice entry Y". Fix idempotent : on traite cette erreur précise comme succès (continue) puisque l'état cible — aucun lien — est déjà atteint. Le filet protège aussi des cas où le délettrage a été fait manuellement dans BC entre-temps. Pur C#, pas de redeploy BC. - Posting Date de l'unapply BC (codeunit
ceuHorizon.UnapplySpecificApplication/UnapplyByEntryNo/UnapplyByDocumentNo) : laPosting Datepassée àPostUnApplyCustomerdoit êtreToday(), pas laPosting Datede la DCLE Application d'origine. Les paiements peuvent remonter à l'année précédente (2025) alors que les comptables verrouillent les ranges de posting au mois courant ; utiliser la date du DCLE déclenche"posting date is not within the range of allowed posting dates". L'UI BC standard (page "Unapply Customer Entries") utiliseWorkDate()pour la même raison. - Invariant ordre — délettrage AVANT avoir : à chaque fois qu'on annule une facture par un avoir tout en déplaçant son paiement, le paiement doit être délettré de l'ancienne facture avant la création de l'avoir. Si l'avoir est créé en premier, deux symptômes selon le taux de paiement de la facture :
- Entièrement payée (
Remaining_Amount == 0) → avoir orphelin :NavCreditMemoService.CreateAsynctombe dans la brancheApplies_to_Doc_Type._blank_(ligne 95/102), on ne peut pas lettrer un avoir sur une facture déjà soldée. - Partiellement payée (
Remaining != 0) → l'avoir s'applique sur le reliquat, puis le délettrage du paiement échoue en LIFO ("Before you can unapply this entry, you must first unapply all application entries in Cust. Ledger Entry No. X that were posted after this entry") car l'avoir est une application plus récente sur la même CLE facture. Une fois le paiement délettré en premier (quand aucun avoir n'existe encore sur la facture), le délettrage réussit, la facture repasseRemaining = plein, et l'avoir se lettre proprement. InvoiceService.InternalUpdateAsync(édition d'une facture existante) : ordre correct nativement — délettrage → avoir → nouvelle facture → relettrage.BookingService.UpdateAsync(process de facturation Confirmé → Facturé, première création de la Balance enNewInvoiceProcess,currentInvoice.Id == Guid.Empty) : l'avoir était créé avant la nouvelle facture (donc avant le délettrage embarqué dansInternalCreateAsync) → avoir orphelin sur la Confirmation entièrement payée. Corrigé en inversant :_invoiceService.CreateAsync(newInvoice, true, true)(qui délettre+relettre) puis_navCreditMemoService.CreateAsync(confirmationInvoice). Dépend du délettrage/relettrage activé.
- Entièrement payée (
- Points de fidélité sur la facture : quand
Booking.ConsumeLoyaltyPoints, une ligne négative « Points fidélité (N pts) » est ajoutée au compte NAV 3916 (TVA suisse) / 3917 (hors-Suisse), répartie acompte/solde viaTravel.DepositPercentage. Le débit des points utilisés et le crédit des points gagnés sont déclenchés parInvoiceService(CreateAsync/InternalUpdateAsync), pas au POST de la résa. Détail :.claude/docs/modules/loyalty-points.md. - Sync NAV : toutes les Invoices partent vers BC après création (NavSalesInvoice + NavPostDocument).
NavNorevient et est stocké. - Sync NAV paiements : chaque
Paymentest posté viaNavPaymentService. - Montant > solde restant : si on encaisse un montant supérieur au reliquat (ou sur une facture déjà soldée), NAV refuse le lettrage avec un message cryptique (
There is no Cust. Ledger Entry within the filter … Open: Yes).PaymentService.CreateAsyncdétecte cette signature (Contains("Cust. Ledger Entry within the filter")) et renvoie un message clair + le solde restant à payer récupéré depuis NAV (INavService<PostedSalesInvoices, Invoice>.GetAllByNos→Remaining_Amount). Centralisé dansPaymentService⇒ vaut pour /Payments et l'encaissement depuis la résa. - Bookings statut → Invoice émise :
Confirmed→ InvoiceConfirmation(acompte) générée parBillService.Billed→ InvoiceBalance(solde) générée. Si l'acompte n'a pas été facturé séparément, on peut émettre directement uneSimple(à confirmer cas par cas).Canceled→ InvoiceCancellation(frais Buchard) et/ouCancellationInsurance(transmission).
- PDFs :
- Templates Liquid dans
Application/TemplatesPdf/. - Compilation :
FluidParser.Parse. Render :template.RenderAsync(context). - HTML → PDF :
iText7.HtmlConverter.ConvertToPdf. - Tous les types exposés au template doivent être enregistrés dans
MemberAccessStrategy.Register<T>dansPdfGeneratorService.cs.
- Templates Liquid dans
- QR-Bill : généré via
Codecrete.SwissQRBill.Generator(bulletin de versement suisse). Embed dans la facture viaQrBillConfirmationBalance.liquid.
Paiements
- Saferpay : CB en ligne. Workflow Initialize → Authorize → Capture (
OnlinePaymentService). - CembraPay : paiement échelonné en ligne. Service custom HTTP (
CembraPayService). Remplace SwissBilling déprécié. - SwissBilling : DÉPRÉCIÉ (Connected Service
SwissBillingV3conservé, plus utilisé en nouvelle release). Retiré des listes déroulantes de saisie manuelle en août 2026 (demande Buchard, Asana 1217138559603101) — le membre d'enum reste, de l'historique le porte : 123Payment, 272Booking, 29Gift, 8Customer, dernier usage fin 2025.PaymentTypeSelection(statique, à côté de l'enum dansPaymentType.cs) est la source unique :Hiddenliste les moyens abandonnés,Selectableest ce qu'on propose. Les 3 listes déroulantes de saisie manuelle en dépendent désormais —Payments/Form.cshtml,Bookings/CreateUpdate.cshtml.cs(catalogue de la SPA) etGifts/Form.cshtml. Les écrans d'affichage (colonne « Type » de/Payments, badge « Web (SwissBilling) » de/Bookings) passent par la valeur stockée et continuent donc d'afficher « SwissBilling » sur l'historique — c'est voulu.- ⚠ Le format des
valuediffère d'un écran à l'autre et doit être conservé :Payments/Form.cshtmlémet la valeur numérique (ce que produisaitHtml.GetEnumSelectList),Gifts/Form.cshtmlémet le nom d'enum (ce que produisait sa liste écrite en dur). Les deux se lient correctement au POST, mais la pré-sélection à l'édition compare des chaînes — changer le format ferait rouvrir le formulaire sur une liste vide. - La liste des bons cadeaux était écrite à la main et avait divergé de l'enum (août 2026) : « Carte Reka - site internet » y figurait deux fois, tandis que Twint, Carte Reka, Cash (Chaux-de-Fonds) et Cash (Neuchâtel) manquaient — un encaissement Twint sur un bon était donc impossible à saisir. Corrigé en la branchant sur
Selectable. - ⚠
BookingCatalogDto.PaymentTypesest unIDictionary<int, string>, pas un tableau.Invoice.vuefaitv-for="(label, value) in catalog.paymentTypes"et poussevaluedirectement dansbooking.paymentType: avec un tableau,valueétait l'index, qui ne coïncidait avec la valeur d'enum que parce que celle-ci est contiguë de 0 à 14 et qu'aucune entrée n'était filtrée. Masquer SwissBilling (= 8) aurait décalé tous les moyens suivants d'un cran — un paiement saisi « Carte - site internet » aurait été enregistré en « SwissBilling ». La clé du dictionnaire est la valeur d'enum, le couplage positionnel disparaît. Ne pas revenir à un tableau. ⚠BookingCatalogDto.Statusgarde ce couplage positionnel : il ne tient que parce que les deux statuts filtrés (Canceled= 6,PendingWebOrMobile= 999) sont les derniers de l'enum.
- Cash : 5 villes (Leytron / Ecuvillens / Aubonne / Chaux-de-Fonds / Neuchâtel) — chaque bureau a son compte d'encaissement comptable. Le commercial sélectionne sa ville à la création du Payment.
- Twint, Reka, PostCard : moyens locaux suisses.
Liste /Payments — recherche & tri (PaymentService.SearchAsync)
- Recherche par mot-clé : couvre les colonnes texte affichées (Client, Voyage, Bon, Club Buchard, Montant). Avant (corrigé juil. 2026) le prédicat ne portait que sur
Amount→ champ de recherche inutilisable. - Sémantique multi-mots alignée sur
InvoiceService: on part dePredicateBuilder.Trueet on compose en.And(...)entre mots-clés,||entre champs ⇒ ET entre les mots, OU entre les champs (chaque mot doit matcher au moins un champ). L'ancien code partait deFalse+.Or(...), ce qui élargissait la recherche à chaque mot ajouté au lieu de l'affiner. - Insensibilité aux accents :
EF.Functions.Collate(..., "SQL_Latin1_General_CP1_CI_AI")sur chaque champ texte, comme sur/Invoices. - Type de paiement et Date sont volontairement hors recherche (décision métier juil. 2026) : le champ est une saisie libre, ces deux colonnes ne s'y prêtent pas. Le type n'existe en base que sous forme d'enum (libellé
[Display(Name)]inconnu de SQL, résolution en mémoire nécessaire) et la date imposerait un format de saisie exact. Elles restent triables, seulement pas cherchables. - Tri :
BaseSearchAsyncne sait ordonner que sur une propriété scalaire de l'entité trouvée par réflexion. Les colonnestype/customer/travel/gift/membershipsont donc traitées explicitement avant l'appel (même approche qu'InvoiceService), puisorderColumnest vidé. ⚠customeretgiftcorrespondaient à des propriétés de navigation existantes surPayment: sans ce traitement,BaseSearchAsyncles considérait comme triables et tentait unOrderBysur une entité → tri cassé.dateetamountrestent gérés par le tri générique.
Worker — paiements en attente
Worker.DoOneMinuteJob > IOnlinePaymentService.CheckPendingPayments :
- Récupère tous les bookings
PendingWebOrMobiledontOnlineBookingPaymentStatusCheckCount != int.MaxValue(OnlinePaymentService.cs:90) — ⚠ pas de filtre surTransactionId, contrairement à ce que cette doc affirmait avant août 2026. IsPaymentApprovedtranche, dans cet ordre :PaymentApproved→RekaCheck→ marqueur « rien à encaisser » → SwissBilling → CembraPay → capture Saferpay. Sans aucune correspondance,return false.- Si succès, dans cet ordre exact (cf.
OnlinePaymentService.CheckBookings) :- Bascule
Booking.StatusversConfirmed(siPaymentDepositOnly) ouBilled(paiement total). BookingService.UpdateAsync→ crée l'Invoice (ConfirmationouBalanceselon le statut).- Crée le
PaymentviaPaymentService.CreateAsync. - Envoie email de confirmation avec PDF de la facture.
- Bascule
Piège — Invoice.TotalPaid sur le chemin direct-Billed web : PaymentService.CreateAsync ne met PAS à jour Invoice.TotalPaid (il pose juste le Payment et l'écriture NAV). Sur le chemin direct PendingWebOrMobile → Billed (paiement total web, pas d'étape Confirmation), l'Invoice Balance est créée par UpdateAsync avant que le Payment existe → TotalPaid initialisée à 0 (BookingService.cs:1962 lit confirmationInvoice.TotalPaid qui vaut 0 puisque la Confirmation n'existe pas). Conséquence sans correction : le PDF Balance.liquid (ligne Montant déjà payé, alimentée par PdfGeneratorService.cs:558 totalAlreadyPaid = balance.TotalPaid != 0 ? balance.TotalPaid * -1 : (booking.FinalAmount - balance.Total) * -1) tombe à 0 alors que le client a tout payé.
Correction (OnlinePaymentService.CheckBookings) : après création réussie du Payment, si booking.Status == Billed, on met explicitement balance.TotalPaid = paidAmount et on appelle _db.Invoices.Update(balance). Le SaveChangesAsync final persiste. Le cas symétrique Confirmation (deposit-only) n'est volontairement PAS traité — bug connu, à corriger ultérieurement si nécessaire. RekaCheck : pas concerné, aucun Payment créé (chèque envoyé par la poste, encaissement manuel ultérieur).
Piège — aucun Payment quand rien n'a été encaissé : paidAmount vaut le Total de la facture courante. Si un bon cadeau (ou des remises) couvre tout, ce total est 0 et la création du Payment partait quand même. BC refuse la ligne de journal : « Amount must have a value in Gen. Journal Line […] It cannot be zero or empty » (NavPaymentService.CreateAsync → PostJournal), l'erreur remonte en hasError, ce qui remet Booking.Status à PendingWebOrMobile (l.318-319) : la résa est rejouée à la minute suivante, re-facturée, et finit en panier abandonné après 4 tentatives — avec autant de factures et d'alertes Sentry. Depuis août 2026, la condition porte && paidAmount > 0 : pas d'encaissement, pas d'écriture. Invoice.TotalPaid reste alors à 0, ce qui est correct pour une facture à 0.
Marqueur « rien à encaisser » (Booking.NoTransactionRequiredMarker = "NO_TRANSACTION_REQUIRED") : quand le montant dû tombe à 0 (bon cadeau couvrant l'acompte ou le total, remises), le site n'ouvre aucune transaction Saferpay et n'appelait donc jamais set-transaction-id. TransactionId restant null, aucune branche d'IsPaymentApproved ne matchait → false à chaque passage → au 5ᵉ, alerte « Paiement en ligne non confirmé » à info@buchard.ch et CheckCount = int.MaxValue : réservation figée en attente, jamais facturée, bon jamais consommé. Le site pose désormais ce marqueur via GET /api/booking/{id}/set-transaction-id/{transactionId} (endpoint existant, aucune route nouvelle) et IsPaymentApproved le reconnaît.
- ⚠ L'ordre des branches est structurel : le test du marqueur doit rester au-dessus de SwissBilling / CembraPay / Saferpay. La branche CembraPay fait
TransactionId.Split('|')puis littransaction[1]→ avec le marqueur,IndexOutOfRangeExceptionavalée par letry/catch, doncfalse, donc le bug d'origine en pire (silencieux). La branche générique enverrait le marqueur àCapture(). - Effet voulu sur les places :
HasImpactOnOccurrenceOccupancyExprtesteTransactionId != null→ une résa porteuse du marqueur continue de bloquer siège et chambre au lieu de les libérer après les 20 min de hold alors qu'elle est bien vivante. - ⚠ Sécurité — trou connu et assumé (août 2026) :
SetTransactionIdest[AllowAnonymous]et en GET. Le marqueur permet donc à n'importe qui, avec une simple URL, de faire confirmer une réservation sans paiement (avant, il fallait au moins une transaction Saferpay capturable). Le recalcul serveur du montant dû — seul vrai garde-fou — est à faire ; décision explicite de le traiter dans un second temps. - RekaCheck n'utilise pas le marqueur : sa branche dédiée (
OnlinePaymentService.cs:659) est conservée pour que les résas Reka ne dépendent pas du déploiement du site.
Points d'attention / pièges
QR-bill — échec de génération silencieux (
BillService.GenerateQrBill) : touteQRBillValidationExceptionde la lib Codecrete est catchée et retourne un tableau vide → le PDF sort normalement mais avec un bulletin vide (traitillés sans slip). Capturé viaSentrySdk.CaptureException(extras : référence QR + nom du débiteur) depuis juil. 2026 (avant :Console.WriteLine, invisible en prod). La lib exige un débiteur complet dès qu'unDebtorest fourni : nom non vide + ligne « NPA localité » non vide. Piège historique corrigé :Customer.Company = ""(chaîne vide, pasNULL) passait le??et donnait un nom de débiteur vide → aucun bulletin sur toutes les factures du client. Cause des"": l'API JSON (POST /api/customer, Newtonsoft) conserve"company": ""tel quel, alors que le binding des formulaires Razor convertit les champs vides enNULL. Corrections (juil. 2026) :GenerateBilltesteIsNullOrWhiteSpace,CustomerService.CreateAsync/UpdateAsyncnormalisentCompanyvide →NULL, + cleanup SQL des données existantes. Tests :tests/Application/Services/BillServiceTests.cs.Capture sans montant : Saferpay capture le montant autorisé. Si le site web a autorisé l'acompte mais Horizon calcule le total localement, mismatch possible (cf. bug réel investigué session précédente). Toujours respecter
PaymentDepositOnlycôté worker.Invoice.NavNoest stocké après le post BC. Si un post échoue,NavNoreste vide → facture re-postée à la prochaine tentative.⚠ Écriture de paiement BC — le type compta. générale de la contrepartie vient du paramétrage BC, pas d'Horizon.
PaymentService.CreateAsyncconstruit unegenjnlline(feuilleGENERAL/ lotCOMPTA) avecAccount_Type = Customer+Bal_Account_Type = G/L Account+Bal_Account_No= un compte en dur selon le mode de paiement (1000/1038/1050/1088F/1088Gcaisses,1111CB/Twint,1010PostCard,1083Reka,1118CB/Twint en ligne,1086CembraPay/SwissBilling), et ne renseigne jamaisBal_Gen_Posting_Type: c'est BC qui le remplit depuis le paramétrage. Si ce paramétrage donne « Achat » sur la contrepartie, BC refuse la comptabilisation (postjnl) — « Account Type Customer and Bal. Gen. Posting Type Purchase is not allowed » : on ne peut pas écrire une vente dans des achats enregistrés. Incident réel (début août 2026, ~150 rejets) : un paramètre « achat » avait été modifié côté Buchard/BC — sans rapport avec la migration V28. Correction 100 % côté BC, aucun changement de code Horizon. Réflexe : sur ce message, ne pas chercher dans le code Horizon (il n'envoie pas ce champ) ni dans les mises en production — aller directement voir ce qui a bougé dans le paramétrage comptable BC. Conséquences à connaître quand ça arrive : la facture est comptabilisée mais lePaymentn'est créé ni dans BC ni dans Horizon → facture ouverte alors que le client a payé (risque de rappel), mail de confirmation non envoyé,Booking.Statusremis àPendingWebOrMobile, 4 tentatives puis alerte « Paiement en ligne non confirmé » à info@buchard.ch. La ligne de journal restée en plan est supprimée au paiement suivant (boucle de purge du lot en tête deNavPaymentService.CreateAsync— qui supprime donc aussi toute ligne saisie à la main dansGENERAL/COMPTA).Templates Liquid
Worker/__publish/TemplatesPdf/*.liquidsont des artefacts de publish. Ne pas les éditer, ils sont régénérés au build.temp_transport.liquidà la racine du repo = scratch/sandbox, à ignorer.InvoiceItemspeuvent contenir des descriptions sensibles à des conditions Liquid (ex:{% if invoiceItem.Description contains "Transport" %}) — toujours vérifier les filtres avant de modifier les libellés.VAT : taux TVA datés via
SettingVatRate.GetByDate(date)— toujours passer la date du voyage, pasDateTime.Now.TVA zone rose — split forfaitaire 25 CHF/passager : sur les voyages
VatType.PinkArea(transfrontaliers), la facture BC est splittée en deux InvoiceItems au lieu d'un : une ligne "Forfait TVA" =25 × nb_passagerssur account3010+ groupe TVA suisse normal (taxable), et une ligne "Transport et hébergement" sur account3011+ groupeZ-ROSE(exonéré) pour le reste. Mécanisme appliqué : (1) émission Confirmation/Balance dansBookingService.UpdateAsyncautour de:2008, (2) annulation complète et partielle dansBookingService.CancelAsyncautour de:2655— déduction proportionnelle du forfait sur les lignes "Annulation pour: X" qui partent en frais Buchard (pas sur les lignes assurance externe). Total client inchangé : c'est un split interne BC, ligne forfait non affichée sur les PDF (commentée dansConfirmation.liquid/Balance.liquid). ⚠ La ligne "Forfait TVA" est stockée HT (BC rajoute la TVA) alors que le forfait retiré du transport est TTC →Invoice.Totalne vaut pasSum(InvoiceItems.Price)sur un dossier zone rose : la TVA du forfait y est réintégrée, sinon paiements en ligne et QR-factures sont courts de ~1.87 CHF/passager. Détail métier complet dans.claude/docs/domain/business-rules.mdsection "TVA zone rose".Intitulé facture — vol simple (aller OU retour) : sur
Confirmation.liquid/Balance.liquid, quand tous les passagers non annulés ont le même sens de trajet unique (tousTripType.Returnou tousTripType.OneWay), le bloctravel-detailsremplace la plage « Du … au … » par « Vol retour le {TravelEnd} » ou « Vol aller le {TravelStart} ». Piloté par la valeur de contexteFlightDirectionOnly("Retour"/"Aller"/"") calculée dansPdfGeneratorService.BaseInvoice. Si les sens sont mélangés ou aller-retour → plage de dates classique. Le libellé par passager ((Adulte, Retour simple), branche Seaside vol-only) est conservé en plus (mai 2026).Champ "Solde dû restant" éditable (pages
Occurrences/BillingETSeasideDates/Billing) : l'inputRemainingBalanceAmountn'est éditable que sibooking.Status == ConfirmedOUWebBookingPendingSupplement. Ce dernier (mai 2026) = booking déjàBilled+Origin.WebOrMobile+ aucune ligne"Supplément global"sur ses factures Confirmation/Balance + le supplément change le total de réservation. Ce dernier test comparecurrentTotal != currentTotal + globalSupplementAmount, oùcurrentTotal=balance.Totalsi la facture Balance existe (balance.Id != Guid.Empty), sinonbooking.FinalAmount(la facture émise fait foi, fallback booking). Une résa web entièrement payée n'affiche le champ que si le supplément change réellement ce total ; sinon lecture seule (<input hidden value="-1">). Après facturation, le supplément est appliqué aux factures →hasGlobalSupplementdevient vrai et le total est à jour → au prochain affichage le champ disparaît.OnPostest gardé sur la même condition (!hasGlobalSupplement && globalSupplementAmount > 0) pour ne pas réinjecter la sentinelle-1des lignes en lecture seule. La même logique web est désormais répliquée surSeasideDates/Billing(mai 2026). ⚠ Le DTO portait aussi unEditRemainingBalanceAmountcalculé mais jamais lu par aucune vue → supprimé (mai 2026, DTO + calcul local dans les 2 pages Billing).⚠ Le PDF de Confirmation est régénéré en direct, il n'est PAS figé — un montant peut donc changer entre la confirmation et la facture finale.
PdfGeneratorService:730alimente le total affiché avecbooking.FinalAmountetConfirmation.liquid:211liste les suppléments depuisbooking.GlobalSupplements: deux données vives, relues à chaque impression. La facture Confirmation stockée (Invoices/InvoiceItems,BookingTotal, acompte posté dans BC), elle, est figée à l'émission et n'est recalculée que par unUpdateAsyncsur la réservation — typiquement la facturation (CreateOrUpdateInvoice). Conséquences à connaître :- Toute modification du catalogue (suppléments globaux, prix, rabais) après émission d'une confirmation change rétroactivement le PDF de confirmation que le client peut réimprimer, sans laisser de trace.
- Ces deux données vives ne bougent pas ensemble : les lignes de supplément suivent le catalogue immédiatement, alors que
FinalAmountattend le prochainUpdateAsync. Entre les deux, le PDF affiche un total qui ne correspond pas à la somme de ses propres lignes. - Cas réel (résa 16759, juil. 2026) : confirmation émise à 2478 avec 2 suppléments globaux (18 + 10) → le supplément « diesel enchaînement » est supprimé du catalogue et recréé sous un autre nom → le PDF affiche pendant 3 semaines un total de 2478 avec des lignes à 2450 + 18 = 2468 → la facturation du 14.07 recalcule et fixe
FinalAmountà 2468. La Confirmation stockée gardeBookingTotal = 2478et son acompte de 867.30 (35 % de 2478 au lieu de 2468) reste posté dans BC. L'écart n'est jamais réconcilié automatiquement. - ⚠ Supprimer un
GlobalSupplementest destructif et silencieux : la table n'a aucun soft-delete etFK_BookingGlobalSupplements_GlobalSupplements_GlobalSupplementIdest enCASCADE(idemGlobalSupplementOccurrences/GlobalSupplementSeasideDates). La suppression retire le supplément de toutes les réservations déjà confirmées qui le portaient. Pour retirer un supplément de la liste sans casser l'historique, préférerIsActive = 0ou uneEndDate. Ne jamais supprimer un supplément déjà engagé sur des réservations.
⚠ Champs de formulaire des 2 pages Billing : indexés par
bookingId, JAMAIS par position (corrigé juil. 2026). Les inputs sont nommésRemainingBalanceAmounts[<bookingId>]/BalanceAmounts[<bookingId>]et relus viaParseAmountsByBookingId(form, ...). L'ancien schéma[idx]appariait le POST au GET par rang, alors que ce sont deux exécutions distinctes deGetByOccurrence/GetBySeasideDate: sansORDER BY, SQL Server ne garantit pas le même ordre, donc une réservation pouvait encaisser le montant d'une autre. Effet concret observé : facture Balance 126533 (résa 9618) émise à 174.– au lieu de 87.– et partie dans BC (NavNo 26120439), 2 factures fausses sur le lot du 02.07.2026. Ces deux requêtes ont désormais unOrderBy(b => b.Number)— à conserver. Corollaire : unbookingIdabsent du POST n'est plus facturé et remonte une erreur sur sa ligne, au lieu d'être facturé au petit bonheur.- Rappel du mécanisme sous-jacent :
RemainingBalanceAmountest[NotMapped]et écrase le total calculé dansBookingService.CreateOrUpdateInvoice:2595(invoiceTotal = booking.RemainingBalanceAmount.Value). C'est le seul chemin par lequelInvoice.Totalpeut diverger de la somme de sesInvoiceItems— un tel écart en base est donc toujours la signature de cette saisie. BookingBillingDto.Iddoit rester renseigné dansConfigureBookingBillings(les 2 pages) : il porte la clé du formulaire, et c'est aussi lui qui permet àBookingBillings.FirstOrDefault(bb => bb.Id == booking.Id)de fusionner les lignes d'erreur au ré-affichage post-POST au lieu de les dupliquer.
- Rappel du mécanisme sous-jacent :
Colonne "Origine" sur les 2 pages Billing :
BookingBillingDto.BookingOrigin(0 = Globe / 1 = Horizon / 2 = Web ou mobile, calculébooking.Legacy ? 0 : booking.Origin == Origin.Horizon ? 1 : 2, même convention queBookings/Index). Rendu en badge. NB : les pages Billing filtrent déjà lesLegacy(Where(b => !b.Legacy)), donc "Globe" n'y apparaît jamais en pratique — le mapping est gardé pour rester identique à la liste des réservations.Délettrage/relettrage NAV des paiements lors de la transition d'invoice : couvert sur les deux chemins Horizon —
InvoiceService.InternalUpdateAsync(édition booking déjàBilled, ordre : unapply → credit memo → CreateNavInvoice → reapply) ETInvoiceService.InternalCreateAsync(création de Balance via passageConfirmed → Billedp.ex. via/SeasideDates/Billing/, ordre : credit memo de la confirmation fait parBookingService.CreateOrUpdateInvoice:2500AVANT, puis unapply → CreateNavInvoice → reapply dansInternalCreateAsync). Le "previous invoice" dans le cas Create =booking.GetCurrentInvoice(InvoiceType.Confirmation)quand on crée un Balance.NavDetCustEntryService.GetAppliedPaymentsForInvoiceest ok pour retrouver les applications même après émission d'un credit memo (il filtreUnapplied=falsesur lesDetCustEntryApplication, BC ne les détruit pas).Téléchargement partiel des PDF en cas d'erreur sur les 2 pages Billing (
/Occurrences/Billing/ET/SeasideDates/Billing/, mai 2026) : HTTP ne permet pas de renvoyer simultanément un HTML d'erreur et un binaire. QuandDownload=trueET au moins un booking échoue ET au moins un PDF a été généré, on stashe le PDF mergé dansIMemoryCache(TTL 10min, cléGuid), on retournePage()avec les erreurs plusPendingPdfDownloadKeyset sur le PageModel. La vue injecte un<iframe>invisible vers?handler=PendingPdf&key=<Guid>qui appelleOnGetPendingPdf: ce handler lit le cache, supprime la clé, et renvoie unFileResult. L'utilisateur voit les erreurs et récupère les factures OK en même temps. Dépend deservices.AddMemoryCache()(ajouté àStartup.cs).PDF — double-rendu des rabais « Early » (corrigé) :
Balance.liquid/Confirmation.liquid/Estimate.liquidrendent les lignes via deux boucles : une boucleInvoiceItemsfiltrée (qui ne doit montrer QUE la ligne auto"Réduction Early Booking"+"Pension"+"Bon cadeau") et une boucleDiscounts(qui rend tous lesDiscountde la résa, en brut{{ discount.Description }}). Le filtre étaitcontains "Early"(trop large) → il attrapait aussi l'item facture"Rabais (Earlybooking)"issu d'unDiscountmanuel nommé « Earlybooking », déjà rendu par la boucleDiscounts. Résultat : le même discount imprimé deux fois (« Rabais (Earlybooking) » + « Earlybooking »), donnant l'illusion d'un 3ᵉ early booking sur le PDF alors que le total (calculé indépendamment depuis le total facture) n'en déduit que deux. Fix : filtre resserré àcontains "Réduction Early Booking". ⚠ Le doublon économique sous-jacent (unDiscountmanuel « Earlybooking » qui fait doublon avec la ligne autoBookingService.cs:2301-2318) est un problème de données distinct : il vient de résas faites le jour J de la deadline avant le fix date-only<=(commitsc27ded18/6028f6d8, mai 2026) qui a activé la réduction auto rétroactivement (champsTravel.EarlyBooking*lus en live, cf.business-rules.md §169). Correctif données = supprimer leDiscountmanuel ; le template ne corrige QUE l'affichage en double.SettingInsuranceest daté par[StartDate, EndDate]— quand le prix change pour la nouvelle année, une nouvelle ligne SQL est créée (Id différent, mêmeSettingInsuranceTypeId).BookingPassengerInsurance.SettingInsuranceIdreste figé à la souscription. Le back (BookingServiceannulation:2670) résout par Id sans filtre de date → fonctionne pour les résas anciennes. Le front filtrait historiquement par fenêtre temporelle à 3 endroits (PaymentInsurances.vuecheckbox,OnGetInsurancesendpoint,cancel-booking/App.vue:calculateFee), causant des bookings cross-year où l'assurance souscrite n'apparaissait plus → cliente facturée comme sans assurance. Pattern actuel :OnGetInsurancesacceptebookingIdet renvoie aussi les assurances historiquement souscrites taguéesIsOutOfPeriod = true;CancelOnePassenger.vuepropose dans le dropdown l'assurance souscrite (ancienne) ET l'équivalent courant du mêmeSettingInsuranceTypeId→ la cliente choisit le prix qui s'applique à l'annulation.
Conventions locales
- Le
BookingPartpour bons partenariat : flag/processus géré parBillService(à investiguer plus en profondeur). - Les emails de paiement sortent via
SendGridService(templates RazorTemplates/).
Dépendances
- ←
booking(source des Invoices). - ←
loyalty-points(ligne de réduction « Points fidélité », débit/crédit déclenchés ici). - ←
gifts(factures Gift / BuchardPart / Compensation). - ←
customer-membership(factures Membership / MembershipRenew). - →
nav-bc-integration(post de tout document). - ←
settings(SettingVatRate, SettingGeneral).

