Skip to content

Formulaires & mail

Deux mecanismes coexistent : Umbraco Forms (formulaires construits par l'editeur) et le formulaire de contact custom Nanoxi.

Umbraco Forms

  • Widget widgetUmbracoForms : propriete form (FormPicker) pour embarquer n'importe quel Form publie.
  • Theme default, template email custom Forms/Emails/Nanoxi-Template.cshtml.
  • Config dans src/Umbraco/appsettings.json : workflow par defaut desactive (DisableDefaultWorkflow), feuille de style desactivee, indicateur * sur les champs requis, retention des soumissions 365 jours, suppression planifiee quotidienne.
  • SiteComposer (App.UmbracoForms/SiteComposer.cs) exclut les workflows Forms par defaut et ajoute le workflow maison SendRazorEmailNanoxi.

Formulaire de contact custom

  • Widget widgetContactForm : affiliatePicker (MultiNodeTreePicker, choisit le destinataire = un affiliate) + subjectChooser (Contentment.ContentBlocks, categories de sujets = contacttopic).
  • Traitement : WidgetContactFormController (SurfaceController) -> validation FluentValidation (WidgetContactFormViewModelValidator) + reCAPTCHA v3 -> envoi mail (IEmailService) -> log en base (IContactFormRepository, table nanoxi_ContactFormLog).
  • Validation client : FormHelper traduit les regles en jQuery Validation.

Paiement en ligne

Greffe un paiement sur n'importe quel Umbraco Form, via un workflow (pas un widget).

  • Workflow RequestPaymentWorkflow (App.UmbracoForms/Workflows/, alias requestPaymentNanoxi, groupe « Paiement ») : ajoute « Demander un paiement » a un Form dans le designer Forms. Reglages : Montant (CHF), Libelle, Email du service interne, Sujet de l'email de confirmation, Message d'introduction (richtext). A la soumission, cree une ligne nanoxi_Payment (statut pending, Reference = Record.UniqueId, FormDefinitionId = Form.Id pour relier la demande a sa soumission ; les 3 reglages email sont snapshotes sur le paiement pour l'envoi post-paiement) via IPaymentRepository, et memorise la reference en session (nanoxi.payment.ref). Enregistre dans SiteComposer (.Add<RequestPaymentWorkflow>()).
  • Bouton submit contextuel : le theme Forms Form.cshtml (theme default) detecte si le workflow paiement est actif sur le formulaire (IWorkflowService.Get(form) + RequestPaymentWorkflow.TypeId, resolution lazy via RequestServices, try/catch — un libelle ne casse jamais le rendu) : le bouton de soumission final devient alors « Passer au paiement » avec un cadenas SVG inline (<button> au lieu d'<input>, memes attributs __next/data-umb), au lieu du SubmitCaption configure par l'editeur. Pages intermediaires (multi-page) inchangees.
  • Redirection automatique : le theme Forms Submitted.cshtml (theme default) detecte la reference en session apres soumission et redirige le citoyen vers la passerelle (/umbraco/surface/Payment/Pay?reference=...), avec bouton de repli « Procéder au paiement » (et <noscript> meta-refresh). Les formulaires sans paiement affichent le message standard. Suppose un POST page entiere (cas par defaut) ; en AJAX, le bouton de repli prend le relais.
  • Passerelle PaymentController (SurfaceController, App.Core) : routes Pay / Return / Notify. GET Pay resout la demande strictement par reference (querystring > session — plus de fallback « derniere pending ») puis appelle ISaferpayService.InitializeAsync (PaymentPage, avec NotifyUrl) -> stocke le ProviderToken -> Redirect vers la page hebergee Saferpay. En cas d'echec d'init ou Saferpay non configure, redirige vers la page source en echec (aucune vue rendue cote serveur). Retry : un statut failed/expired (= aucun fonds preleve) est re-payablePay rouvre atomiquement la demande (TryReopen : failed|expired -> pending, token/transaction effaces) et demarre une nouvelle session Saferpay ; paid/received (fonds encaisses ou reconcilies) restent definitivement verrouilles (anti double-encaissement).
  • Finalisation centralisee : IPaymentFinalizer / PaymentFinalizer (App.Interface.Payment / App.Core.Services) est le seul point de finalisation. Algorithme : ConfirmAsync(token, montant, devise) (assert + capture) -> classification de l'erreur (expire/transitoire/definitif) -> IPaymentRepository.TryFinalize(reference, newStatus, transactionId) (UPDATE ... WHERE Status='pending', atomique) -> email de confirmation uniquement si la transition a reussi (gagnant seul). Idempotent : une demande deja dans un etat final n'est pas touchee. Classification typee : les erreurs Saferpay sont classees via les champs types Error.ErrorName (enum) et Error.Behavior (RETRY/RETRY_LATER -> transitoire) — JAMAIS via ErrorMessage/ToString() (prose humaine, Saferpay interdit de la parser ; un ancien string-matching sur les tokens etait du code mort). ErrorName == NONE = erreur transport fabriquee par le SDK -> transitoire. Statut de transaction PENDING (moyens asynchrones, ex. PostFinance Instant Payout) -> transitoire. TRANSACTION_ALREADY_CAPTURED a la capture = succes (concurrence). Le bloc d'interpretation de capture est partage (EvaluateCapture) entre ConfirmAsync et CaptureByTransactionAsync. Dans Pay, le token est pose par TrySetProviderToken (UPDATE cible WHERE Status='pending') et jamais par un Update pleine-ligne : si la demande a ete finalisee pendant l'appel d'init Saferpay, la page de paiement ne s'ouvre pas (anti double-encaissement).
  • Trois declencheurs independants de IPaymentFinalizer.FinalizeAsync :
    1. GET Return (retour navigateur depuis Saferpay) : idempotent, redirige vers la page source avec ?paiement=success, ?paiement=failed ou ?paiement=processing (cas transitoire : Saferpay pas encore concluant, le job finalisera ; _Layout.cshtml affiche une page « Paiement en cours de traitement » neutre, pas un echec).
    2. GET|POST Notify (webhook serveur-a-serveur Saferpay, via Notification.SuccessNotifyUrl et FailNotifyUrl pointant sur le meme endpoint — NotifyUrl n'existe plus en spec >= 1.23 pour la PaymentPage, erreur VALIDATION_FAILED sinon) : finalise independamment du retour navigateur ; repond 200 OK. Atteint meme si le citoyen ferme l'onglet apres paiement. Signe : la NotifyUrl porte un parametre sig = HMAC-SHA256(reference, NotifySecret) ; un appel sans signature valide est ignore (200 OK sans finalisation) — empeche un tiers de forcer une finalisation. Si NotifySecret est vide, la verification est desactivee (dev).
    3. PaymentReconciliationJob (Quartz, [DisallowConcurrentExecution]) : toutes les ReconciliationIntervalMinutes minutes, itere sur les pending et appelle FinalizeAsync. Filet de securite contre les orphelins. Ignore les pending plus jeunes que ReconciliationMinAgeMinutes (le payeur peut encore etre sur la page Saferpay ; Return/Notify couvrent cette fenetre). Force-expire (expired) les demandes pending dont l'age depasse PendingMaxLifetimeHours. Les deux gardes d'age s'appuient sur la derniere activite (UpdatedDate ?? CreatedDate — init et reopen rafraichissent UpdatedDate), pas sur la date de creation : un retry sur une vieille demande repart avec une fenetre fraiche. Un pending sans token ni transaction (citoyen n'a pas encore clique « payer ») n'est JAMAIS finalise par FinalizeAsync (retour transient) — sinon un prefetcher/tiers touchant Return?reference=... tuerait une demande payable ; seul le filet d'age l'expire.
  • Saferpay : ISaferpayService / SaferpayService (App.Core/Services, package SaferPay.Netcore), options SaferpayOptions (Nanoxi:CMS:Saferpay : Enabled, SandBox, CustomerId, TerminalId, ApiUsername, ApiPassword, BaseUrl (URL publique canonique, ex. https://www.pully.ch, pour que le webhook Notify soit joignable par Saferpay derriere un proxy ; fallback sur le host de la requete si vide), NotifySecret (cle HMAC de signature de la NotifyUrl ; vide = verification desactivee), ReconciliationIntervalMinutes (defaut : 5), ReconciliationMinAgeMinutes (defaut : 30), PendingMaxLifetimeHours (defaut : 168)). Token stocke dans nanoxi_Payment.ProviderToken ; l'id de transaction Saferpay est stocke dans ProviderTransactionId a la confirmation (audit / remboursements). ConfirmAsync renvoie un SaferpayPaymentResult (Paid, TokenExpired, Transient, TransactionId, CaptureId, Status, Error). Credentials hors commit (appsettings.local.json). Les moyens de paiement proposes (carte, TWINT, ...) sont ceux actives sur le terminal Saferpay : l'init ne pose aucune restriction PaymentMethods, le choix se fait sur la page hebergee. Une capture concurrente (ALREADY_CAPTURED) est traitee comme un succes. Resilience capture : des qu'une transaction est autorisee, son id est persiste tot (IPaymentRepository.SetProviderTransactionId) ; si le token PaymentPage expire avant la capture, CaptureByTransactionAsync(transactionId) capture directement par id de transaction (independant du token), evitant de perdre un encaissement recuperable. Une capture echouee de maniere transitoire reste pending (rejeu), une capture definitivement impossible -> expired (aucun fonds preleve).
  • Donnees : PaymentMapping ([TableName("nanoxi_Payment")], dont FormDefinitionId = lien vers la definition Forms, ProviderTransactionId = id transaction Saferpay) + IPaymentRepository/PaymentRepository (App.Repository, pattern IScopeProvider), enregistre dans Program.cs. Methode cle : TryFinalize(reference, newStatus, transactionId) (UPDATE atomique WHERE Status='pending', renvoie true si la transition a eu lieu). GetPending() retourne toutes les demandes pending (pour le job). Table creee par data/01.Scratch/050.NanoxiTable/Payment.sql (colonne Method deprecated, rendue nullable ; colonne ProviderTransactionId ; colonnes NotificationEmail/EmailSubject/EmailIntro = snapshot des reglages email du workflow). Les alter table ... if not exists du script rendent l'ajout de colonne idempotent : a rejouer sur les bases existantes (local/staging/prod).
  • Back-office : dashboard App_Plugins/PaymentsDashboard (section Contenu) + PaymentsApiController : UmbracoAuthorizedApiController (route authentifiee /umbraco/backoffice/api/PaymentsApi/ : GetAll, MarkAsReceived, GetDetail). MarkAsReceived n'agit que sur une demande pending (jamais d'ecrasement d'un statut final paid/failed/received/expired). Liste des paiements (montant, reference, statut, date) + action « Marquer recu » + bouton « Ouvrir » : panneau lateral affichant le recap du paiement, le formulaire lie et le detail des champs saisis (charges via IFormService + IRecordStorage.GetRecordByUniqueId(Reference, Form), avec lien vers la section Formulaires). Sans FormDefinitionId (anciennes demandes), le panneau montre le recap sans les champs.
  • Permissions : le dashboard respecte la securite par formulaire d'Umbraco Forms. GetAll/GetDetail/MarkAsReceived filtrent via IFormsSecurity.FilterFormIdsForCurrentUser (Umbraco.Forms.Core.Security) : un utilisateur ne voit/modifie que les paiements dont le formulaire lie lui est accessible (configure la securite par formulaire dans la section Formulaires). Sans restriction configuree, tout passe (comportement inchange). Les paiements sans FormDefinitionId ne sont rattachables a aucune permission et sont exclus du dashboard. Un utilisateur sans acces a aucun formulaire ne voit rien : GetAll renvoie canView=false (= FilterFormIdsForCurrentUser sur tous les formulaires est vide) et le dashboard ne rend ni coquille, ni stats, ni tableau. Limite : l'onglet du dashboard reste visible dans la section Contenu (visibilite d'onglet non pilotable par la securite Forms) ; seul son contenu est masque.
  • Email de confirmation : envoye par IPaymentConfirmationMailer (App.Interface.Payment ; impl. PaymentConfirmationMailer dans App.UmbracoForms, car App.Core ne reference pas Forms), appele par PaymentFinalizer uniquement pour le gagnant de la transition (prevention des doublons en cas de concurrence). Recharge la soumission (IRecordStorage.GetRecordByUniqueId) pour auto-detecter l'email du citoyen (1er champ ressemblant a un email, priorite aux libelles contenant « mail ») et lister les champs saisis. Destinataires : le citoyen et l'Email du service interne (snapshote sur le paiement), avec deux variantes du meme template (drapeau IsInternalNotification sur PaymentEmailModel) : le citoyen recoit le recu (sujet = reglage du workflow suffixe du nom de la demande — sauf si l'editeur l'y a deja inclus —, message d'intro = reglage du workflow, titre « Paiement confirme », disclaimer justificatif) ; le service interne recoit une notification (sujet [Paiement recu] {formulaire} - {devise} {montant}, titre « Nouveau paiement recu », intro factuelle generee, bouton vers le back-office /umbraco#/content, disclaimer « notification automatique »). Recap + champs saisis identiques dans les deux. Contenu : rendu HTML du template de marque Views/Partials/Forms/Emails/PaymentConfirmation.cshtml (modele PaymentEmailModel, rendu par IViewRender), calque sur le template email Nanoxi (logo forms-logo.png, titre, intro, recap paiement — statut/montant/demande/reference/date — puis detail des champs saisis, disclaimer). Envoi via IWorkflowEmailService (meme sender que SendRazorEmailNanoxi), best-effort (n'interrompt jamais le retour de paiement). Enregistre dans Program.cs.
  • Statuts : pending -> paid/failed/expired (passerelle/webhook/job) ; received = marque manuellement depuis pending (suivi des versements differes, ex. futur QR-facture) ; failed/expired -> pending via TryReopen (retry citoyen, aucun fonds n'avait ete preleve). Etats definitivement verrouilles : paid, received.
  • Limites assumees : remboursements non implementes (hors perimetre) ; gestion des secrets Saferpay et SMTP hors commit (configuration manuelle par environnement).

Mail

  • IEmailService (App.Mail) : rend un template Razor via IViewRender puis envoie en SMTP.
  • SMTP : Mailgun (smtp.eu.mailgun.org:587), configurable par domaine (IOptions<SmtpOptions>).
  • Le template email reference Model.SiteDomain pour construire les URL absolues des assets.

Newsletter

Widget widgetNewsletter -> WidgetNewsletterController + INewsletterService (implementation NewsletterInfomaniakService). Validation via WidgetNewsletterViewModelValidator.

Contributors

No contributors

Changelog

No recent changes