Formulaires & mail
Deux mecanismes coexistent : Umbraco Forms (formulaires construits par l'editeur) et le formulaire de contact custom Nanoxi.
Umbraco Forms
- Widget
widgetUmbracoForms: proprieteform(FormPicker) pour embarquer n'importe quel Form publie. - Theme
default, template email customForms/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 maisonSendRazorEmailNanoxi.
Formulaire de contact custom
- Widget
widgetContactForm:affiliatePicker(MultiNodeTreePicker, choisit le destinataire = unaffiliate) +subjectChooser(Contentment.ContentBlocks, categories de sujets =contacttopic). - Traitement :
WidgetContactFormController(SurfaceController) -> validation FluentValidation (WidgetContactFormViewModelValidator) + reCAPTCHA v3 -> envoi mail (IEmailService) -> log en base (IContactFormRepository, tablenanoxi_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/, aliasrequestPaymentNanoxi, 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 lignenanoxi_Payment(statutpending,Reference = Record.UniqueId,FormDefinitionId = Form.Idpour relier la demande a sa soumission ; les 3 reglages email sont snapshotes sur le paiement pour l'envoi post-paiement) viaIPaymentRepository, et memorise la reference en session (nanoxi.payment.ref). Enregistre dansSiteComposer(.Add<RequestPaymentWorkflow>()). - Bouton submit contextuel : le theme Forms
Form.cshtml(themedefault) detecte si le workflow paiement est actif sur le formulaire (IWorkflowService.Get(form)+RequestPaymentWorkflow.TypeId, resolution lazy viaRequestServices, 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 duSubmitCaptionconfigure par l'editeur. Pages intermediaires (multi-page) inchangees. - Redirection automatique : le theme Forms
Submitted.cshtml(themedefault) 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) : routesPay/Return/Notify.GET Payresout la demande strictement par reference (querystring > session — plus de fallback « derniere pending ») puis appelleISaferpayService.InitializeAsync(PaymentPage, avecNotifyUrl) -> stocke leProviderToken->Redirectvers 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 statutfailed/expired(= aucun fonds preleve) est re-payable —Payrouvre 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 typesError.ErrorName(enum) etError.Behavior(RETRY/RETRY_LATER -> transitoire) — JAMAIS viaErrorMessage/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 transactionPENDING(moyens asynchrones, ex. PostFinance Instant Payout) -> transitoire.TRANSACTION_ALREADY_CAPTUREDa la capture = succes (concurrence). Le bloc d'interpretation de capture est partage (EvaluateCapture) entreConfirmAsyncetCaptureByTransactionAsync. DansPay, le token est pose parTrySetProviderToken(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:GET Return(retour navigateur depuis Saferpay) : idempotent, redirige vers la page source avec?paiement=success,?paiement=failedou?paiement=processing(cas transitoire : Saferpay pas encore concluant, le job finalisera ;_Layout.cshtmlaffiche une page « Paiement en cours de traitement » neutre, pas un echec).GET|POST Notify(webhook serveur-a-serveur Saferpay, viaNotification.SuccessNotifyUrletFailNotifyUrlpointant sur le meme endpoint —NotifyUrln'existe plus en spec >= 1.23 pour la PaymentPage, erreurVALIDATION_FAILEDsinon) : finalise independamment du retour navigateur ; repond200 OK. Atteint meme si le citoyen ferme l'onglet apres paiement. Signe : laNotifyUrlporte un parametresig= HMAC-SHA256(reference,NotifySecret) ; un appel sans signature valide est ignore (200 OK sans finalisation) — empeche un tiers de forcer une finalisation. SiNotifySecretest vide, la verification est desactivee (dev).PaymentReconciliationJob(Quartz,[DisallowConcurrentExecution]) : toutes lesReconciliationIntervalMinutesminutes, itere sur lespendinget appelleFinalizeAsync. Filet de securite contre les orphelins. Ignore les pending plus jeunes queReconciliationMinAgeMinutes(le payeur peut encore etre sur la page Saferpay ; Return/Notify couvrent cette fenetre). Force-expire (expired) les demandespendingdont l'age depassePendingMaxLifetimeHours. Les deux gardes d'age s'appuient sur la derniere activite (UpdatedDate ?? CreatedDate— init et reopen rafraichissentUpdatedDate), pas sur la date de creation : un retry sur une vieille demande repart avec une fenetre fraiche. Unpendingsans token ni transaction (citoyen n'a pas encore clique « payer ») n'est JAMAIS finalise parFinalizeAsync(retourtransient) — sinon un prefetcher/tiers touchantReturn?reference=...tuerait une demande payable ; seul le filet d'age l'expire.
- Saferpay :
ISaferpayService/SaferpayService(App.Core/Services, package SaferPay.Netcore), optionsSaferpayOptions(Nanoxi:CMS:Saferpay:Enabled,SandBox,CustomerId,TerminalId,ApiUsername,ApiPassword,BaseUrl(URL publique canonique, ex.https://www.pully.ch, pour que le webhookNotifysoit joignable par Saferpay derriere un proxy ; fallback sur le host de la requete si vide),NotifySecret(cle HMAC de signature de laNotifyUrl; vide = verification desactivee),ReconciliationIntervalMinutes(defaut : 5),ReconciliationMinAgeMinutes(defaut : 30),PendingMaxLifetimeHours(defaut : 168)). Token stocke dansnanoxi_Payment.ProviderToken; l'id de transaction Saferpay est stocke dansProviderTransactionIda la confirmation (audit / remboursements).ConfirmAsyncrenvoie unSaferpayPaymentResult(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 restrictionPaymentMethods, 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 restepending(rejeu), une capture definitivement impossible ->expired(aucun fonds preleve). - Donnees :
PaymentMapping([TableName("nanoxi_Payment")], dontFormDefinitionId= lien vers la definition Forms,ProviderTransactionId= id transaction Saferpay) +IPaymentRepository/PaymentRepository(App.Repository, patternIScopeProvider), enregistre dansProgram.cs. Methode cle :TryFinalize(reference, newStatus, transactionId)(UPDATE atomique WHERE Status='pending', renvoietruesi la transition a eu lieu).GetPending()retourne toutes les demandespending(pour le job). Table creee pardata/01.Scratch/050.NanoxiTable/Payment.sql(colonneMethoddeprecated, rendue nullable ; colonneProviderTransactionId; colonnesNotificationEmail/EmailSubject/EmailIntro= snapshot des reglages email du workflow). Lesalter table ... if not existsdu 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).MarkAsReceivedn'agit que sur une demandepending(jamais d'ecrasement d'un statut finalpaid/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 viaIFormService+IRecordStorage.GetRecordByUniqueId(Reference, Form), avec lien vers la section Formulaires). SansFormDefinitionId(anciennes demandes), le panneau montre le recap sans les champs. - Permissions : le dashboard respecte la securite par formulaire d'Umbraco Forms.
GetAll/GetDetail/MarkAsReceivedfiltrent viaIFormsSecurity.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 sansFormDefinitionIdne sont rattachables a aucune permission et sont exclus du dashboard. Un utilisateur sans acces a aucun formulaire ne voit rien :GetAllrenvoiecanView=false(=FilterFormIdsForCurrentUsersur 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.PaymentConfirmationMailerdansApp.UmbracoForms, carApp.Corene reference pas Forms), appele parPaymentFinalizeruniquement 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 (drapeauIsInternalNotificationsurPaymentEmailModel) : 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 marqueViews/Partials/Forms/Emails/PaymentConfirmation.cshtml(modelePaymentEmailModel, rendu parIViewRender), calque sur le template email Nanoxi (logoforms-logo.png, titre, intro, recap paiement — statut/montant/demande/reference/date — puis detail des champs saisis, disclaimer). Envoi viaIWorkflowEmailService(meme sender queSendRazorEmailNanoxi), best-effort (n'interrompt jamais le retour de paiement). Enregistre dansProgram.cs. - Statuts :
pending->paid/failed/expired(passerelle/webhook/job) ;received= marque manuellement depuispending(suivi des versements differes, ex. futur QR-facture) ;failed/expired->pendingviaTryReopen(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 viaIViewRenderpuis envoie en SMTP.- SMTP : Mailgun (
smtp.eu.mailgun.org:587), configurable par domaine (IOptions<SmtpOptions>). - Le template email reference
Model.SiteDomainpour construire les URL absolues des assets.
Newsletter
Widget widgetNewsletter -> WidgetNewsletterController + INewsletterService (implementation NewsletterInfomaniakService). Validation via WidgetNewsletterViewModelValidator.
Contributors
No contributors
Changelog
No recent changes

