Skip to content

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 de CreateOrUpdateInvoice.

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 :
    • TypeInvoiceType : Confirmation / Balance / Simple / Cancellation / CancellationInsurance / BuchardPart / Compensation.
    • CategoryInvoiceCategory : 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. PaymentType indique 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, comptes 3010/3011.
    • CancellationInsurance : même contenu mais à destination de l'assurance — passagers avec une assurance interne, compte 3022, CustomerId nul. 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 passe Enabled = false. Lecture courante : booking.Invoices.FirstOrDefault(i => i.Type == X && i.Enabled). Helper Booking.GetCurrentInvoice(InvoiceType).
  • Numérotation & référence QR : Invoice.Number est un int attribué en C# par séquence _db.Invoices.Max(i => i.Number) + 1 (départ 100000), pas une identity SQL — donc non concurrent-safe (même fragilité que Booking.Number). Ce Number part dans BC comme Your_Reference (InvoiceService.cs:597, pas l'identité du document BC qui est le NavNo) et sert de base à la référence QR : InvoiceReference.Generate(Number) = "21" + Number cadré à 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, mode NewInvoiceProcess courant) :
    • ConfirmationMax+1.
    • Confirmé→Facturé (1ʳᵉ Balance) → réutilise le Number et le NavNo de 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 nouveau Number/référence (Max+1) — cohérent avec le flux d'édition. (Avant mai 2026 : le Number é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 (ancienne Enabled=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.
  • 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 via UnapplySpecificApplicationAsync(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 via ApplyPaymentToInvoicesAsync(paymentEntryNo, [newInvoiceNavNo], DateTime.Today). La codeunit BC custom ceuHorizon (déployée chez l'intégrateur SaaS) expose ces opérations. Conséquence sur Invoice.TotalPaid : BC est désormais la source de vérité du "déjà payé", donc TotalPaid n'est PLUS carry-forward (pas de += paidAmount depuis l'ancienne facture). RemainingDueAmount se calcule en déduisant le Remaining_Amount BC du BookingTotal. Les données antérieures à cette feature restent cohérentes : elles ont TotalPaid != 0 mais BC remaining = total (paiement orphelin sur facture annulée), donc la formule BookingTotal - 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é avec Cust_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 les paymentEntryNos trié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 de InvoiceService respecte 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. PostUnApplyCustomer reverse 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) : la Posting Date passée à PostUnApplyCustomer doit être Today(), pas la Posting Date de 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") utilise WorkDate() 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.CreateAsync tombe dans la branche Applies_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 repasse Remaining = 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 en NewInvoiceProcess, currentInvoice.Id == Guid.Empty) : l'avoir était créé avant la nouvelle facture (donc avant le délettrage embarqué dans InternalCreateAsync) → 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é.
  • 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 via Travel.DepositPercentage. Le débit des points utilisés et le crédit des points gagnés sont déclenchés par InvoiceService (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). NavNo revient et est stocké.
  • Sync NAV paiements : chaque Payment est posté via NavPaymentService.
  • 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.CreateAsync dé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>.GetAllByNosRemaining_Amount). Centralisé dans PaymentService ⇒ vaut pour /Payments et l'encaissement depuis la résa.
  • Bookings statut → Invoice émise :
    • Confirmed → Invoice Confirmation (acompte) générée par BillService.
    • Billed → Invoice Balance (solde) générée. Si l'acompte n'a pas été facturé séparément, on peut émettre directement une Simple (à confirmer cas par cas).
    • Canceled → Invoice Cancellation (frais Buchard) et/ou CancellationInsurance (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> dans PdfGeneratorService.cs.
  • QR-Bill : généré via Codecrete.SwissQRBill.Generator (bulletin de versement suisse). Embed dans la facture via QrBillConfirmationBalance.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 SwissBillingV3 conservé, 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 : 123 Payment, 272 Booking, 29 Gift, 8 Customer, dernier usage fin 2025.
    • PaymentTypeSelection (statique, à côté de l'enum dans PaymentType.cs) est la source unique : Hidden liste les moyens abandonnés, Selectable est 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) et Gifts/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 value diffère d'un écran à l'autre et doit être conservé : Payments/Form.cshtml émet la valeur numérique (ce que produisait Html.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.PaymentTypes est un IDictionary<int, string>, pas un tableau. Invoice.vue fait v-for="(label, value) in catalog.paymentTypes" et pousse value directement dans booking.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.Status garde 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 de PredicateBuilder.True et 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 de False + .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 : BaseSearchAsync ne sait ordonner que sur une propriété scalaire de l'entité trouvée par réflexion. Les colonnes type / customer / travel / gift / membership sont donc traitées explicitement avant l'appel (même approche qu'InvoiceService), puis orderColumn est vidé. ⚠ customer et gift correspondaient à des propriétés de navigation existantes sur Payment : sans ce traitement, BaseSearchAsync les considérait comme triables et tentait un OrderBy sur une entité → tri cassé. date et amount restent gérés par le tri générique.

Worker — paiements en attente

Worker.DoOneMinuteJob > IOnlinePaymentService.CheckPendingPayments :

  1. Récupère tous les bookings PendingWebOrMobile dont OnlineBookingPaymentStatusCheckCount != int.MaxValue (OnlinePaymentService.cs:90) — ⚠ pas de filtre sur TransactionId, contrairement à ce que cette doc affirmait avant août 2026.
  2. IsPaymentApproved tranche, dans cet ordre : PaymentApprovedRekaCheckmarqueur « rien à encaisser » → SwissBilling → CembraPay → capture Saferpay. Sans aucune correspondance, return false.
  3. Si succès, dans cet ordre exact (cf. OnlinePaymentService.CheckBookings) :
    • Bascule Booking.Status vers Confirmed (si PaymentDepositOnly) ou Billed (paiement total).
    • BookingService.UpdateAsync → crée l'Invoice (Confirmation ou Balance selon le statut).
    • Crée le Payment via PaymentService.CreateAsync.
    • Envoie email de confirmation avec PDF de la facture.

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.CreateAsyncPostJournal), 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 matchaitfalse à 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 lit transaction[1] → avec le marqueur, IndexOutOfRangeException avalée par le try/catch, donc false, donc le bug d'origine en pire (silencieux). La branche générique enverrait le marqueur à Capture().
  • Effet voulu sur les places : HasImpactOnOccurrenceOccupancyExpr teste TransactionId != 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) : SetTransactionId est [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) : toute QRBillValidationException de 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é via SentrySdk.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'un Debtor est fourni : nom non vide + ligne « NPA localité » non vide. Piège historique corrigé : Customer.Company = "" (chaîne vide, pas NULL) 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 en NULL. Corrections (juil. 2026) : GenerateBill teste IsNullOrWhiteSpace, CustomerService.CreateAsync/UpdateAsync normalisent Company vide → 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 PaymentDepositOnly côté worker.

  • Invoice.NavNo est stocké après le post BC. Si un post échoue, NavNo reste 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.CreateAsync construit une genjnlline (feuille GENERAL / lot COMPTA) avec Account_Type = Customer + Bal_Account_Type = G/L Account + Bal_Account_No = un compte en dur selon le mode de paiement (1000/1038/1050/1088F/1088G caisses, 1111 CB/Twint, 1010 PostCard, 1083 Reka, 1118 CB/Twint en ligne, 1086 CembraPay/SwissBilling), et ne renseigne jamais Bal_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 le Payment n'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.Status remis à 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 de NavPaymentService.CreateAsync — qui supprime donc aussi toute ligne saisie à la main dans GENERAL/COMPTA).

  • Templates Liquid Worker/__publish/TemplatesPdf/*.liquid sont 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.

  • InvoiceItems peuvent 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, pas DateTime.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_passagers sur account 3010 + groupe TVA suisse normal (taxable), et une ligne "Transport et hébergement" sur account 3011 + groupe Z-ROSE (exonéré) pour le reste. Mécanisme appliqué : (1) émission Confirmation/Balance dans BookingService.UpdateAsync autour de :2008, (2) annulation complète et partielle dans BookingService.CancelAsync autour 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 dans Confirmation.liquid / Balance.liquid). ⚠ La ligne "Forfait TVA" est stockée HT (BC rajoute la TVA) alors que le forfait retiré du transport est TTCInvoice.Total ne vaut pas Sum(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.md section "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 (tous TripType.Return ou tous TripType.OneWay), le bloc travel-details remplace la plage « Du … au … » par « Vol retour le {TravelEnd} » ou « Vol aller le {TravelStart} ». Piloté par la valeur de contexte FlightDirectionOnly ("Retour" / "Aller" / "") calculée dans PdfGeneratorService.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/Billing ET SeasideDates/Billing) : l'input RemainingBalanceAmount n'est éditable que si booking.Status == Confirmed OU WebBookingPendingSupplement. 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 compare currentTotal != currentTotal + globalSupplementAmount, où currentTotal = balance.Total si la facture Balance existe (balance.Id != Guid.Empty), sinon booking.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 → hasGlobalSupplement devient vrai et le total est à jour → au prochain affichage le champ disparaît. OnPost est gardé sur la même condition (!hasGlobalSupplement && globalSupplementAmount > 0) pour ne pas réinjecter la sentinelle -1 des lignes en lecture seule. La même logique web est désormais répliquée sur SeasideDates/Billing (mai 2026). ⚠ Le DTO portait aussi un EditRemainingBalanceAmount calculé 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:730 alimente le total affiché avec booking.FinalAmount et Confirmation.liquid:211 liste les suppléments depuis booking.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 un UpdateAsync sur 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 FinalAmount attend le prochain UpdateAsync. 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 garde BookingTotal = 2478 et 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 GlobalSupplement est destructif et silencieux : la table n'a aucun soft-delete et FK_BookingGlobalSupplements_GlobalSupplements_GlobalSupplementId est en CASCADE (idem GlobalSupplementOccurrences / 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érer IsActive = 0 ou une EndDate. 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és RemainingBalanceAmounts[<bookingId>] / BalanceAmounts[<bookingId>] et relus via ParseAmountsByBookingId(form, ...). L'ancien schéma [idx] appariait le POST au GET par rang, alors que ce sont deux exécutions distinctes de GetByOccurrence / GetBySeasideDate : sans ORDER 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 un OrderBy(b => b.Number) — à conserver. Corollaire : un bookingId absent 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 : RemainingBalanceAmount est [NotMapped] et écrase le total calculé dans BookingService.CreateOrUpdateInvoice:2595 (invoiceTotal = booking.RemainingBalanceAmount.Value). C'est le seul chemin par lequel Invoice.Total peut diverger de la somme de ses InvoiceItems — un tel écart en base est donc toujours la signature de cette saisie.
    • BookingBillingDto.Id doit rester renseigné dans ConfigureBookingBillings (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.
  • 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 que Bookings/Index). Rendu en badge. NB : les pages Billing filtrent déjà les Legacy (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) ET InvoiceService.InternalCreateAsync (création de Balance via passage Confirmed → Billed p.ex. via /SeasideDates/Billing/, ordre : credit memo de la confirmation fait par BookingService.CreateOrUpdateInvoice:2500 AVANT, puis unapply → CreateNavInvoice → reapply dans InternalCreateAsync). Le "previous invoice" dans le cas Create = booking.GetCurrentInvoice(InvoiceType.Confirmation) quand on crée un Balance. NavDetCustEntryService.GetAppliedPaymentsForInvoice est ok pour retrouver les applications même après émission d'un credit memo (il filtre Unapplied=false sur les DetCustEntry Application, 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. Quand Download=true ET au moins un booking échoue ET au moins un PDF a été généré, on stashe le PDF mergé dans IMemoryCache (TTL 10min, clé Guid), on retourne Page() avec les erreurs plus PendingPdfDownloadKey set sur le PageModel. La vue injecte un <iframe> invisible vers ?handler=PendingPdf&key=<Guid> qui appelle OnGetPendingPdf : ce handler lit le cache, supprime la clé, et renvoie un FileResult. L'utilisateur voit les erreurs et récupère les factures OK en même temps. Dépend de services.AddMemoryCache() (ajouté à Startup.cs).

  • PDF — double-rendu des rabais « Early » (corrigé) : Balance.liquid / Confirmation.liquid / Estimate.liquid rendent les lignes via deux boucles : une boucle InvoiceItems filtrée (qui ne doit montrer QUE la ligne auto "Réduction Early Booking" + "Pension" + "Bon cadeau") et une boucle Discounts (qui rend tous les Discount de la résa, en brut {{ discount.Description }}). Le filtre était contains "Early" (trop large) → il attrapait aussi l'item facture "Rabais (Earlybooking)" issu d'un Discount manuel nommé « Earlybooking », déjà rendu par la boucle Discounts. 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 (un Discount manuel « Earlybooking » qui fait doublon avec la ligne auto BookingService.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 <= (commits c27ded18/6028f6d8, mai 2026) qui a activé la réduction auto rétroactivement (champs Travel.EarlyBooking* lus en live, cf. business-rules.md §169). Correctif données = supprimer le Discount manuel ; le template ne corrige QUE l'affichage en double.

  • SettingInsurance est daté par [StartDate, EndDate] — quand le prix change pour la nouvelle année, une nouvelle ligne SQL est créée (Id différent, même SettingInsuranceTypeId). BookingPassengerInsurance.SettingInsuranceId reste figé à la souscription. Le back (BookingService annulation :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.vue checkbox, OnGetInsurances endpoint, 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 : OnGetInsurances accepte bookingId et renvoie aussi les assurances historiquement souscrites taguées IsOutOfPeriod = true ; CancelOnePassenger.vue propose dans le dropdown l'assurance souscrite (ancienne) ET l'équivalent courant du même SettingInsuranceTypeId → la cliente choisit le prix qui s'applique à l'annulation.

Conventions locales

  • Le BookingPart pour bons partenariat : flag/processus géré par BillService (à investiguer plus en profondeur).
  • Les emails de paiement sortent via SendGridService (templates Razor Templates/).

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).

Contributors

No contributors

Changelog

No recent changes