From bf03eed04dfaf2fbd76a73c161949c51e198d939 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 18 Feb 2026 20:39:00 -0700 Subject: [PATCH 01/33] Fix: skip password strength validation when meter element is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When password_strength_meter form setting is disabled ('0'), the #pass-strength-result element is not rendered. The checkout JS was still setting valid_password=false and trying to initialize the strength checker, which silently failed without the result element. This caused all form submissions to be blocked with "Password too weak". Also fix $className → $class_name in thank-you.php (PHPCS). Co-Authored-By: Claude Opus 4.6 --- assets/js/checkout.js | 7 +++++++ assets/js/checkout.min.js | 2 +- views/dashboard-widgets/thank-you.php | 8 ++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/assets/js/checkout.js b/assets/js/checkout.js index 7ecf40cf..3fdf9678 100644 --- a/assets/js/checkout.js +++ b/assets/js/checkout.js @@ -786,6 +786,13 @@ } // end if; + // If the strength meter element doesn't exist, skip validation + if (! jQuery('#pass-strength-result').length) { + + return; + + } // end if; + // Use the shared WU_PasswordStrength utility if (typeof window.WU_PasswordStrength !== 'undefined') { diff --git a/assets/js/checkout.min.js b/assets/js/checkout.min.js index e5600b6b..d786eb1d 100644 --- a/assets/js/checkout.min.js +++ b/assets/js/checkout.min.js @@ -1 +1 @@ -((r,s,n)=>{window.history.replaceState&&window.history.replaceState(null,null,wu_checkout.baseurl),s.addAction("wu_on_create_order","nextpress/wp-ultimo",function(e,t){void 0!==t.order.extra.template_id&&t.order.extra.template_id&&(e.template_id=t.order.extra.template_id)}),s.addAction("wu_checkout_loaded","nextpress/wp-ultimo",function(e){void 0!==window.wu_auto_submittable_field&&window.wu_auto_submittable_field&&e.$watch(window.wu_auto_submittable_field,function(){jQuery(this.$el).submit()},{deep:!0})}),s.addAction("wu_checkout_loaded","nextpress/wp-ultimo",function(t){wu_create_cookie("wu_template",""),wu_create_cookie("wu_selected_products",""),wu_listen_to_cookie_change("wu_template",function(e){e&&(t.template_id=e)})}),r(document).on("click",'[href|="#wu-checkout-add"]',function(e){e.preventDefault();var e=r(this),t=e.attr("href").split("#").pop().replace("wu-checkout-add-","");"undefined"!=typeof wu_checkout_form&&-1===wu_checkout_form.products.indexOf(t)&&(wu_checkout_form.add_product(t),e.html(wu_checkout.i18n.added_to_order))}),window.addEventListener("pageshow",function(e){e.persisted&&this.window.wu_checkout_form&&this.window.wu_checkout_form.unblock()}),r(document).ready(function(){var e;void 0!==window.Vue&&(Object.defineProperty(Vue.prototype,"$moment",{value:moment}),e={plan:(e=function(e){return isNaN(e)?e:parseInt(e,10)})(wu_checkout.plan),errors:[],order:wu_checkout.order,products:n.map(wu_checkout.products,e),template_id:wu_checkout.template_id,template_category:"",gateway:wu_checkout.gateway,request_billing_address:wu_checkout.request_billing_address,country:wu_checkout.country,state:"",city:"",site_title:wu_checkout.site_title||"",site_url:wu_checkout.site_url,site_domain:wu_checkout.site_domain,is_subdomain:wu_checkout.is_subdomain,discount_code:wu_checkout.discount_code,toggle_discount_code:0,payment_method:"",username:"",email_address:"",payment_id:wu_checkout.payment_id,membership_id:wu_checkout.membership_id,cart_type:"new",auto_renew:1,duration:wu_checkout.duration,duration_unit:wu_checkout.duration_unit,prevent_submission:!1,valid_password:!0,stored_templates:{},state_list:[],city_list:[],labels:{},show_login_prompt:!1,login_prompt_field:"",checking_user_exists:!1,logging_in:!1,login_error:"",inline_login_password:"",custom_amounts:wu_checkout.custom_amounts||{},pwyw_recurring:wu_checkout.pwyw_recurring||{}},s.applyFilters("wu_before_form_init",e),jQuery("#wu_form").length)&&(Vue.component("colorPicker",{props:["value"],template:'',mounted(){let o=this;r(this.$el).val(this.value).wpColorPicker({width:200,defaultColor:this.value,change(e,t){o.$emit("input",t.color.toString())}})},watch:{value(e){r(this.$el).wpColorPicker("color",e)}},destroyed(){r(this.$el).off().wpColorPicker("destroy")}}),window.wu_checkout_form=new Vue({el:"#wu_form",data:e,directives:{init:{bind(e,t,o){o.context[t.arg]=t.value}}},components:{dynamic:{functional:!0,template:"#dynamic",props:["template"],render(e,t){t=t.props.template;return e(t?{template:t}:"
nbsp;
")}}},computed:{hooks(){return wp.hooks},unique_products(){return n.uniq(this.products,!1,e=>parseInt(e,10))}},methods:{debounce(e){return n.debounce(e,200,!0)},open_url(e,t="_blank"){window.open(e,t)},get_template(e,t){void 0===t.id&&(t.id="default");var o=e+"/"+t.id;return void 0!==this.stored_templates[o]?this.stored_templates[o]:(o=this.hooks.applyFilters("wu_before_template_fetch",{duration:this.duration,duration_unit:this.duration_unit,products:this.products,...t},this),this.fetch_template(e,o),'
'+wu_checkout.i18n.loading+"
")},reset_templates(r){if(void 0===r)this.stored_templates={};else{let i={};n.forEach(this.stored_templates,function(e,t){var o=t.toString().substr(0,t.toString().indexOf("/"));!1===n.contains(r,o)&&(i[t]=e)}),this.stored_templates=i}},fetch_template(o,i){let r=this;void 0===i.id&&(i.id="default"),this.request("wu_render_field_template",{template:o,attributes:i},function(e){var t=o+"/"+i.id;e.success?Vue.set(r.stored_templates,t,e.data.html):Vue.set(r.stored_templates,t,"
"+e.data[0].message+"
")})},go_back(){this.block(),window.history.back()},set_prevent_submission(e){this.$nextTick(function(){this.prevent_submission=e})},remove_product(t,o){this.products=n.filter(this.products,function(e){return e!=t&&e!=o})},add_plan(e){this.plan&&this.remove_product(this.plan),this.plan=e,this.add_product(e)},add_product(e){this.products.push(e)},has_product(e){return-1',overlayCSS:{backgroundColor:e||"#ffffff",opacity:.6},css:{padding:0,margin:0,width:"50%",fontSize:"14px !important",top:"40%",left:"35%",textAlign:"center",color:"#000",border:"none",backgroundColor:"none",cursor:"wait"}})},unblock(){jQuery(this.$el).wu_unblock()},request(e,t,o,i){var r="wu_validate_form"===e||"wu_create_order"===e||"wu_render_field_template"===e||"wu_check_user_exists"===e||"wu_inline_login"===e?wu_checkout.late_ajaxurl:wu_checkout.ajaxurl;jQuery.ajax({method:"POST",url:r+"&action="+e,data:t,success:o,error:i})},init_password_strength(){let t=this;var e=jQuery("#field-password");e.length&&void 0!==window.WU_PasswordStrength&&(this.valid_password=!1,this.password_strength_checker=new window.WU_PasswordStrength({pass1:e,result:jQuery("#pass-strength-result"),onValidityChange(e){t.valid_password=e}}))},check_user_exists_debounced:n.debounce(function(e,t){this.check_user_exists(e,t)},500),check_user_exists(o,e){if(!this.show_login_prompt||"email"!==this.login_prompt_field||"email"===o)if(!e||e.length<3)this.login_prompt_field===o&&(this.show_login_prompt=!1,this.remove_field_error("email"===o?"email_address":"username"));else{this.checking_user_exists=!0,this.login_error="";let t=this;this.request("wu_check_user_exists",{field_type:o,value:e,_wpnonce:jQuery('[name="_wpnonce"]').val()},function(e){t.checking_user_exists=!1,e.success&&e.data.exists?(t.show_login_prompt=!0,t.login_prompt_field=o,t.add_field_error("email"===o?"email_address":"username",wu_checkout.i18n.email_exists)):t.login_prompt_field===o&&(t.show_login_prompt=!1,t.remove_field_error("email"===o?"email_address":"username"))},function(e){t.checking_user_exists=!1,t.login_prompt_field===o&&(t.show_login_prompt=!1,t.remove_field_error("email"===o?"email_address":"username"))})}},handle_inline_login(e){if(e&&(e.preventDefault(),e.stopPropagation(),e.stopImmediatePropagation()),this.inline_login_password){this.logging_in=!0,this.login_error="";let t=this;e="email"===this.login_prompt_field?this.email_address||"":this.username||"";this.request("wu_inline_login",{username_or_email:e,password:this.inline_login_password,_wpnonce:jQuery('[name="_wpnonce"]').val()},function(e){t.logging_in=!1,e.success&&window.location.reload()},function(e){t.logging_in=!1,e.responseJSON&&e.responseJSON.data&&e.responseJSON.data.message?t.login_error=e.responseJSON.data.message:t.login_error=wu_checkout.i18n.login_failed||"Login failed. Please try again."})}else this.login_error=wu_checkout.i18n.password_required||"Password is required";return!1},add_field_error(e,t){this.remove_field_error(e),this.errors.push({code:e,message:t})},remove_field_error(t){this.errors=this.errors.filter(function(e){return e.code!==t})},dismiss_login_prompt(){this.show_login_prompt=!1,this.inline_login_password="",this.login_error=""},setup_inline_login_handlers(){let u=this;["email","username"].forEach(function(s){var e=document.getElementById("wu-inline-login-prompt-"+s);if(e&&!e.dataset.wuHandlersAttached){e.dataset.wuHandlersAttached="1";let i=document.getElementById("wu-inline-login-password-"+s),r=document.getElementById("wu-inline-login-submit-"+s);if(i&&r){let o=document.getElementById("wu-login-error-"+s);function n(e){o.textContent=e,o.classList.remove("wu-hidden")}function a(e){r.disabled=!1,r.textContent=wu_checkout.i18n.sign_in||"Sign in",e.data&&e.data.message?n(e.data.message):n(wu_checkout.i18n.login_failed||"Login failed. Please try again.")}function t(e){e.preventDefault(),e.stopPropagation(),e.stopImmediatePropagation();var t,e=i.value;return e?(r.disabled=!0,r.innerHTML=''+(wu_checkout.i18n.logging_in||"Logging in..."),o.classList.add("wu-hidden"),t="email"===s?u.email_address:u.username,jQuery.ajax({method:"POST",url:wu_checkout.late_ajaxurl+"&action=wu_inline_login",data:{username_or_email:t,password:e,_wpnonce:jQuery('[name="_wpnonce"]').val()},success(e){e.success?window.location.reload():a(e)},error:a})):n(wu_checkout.i18n.password_required||"Password is required"),!1}e.addEventListener("click",function(e){e.stopPropagation()}),e.addEventListener("keydown",function(e){e.stopPropagation()}),e.addEventListener("keyup",function(e){e.stopPropagation()}),r.addEventListener("click",t),i.addEventListener("keydown",function(e){"Enter"===e.key&&t(e)})}}})}},updated(){this.$nextTick(function(){s.doAction("wu_on_form_updated",this),wu_initialize_tooltip(),this.setup_inline_login_handlers(),!this.password_strength_checker&&jQuery("#field-password").length&&this.init_password_strength()})},mounted(){let i=this;jQuery(this.$el).on("click",function(e){r(this).data("submited_via",r(e.target))}),jQuery(this.$el).on("submit",async function(e){e.preventDefault();var t,e=jQuery(this).data("submited_via");e&&((t=jQuery("")).attr("type","hidden"),t.attr("name",e.attr("name")),t.attr("value",e.val()),jQuery(this).append(t)),i.block();try{var o=[];await Promise.all(s.applyFilters("wu_before_form_submitted",o,i,i.gateway))}catch(e){return i.errors=[],i.errors.push({code:"before-submit-error",message:e.message}),i.unblock(),void i.handle_errors(e)}i.validate_form(),s.doAction("wu_on_form_submitted",i,i.gateway)}),this.create_order(),s.doAction("wu_checkout_loaded",this),s.doAction("wu_on_change_gateway",this.gateway,this.gateway),this.init_password_strength(),wu_initialize_tooltip()},watch:{email_address:n.debounce(function(e){this.check_user_exists("email",e)},500),products(e,t){this.on_change_product(e,t)},toggle_discount_code(e){e||(this.discount_code="")},discount_code(e,t){this.on_change_discount_code(e,t)},gateway(e,t){this.on_change_gateway(e,t)},country(e,t){this.state="",this.on_change_country(e,t)},state(e,t){this.city="",this.on_change_state(e,t)},city(e,t){this.on_change_city(e,t)},duration(e,t){this.on_change_duration(e,t)},duration_unit(e,t){this.on_change_duration_unit(e,t)}}}))})})(jQuery,wp.hooks,_); \ No newline at end of file +((r,s,n)=>{window.history.replaceState&&window.history.replaceState(null,null,wu_checkout.baseurl),s.addAction("wu_on_create_order","nextpress/wp-ultimo",function(e,t){void 0!==t.order.extra.template_id&&t.order.extra.template_id&&(e.template_id=t.order.extra.template_id)}),s.addAction("wu_checkout_loaded","nextpress/wp-ultimo",function(e){void 0!==window.wu_auto_submittable_field&&window.wu_auto_submittable_field&&e.$watch(window.wu_auto_submittable_field,function(){jQuery(this.$el).submit()},{deep:!0})}),s.addAction("wu_checkout_loaded","nextpress/wp-ultimo",function(t){wu_create_cookie("wu_template",""),wu_create_cookie("wu_selected_products",""),wu_listen_to_cookie_change("wu_template",function(e){e&&(t.template_id=e)})}),r(document).on("click",'[href|="#wu-checkout-add"]',function(e){e.preventDefault();var e=r(this),t=e.attr("href").split("#").pop().replace("wu-checkout-add-","");"undefined"!=typeof wu_checkout_form&&-1===wu_checkout_form.products.indexOf(t)&&(wu_checkout_form.add_product(t),e.html(wu_checkout.i18n.added_to_order))}),window.addEventListener("pageshow",function(e){e.persisted&&this.window.wu_checkout_form&&this.window.wu_checkout_form.unblock()}),r(document).ready(function(){var e;void 0!==window.Vue&&(Object.defineProperty(Vue.prototype,"$moment",{value:moment}),e={plan:(e=function(e){return isNaN(e)?e:parseInt(e,10)})(wu_checkout.plan),errors:[],order:wu_checkout.order,products:n.map(wu_checkout.products,e),template_id:wu_checkout.template_id,template_category:"",gateway:wu_checkout.gateway,request_billing_address:wu_checkout.request_billing_address,country:wu_checkout.country,state:"",city:"",site_title:wu_checkout.site_title||"",site_url:wu_checkout.site_url,site_domain:wu_checkout.site_domain,is_subdomain:wu_checkout.is_subdomain,discount_code:wu_checkout.discount_code,toggle_discount_code:0,payment_method:"",username:"",email_address:"",payment_id:wu_checkout.payment_id,membership_id:wu_checkout.membership_id,cart_type:"new",auto_renew:1,duration:wu_checkout.duration,duration_unit:wu_checkout.duration_unit,prevent_submission:!1,valid_password:!0,stored_templates:{},state_list:[],city_list:[],labels:{},show_login_prompt:!1,login_prompt_field:"",checking_user_exists:!1,logging_in:!1,login_error:"",inline_login_password:"",custom_amounts:wu_checkout.custom_amounts||{},pwyw_recurring:wu_checkout.pwyw_recurring||{}},s.applyFilters("wu_before_form_init",e),jQuery("#wu_form").length)&&(Vue.component("colorPicker",{props:["value"],template:'',mounted(){let o=this;r(this.$el).val(this.value).wpColorPicker({width:200,defaultColor:this.value,change(e,t){o.$emit("input",t.color.toString())}})},watch:{value(e){r(this.$el).wpColorPicker("color",e)}},destroyed(){r(this.$el).off().wpColorPicker("destroy")}}),window.wu_checkout_form=new Vue({el:"#wu_form",data:e,directives:{init:{bind(e,t,o){o.context[t.arg]=t.value}}},components:{dynamic:{functional:!0,template:"#dynamic",props:["template"],render(e,t){t=t.props.template;return e(t?{template:t}:"
nbsp;
")}}},computed:{hooks(){return wp.hooks},unique_products(){return n.uniq(this.products,!1,e=>parseInt(e,10))}},methods:{debounce(e){return n.debounce(e,200,!0)},open_url(e,t="_blank"){window.open(e,t)},get_template(e,t){void 0===t.id&&(t.id="default");var o=e+"/"+t.id;return void 0!==this.stored_templates[o]?this.stored_templates[o]:(o=this.hooks.applyFilters("wu_before_template_fetch",{duration:this.duration,duration_unit:this.duration_unit,products:this.products,...t},this),this.fetch_template(e,o),'
'+wu_checkout.i18n.loading+"
")},reset_templates(r){if(void 0===r)this.stored_templates={};else{let i={};n.forEach(this.stored_templates,function(e,t){var o=t.toString().substr(0,t.toString().indexOf("/"));!1===n.contains(r,o)&&(i[t]=e)}),this.stored_templates=i}},fetch_template(o,i){let r=this;void 0===i.id&&(i.id="default"),this.request("wu_render_field_template",{template:o,attributes:i},function(e){var t=o+"/"+i.id;e.success?Vue.set(r.stored_templates,t,e.data.html):Vue.set(r.stored_templates,t,"
"+e.data[0].message+"
")})},go_back(){this.block(),window.history.back()},set_prevent_submission(e){this.$nextTick(function(){this.prevent_submission=e})},remove_product(t,o){this.products=n.filter(this.products,function(e){return e!=t&&e!=o})},add_plan(e){this.plan&&this.remove_product(this.plan),this.plan=e,this.add_product(e)},add_product(e){this.products.push(e)},has_product(e){return-1',overlayCSS:{backgroundColor:e||"#ffffff",opacity:.6},css:{padding:0,margin:0,width:"50%",fontSize:"14px !important",top:"40%",left:"35%",textAlign:"center",color:"#000",border:"none",backgroundColor:"none",cursor:"wait"}})},unblock(){jQuery(this.$el).wu_unblock()},request(e,t,o,i){var r="wu_validate_form"===e||"wu_create_order"===e||"wu_render_field_template"===e||"wu_check_user_exists"===e||"wu_inline_login"===e?wu_checkout.late_ajaxurl:wu_checkout.ajaxurl;jQuery.ajax({method:"POST",url:r+"&action="+e,data:t,success:o,error:i})},init_password_strength(){let t=this;var e=jQuery("#field-password");e.length&&jQuery("#pass-strength-result").length&&void 0!==window.WU_PasswordStrength&&(this.valid_password=!1,this.password_strength_checker=new window.WU_PasswordStrength({pass1:e,result:jQuery("#pass-strength-result"),onValidityChange(e){t.valid_password=e}}))},check_user_exists_debounced:n.debounce(function(e,t){this.check_user_exists(e,t)},500),check_user_exists(o,e){if(!this.show_login_prompt||"email"!==this.login_prompt_field||"email"===o)if(!e||e.length<3)this.login_prompt_field===o&&(this.show_login_prompt=!1,this.remove_field_error("email"===o?"email_address":"username"));else{this.checking_user_exists=!0,this.login_error="";let t=this;this.request("wu_check_user_exists",{field_type:o,value:e,_wpnonce:jQuery('[name="_wpnonce"]').val()},function(e){t.checking_user_exists=!1,e.success&&e.data.exists?(t.show_login_prompt=!0,t.login_prompt_field=o,t.add_field_error("email"===o?"email_address":"username",wu_checkout.i18n.email_exists)):t.login_prompt_field===o&&(t.show_login_prompt=!1,t.remove_field_error("email"===o?"email_address":"username"))},function(e){t.checking_user_exists=!1,t.login_prompt_field===o&&(t.show_login_prompt=!1,t.remove_field_error("email"===o?"email_address":"username"))})}},handle_inline_login(e){if(e&&(e.preventDefault(),e.stopPropagation(),e.stopImmediatePropagation()),this.inline_login_password){this.logging_in=!0,this.login_error="";let t=this;e="email"===this.login_prompt_field?this.email_address||"":this.username||"";this.request("wu_inline_login",{username_or_email:e,password:this.inline_login_password,_wpnonce:jQuery('[name="_wpnonce"]').val()},function(e){t.logging_in=!1,e.success&&window.location.reload()},function(e){t.logging_in=!1,e.responseJSON&&e.responseJSON.data&&e.responseJSON.data.message?t.login_error=e.responseJSON.data.message:t.login_error=wu_checkout.i18n.login_failed||"Login failed. Please try again."})}else this.login_error=wu_checkout.i18n.password_required||"Password is required";return!1},add_field_error(e,t){this.remove_field_error(e),this.errors.push({code:e,message:t})},remove_field_error(t){this.errors=this.errors.filter(function(e){return e.code!==t})},dismiss_login_prompt(){this.show_login_prompt=!1,this.inline_login_password="",this.login_error=""},setup_inline_login_handlers(){let u=this;["email","username"].forEach(function(s){var e=document.getElementById("wu-inline-login-prompt-"+s);if(e&&!e.dataset.wuHandlersAttached){e.dataset.wuHandlersAttached="1";let i=document.getElementById("wu-inline-login-password-"+s),r=document.getElementById("wu-inline-login-submit-"+s);if(i&&r){let o=document.getElementById("wu-login-error-"+s);function n(e){o.textContent=e,o.classList.remove("wu-hidden")}function a(e){r.disabled=!1,r.textContent=wu_checkout.i18n.sign_in||"Sign in",e.data&&e.data.message?n(e.data.message):n(wu_checkout.i18n.login_failed||"Login failed. Please try again.")}function t(e){e.preventDefault(),e.stopPropagation(),e.stopImmediatePropagation();var t,e=i.value;return e?(r.disabled=!0,r.innerHTML=''+(wu_checkout.i18n.logging_in||"Logging in..."),o.classList.add("wu-hidden"),t="email"===s?u.email_address:u.username,jQuery.ajax({method:"POST",url:wu_checkout.late_ajaxurl+"&action=wu_inline_login",data:{username_or_email:t,password:e,_wpnonce:jQuery('[name="_wpnonce"]').val()},success(e){e.success?window.location.reload():a(e)},error:a})):n(wu_checkout.i18n.password_required||"Password is required"),!1}e.addEventListener("click",function(e){e.stopPropagation()}),e.addEventListener("keydown",function(e){e.stopPropagation()}),e.addEventListener("keyup",function(e){e.stopPropagation()}),r.addEventListener("click",t),i.addEventListener("keydown",function(e){"Enter"===e.key&&t(e)})}}})}},updated(){this.$nextTick(function(){s.doAction("wu_on_form_updated",this),wu_initialize_tooltip(),this.setup_inline_login_handlers(),!this.password_strength_checker&&jQuery("#field-password").length&&this.init_password_strength()})},mounted(){let i=this;jQuery(this.$el).on("click",function(e){r(this).data("submited_via",r(e.target))}),jQuery(this.$el).on("submit",async function(e){e.preventDefault();var t,e=jQuery(this).data("submited_via");e&&((t=jQuery("")).attr("type","hidden"),t.attr("name",e.attr("name")),t.attr("value",e.val()),jQuery(this).append(t)),i.block();try{var o=[];await Promise.all(s.applyFilters("wu_before_form_submitted",o,i,i.gateway))}catch(e){return i.errors=[],i.errors.push({code:"before-submit-error",message:e.message}),i.unblock(),void i.handle_errors(e)}i.validate_form(),s.doAction("wu_on_form_submitted",i,i.gateway)}),this.create_order(),s.doAction("wu_checkout_loaded",this),s.doAction("wu_on_change_gateway",this.gateway,this.gateway),this.init_password_strength(),wu_initialize_tooltip()},watch:{email_address:n.debounce(function(e){this.check_user_exists("email",e)},500),products(e,t){this.on_change_product(e,t)},toggle_discount_code(e){e||(this.discount_code="")},discount_code(e,t){this.on_change_discount_code(e,t)},gateway(e,t){this.on_change_gateway(e,t)},country(e,t){this.state="",this.on_change_country(e,t)},state(e,t){this.city="",this.on_change_state(e,t)},city(e,t){this.on_change_city(e,t)},duration(e,t){this.on_change_duration(e,t)},duration_unit(e,t){this.on_change_duration_unit(e,t)}}}))})})(jQuery,wp.hooks,_); \ No newline at end of file diff --git a/views/dashboard-widgets/thank-you.php b/views/dashboard-widgets/thank-you.php index a7965d94..6f8954f1 100644 --- a/views/dashboard-widgets/thank-you.php +++ b/views/dashboard-widgets/thank-you.php @@ -6,7 +6,7 @@ */ defined('ABSPATH') || exit; ?> -
+
@@ -250,11 +250,11 @@
-
+
Thumbnail of Site
From 185cfce8522cc1fa411107e42761922fd1e61ca9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 19 Feb 2026 14:15:08 -0700 Subject: [PATCH 02/33] Use correct return type for pages --- .../class-checkout-form-edit-admin-page.php | 4 +- .../class-customer-edit-admin-page.php | 8 +- .../class-discount-code-edit-admin-page.php | 6 +- .../class-domain-edit-admin-page.php | 6 +- .../class-email-edit-admin-page.php | 6 +- .../class-payment-edit-admin-page.php | 6 +- .../class-product-edit-admin-page.php | 6 +- .../interface-domain-selling-capability.php | 2 + .../interface-node-management-capability.php | 139 ++++++++++++++++++ 9 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 inc/integrations/capabilities/interface-node-management-capability.php diff --git a/inc/admin-pages/class-checkout-form-edit-admin-page.php b/inc/admin-pages/class-checkout-form-edit-admin-page.php index 6432a1fd..0b670116 100644 --- a/inc/admin-pages/class-checkout-form-edit-admin-page.php +++ b/inc/admin-pages/class-checkout-form-edit-admin-page.php @@ -1530,7 +1530,7 @@ public function handle_save() { */ ob_start(); - parent::handle_save(); + $result = parent::handle_save(); $object = $this->get_object(); @@ -1547,7 +1547,7 @@ public function handle_save() { } wp_ob_end_flush_all(); - return true; + return $result; } /** diff --git a/inc/admin-pages/class-customer-edit-admin-page.php b/inc/admin-pages/class-customer-edit-admin-page.php index 44b1a9ff..e17f7765 100644 --- a/inc/admin-pages/class-customer-edit-admin-page.php +++ b/inc/admin-pages/class-customer-edit-admin-page.php @@ -1159,10 +1159,10 @@ public function has_title(): bool { /** * Should implement the processes necessary to save the changes made to the object. * - * @return void + * @return bool * @since 2.0.0 */ - public function handle_save(): void { + public function handle_save(): bool { // Nonce handled in calling method. // phpcs:disable WordPress.Security.NonceVerification @@ -1199,7 +1199,7 @@ public function handle_save(): void { WP_Ultimo()->notices->add($errors, 'error', 'network-admin'); - return; + return false; } $object->set_billing_address($billing_address); @@ -1245,7 +1245,7 @@ public function handle_save(): void { unset($_POST['new_meta_fields']); // phpcs:enable - parent::handle_save(); + return parent::handle_save(); } /** diff --git a/inc/admin-pages/class-discount-code-edit-admin-page.php b/inc/admin-pages/class-discount-code-edit-admin-page.php index 24ef8711..00b169cb 100644 --- a/inc/admin-pages/class-discount-code-edit-admin-page.php +++ b/inc/admin-pages/class-discount-code-edit-admin-page.php @@ -844,9 +844,9 @@ public function has_title(): bool { * Should implement the processes necessary to save the changes made to the object. * * @since 2.0.0 - * @return void + * @return bool */ - public function handle_save(): void { + public function handle_save(): bool { /* * Set the recurring value to zero if the toggle is disabled. */ @@ -897,6 +897,6 @@ public function handle_save(): void { $_POST['code'] = trim((string) wu_request('code')); - parent::handle_save(); + return parent::handle_save(); } } diff --git a/inc/admin-pages/class-domain-edit-admin-page.php b/inc/admin-pages/class-domain-edit-admin-page.php index b71d2b7e..67496d9e 100644 --- a/inc/admin-pages/class-domain-edit-admin-page.php +++ b/inc/admin-pages/class-domain-edit-admin-page.php @@ -572,9 +572,9 @@ public function has_title(): bool { * Should implement the processes necessary to save the changes made to the object. * * @since 2.0.0 - * @return void + * @return bool */ - public function handle_save(): void { + public function handle_save(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verification happens in parent::handle_save() if ( ! wu_request('primary_domain')) { @@ -593,6 +593,6 @@ public function handle_save(): void { wu_enqueue_async_action('wu_async_process_domain_stage', ['domain_id' => $this->get_object()->get_id()], 'domain'); - parent::handle_save(); + return parent::handle_save(); } } diff --git a/inc/admin-pages/class-email-edit-admin-page.php b/inc/admin-pages/class-email-edit-admin-page.php index 38d21640..8caa82c2 100644 --- a/inc/admin-pages/class-email-edit-admin-page.php +++ b/inc/admin-pages/class-email-edit-admin-page.php @@ -475,9 +475,9 @@ public function query_filter($args) { * Handles the toggles. * * @since 2.0.0 - * @return void + * @return bool */ - public function handle_save(): void { + public function handle_save(): bool { $_POST['schedule'] = wu_request('schedule'); @@ -485,7 +485,7 @@ public function handle_save(): void { $_POST['custom_sender'] = wu_request('custom_sender'); - parent::handle_save(); + return parent::handle_save(); } /** diff --git a/inc/admin-pages/class-payment-edit-admin-page.php b/inc/admin-pages/class-payment-edit-admin-page.php index 8812f78e..c6ae6635 100644 --- a/inc/admin-pages/class-payment-edit-admin-page.php +++ b/inc/admin-pages/class-payment-edit-admin-page.php @@ -1310,9 +1310,9 @@ public function has_title(): bool { * * @todo: This can not be handled here. * @since 2.0.0 - * @return void + * @return bool */ - public function handle_save(): void { + public function handle_save(): bool { $this->get_object()->recalculate_totals()->save(); @@ -1328,6 +1328,6 @@ public function handle_save(): void { } } - parent::handle_save(); + return parent::handle_save(); } } diff --git a/inc/admin-pages/class-product-edit-admin-page.php b/inc/admin-pages/class-product-edit-admin-page.php index 9a8e0618..b19438f8 100644 --- a/inc/admin-pages/class-product-edit-admin-page.php +++ b/inc/admin-pages/class-product-edit-admin-page.php @@ -1159,9 +1159,9 @@ public function has_title(): bool { * Should implement the processes necessary to save the changes made to the object. * * @since 2.0.0 - * @return void + * @return bool */ - public function handle_save(): void { + public function handle_save(): bool { /* * Set the recurring value to zero if the toggle is disabled. */ @@ -1212,6 +1212,6 @@ public function handle_save(): void { $_POST['taxable'] = 0; } - parent::handle_save(); + return parent::handle_save(); } } diff --git a/inc/integrations/capabilities/interface-domain-selling-capability.php b/inc/integrations/capabilities/interface-domain-selling-capability.php index 88f9fca5..f30320a1 100644 --- a/inc/integrations/capabilities/interface-domain-selling-capability.php +++ b/inc/integrations/capabilities/interface-domain-selling-capability.php @@ -19,6 +19,8 @@ */ interface Domain_Selling_Capability { + public const ID = 'domain-selling'; + /** * Search for available domains. * diff --git a/inc/integrations/capabilities/interface-node-management-capability.php b/inc/integrations/capabilities/interface-node-management-capability.php new file mode 100644 index 00000000..119e8269 --- /dev/null +++ b/inc/integrations/capabilities/interface-node-management-capability.php @@ -0,0 +1,139 @@ +, + * message: string + * } + */ + public function detect_node(): array; + + /** + * Register/create a Node.js application. + * + * @since 2.5.0 + * + * @param array $config { + * Application configuration. + * + * @type string $app_root Absolute path to the application root directory. + * @type string $app_url URL path where the application will be accessible. + * @type string $startup Startup file (e.g., 'app.js', 'server.js'). + * @type string $node_version Node.js version to use (e.g., '22'). + * @type int $port Port number for the application (if applicable). + * } + * @return array{success: bool, message: string, app_id?: string} + */ + public function create_app(array $config): array; + + /** + * Start a Node.js application. + * + * @since 2.5.0 + * + * @param string $app_id Application identifier (path, name, or ID). + * @return array{success: bool, message: string} + */ + public function start_app(string $app_id): array; + + /** + * Stop a running Node.js application. + * + * @since 2.5.0 + * + * @param string $app_id Application identifier. + * @return array{success: bool, message: string} + */ + public function stop_app(string $app_id): array; + + /** + * Restart a Node.js application. + * + * @since 2.5.0 + * + * @param string $app_id Application identifier. + * @return array{success: bool, message: string} + */ + public function restart_app(string $app_id): array; + + /** + * Remove/unregister a Node.js application. + * + * @since 2.5.0 + * + * @param string $app_id Application identifier. + * @return array{success: bool, message: string} + */ + public function destroy_app(string $app_id): array; + + /** + * Get the status of a Node.js application. + * + * @since 2.5.0 + * + * @param string $app_id Application identifier. + * @return array{ + * success: bool, + * running: bool, + * message: string, + * pid?: int, + * uptime?: string, + * memory?: string + * } + */ + public function get_app_status(string $app_id): array; + + /** + * List all registered Node.js applications. + * + * @since 2.5.0 + * @return array{ + * success: bool, + * apps: array, + * message: string + * } + */ + public function list_apps(): array; + + /** + * Install npm dependencies for a Node.js application. + * + * @since 2.5.0 + * + * @param string $app_id Application identifier. + * @return array{success: bool, message: string, output?: string} + */ + public function install_deps(string $app_id): array; +} From f74a02f816146d7e0ca90843549b138380bd6a28 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 23 Feb 2026 21:22:02 -0700 Subject: [PATCH 03/33] Fix some wc url with %2F being striped --- inc/sso/auth-functions.php | 2 +- inc/sso/class-sso.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/sso/auth-functions.php b/inc/sso/auth-functions.php index 4cf3c715..94d23bf7 100644 --- a/inc/sso/auth-functions.php +++ b/inc/sso/auth-functions.php @@ -189,7 +189,7 @@ function auth_redirect() { */ $secure = apply_filters('secure_auth_redirect', $secure); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound - $request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '')); + $request_uri = wp_unslash($_SERVER['REQUEST_URI'] ?? ''); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $host = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'] ?? '')); // If https is required and request is http, redirect. if ( $secure && ! is_ssl() && str_contains($request_uri, 'wp-admin') ) { diff --git a/inc/sso/class-sso.php b/inc/sso/class-sso.php index c0a4c9b3..333c4ea6 100644 --- a/inc/sso/class-sso.php +++ b/inc/sso/class-sso.php @@ -385,7 +385,7 @@ public function handle_auth_redirect() { $_SERVER['REQUEST_URI'] = str_replace( 'https://a.com/', '', - remove_query_arg('sso', 'https://a.com/' . sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''))) + remove_query_arg('sso', 'https://a.com/' . wp_unslash($_SERVER['REQUEST_URI'] ?? '')) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized ); return null; } From 455abdf2ba56f1d8f438f600b48fa86497d8def0 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 23 Feb 2026 21:22:59 -0700 Subject: [PATCH 04/33] Show errors when they occur --- assets/js/integration-test.js | 10 +++++++--- assets/js/integration-test.min.js | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/assets/js/integration-test.js b/assets/js/integration-test.js index 1a476d5a..68711d17 100644 --- a/assets/js/integration-test.js +++ b/assets/js/integration-test.js @@ -10,7 +10,7 @@ mounted: function() { var that = this; this.loading = true; - + setTimeout(() => { $.ajax({ url: ajaxurl, @@ -20,14 +20,18 @@ integration: wu_integration_test_data.integration_id, }, success: function(response) { - console.log(response); that.loading = false; that.success = response.success; that.results = response.data; + }, + error: function() { + that.loading = false; + that.success = false; + that.results = wu_integration_test_data.error_message || 'Connection test failed. Please try again.'; } }); }, 1000); }, }); }); -})(jQuery); \ No newline at end of file +})(jQuery); diff --git a/assets/js/integration-test.min.js b/assets/js/integration-test.min.js index a6bcd58b..b95556b5 100644 --- a/assets/js/integration-test.min.js +++ b/assets/js/integration-test.min.js @@ -1 +1 @@ -(t=>{t(document).ready(function(){new Vue({el:"#integration-test",data:{success:!1,loading:!1,results:wu_integration_test_data.waiting_message},mounted:function(){var e=this;this.loading=!0,setTimeout(()=>{t.ajax({url:ajaxurl,method:"POST",data:{action:"wu_test_hosting_integration",integration:wu_integration_test_data.integration_id},success:function(t){console.log(t),e.loading=!1,e.success=t.success,e.results=t.data}})},1e3)}})})})(jQuery); \ No newline at end of file +(t=>{t(document).ready(function(){new Vue({el:"#integration-test",data:{success:!1,loading:!1,results:wu_integration_test_data.waiting_message},mounted:function(){var e=this;this.loading=!0,setTimeout(()=>{t.ajax({url:ajaxurl,method:"POST",data:{action:"wu_test_hosting_integration",integration:wu_integration_test_data.integration_id},success:function(t){e.loading=!1,e.success=t.success,e.results=t.data},error:function(){e.loading=!1,e.success=!1,e.results=wu_integration_test_data.error_message||"Connection test failed. Please try again."}})},1e3)}})})})(jQuery); \ No newline at end of file From 2a67991c323ea4b96ded55feb23aa408cd3d4d7b Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 23 Feb 2026 21:39:42 -0700 Subject: [PATCH 05/33] Add fill button to checkout form --- inc/class-wp-ultimo.php | 5 ++- inc/debug/class-debug.php | 77 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 13b618f3..4dadf897 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -459,7 +459,9 @@ protected function load_extra_components(): void { /* * Loads the debugger tools */ - WP_Ultimo\Debug\Debug::get_instance(); + if (defined('WP_ULTIMO_DEBUG') && WP_ULTIMO_DEBUG) { + WP_Ultimo\Debug\Debug::get_instance(); + } /* * Loads the Jumper UI @@ -796,6 +798,7 @@ protected function load_admin_only_pages(): void { new WP_Ultimo\Admin_Pages\Customer_Panel\Add_New_Site_Admin_Page(); new WP_Ultimo\Admin_Pages\Customer_Panel\Checkout_Admin_Page(); new WP_Ultimo\Admin_Pages\Customer_Panel\Template_Switching_Admin_Page(); + new WP_Ultimo\Admin_Pages\Customer_Panel\Addon_Catalog_Admin_Page(); new WP_Ultimo\Tax\Dashboard_Taxes_Tab(); diff --git a/inc/debug/class-debug.php b/inc/debug/class-debug.php index 0a20d443..edccc972 100644 --- a/inc/debug/class-debug.php +++ b/inc/debug/class-debug.php @@ -57,6 +57,8 @@ public function init(): void { public function add_additional_hooks(): void { add_action('wu_header_left', [$this, 'add_debug_links']); + + add_action('wp_footer', [$this, 'render_checkout_autofill_button']); } /** @@ -945,6 +947,81 @@ private function reset_events($ids = []): void { $this->reset_table($events_table, $ids); } + /** + * Renders a button at the top of the checkout form that fills fields with random data. + * + * @since 2.4.11 + * @return void + */ + public function render_checkout_autofill_button(): void { + + ?> + + + Date: Tue, 24 Feb 2026 13:10:37 -0700 Subject: [PATCH 06/33] remove obsolute doc --- .wiki/how-can-i-cancel-my-subscription.md | 29 ----------------------- 1 file changed, 29 deletions(-) delete mode 100644 .wiki/how-can-i-cancel-my-subscription.md diff --git a/.wiki/how-can-i-cancel-my-subscription.md b/.wiki/how-can-i-cancel-my-subscription.md deleted file mode 100644 index d6637263..00000000 --- a/.wiki/how-can-i-cancel-my-subscription.md +++ /dev/null @@ -1,29 +0,0 @@ -# How can I cancel my subscription? - -If you have a Ultimate Multisite license, you can cancel its renewal at any time you want. Just follow the steps below: - -Access your Freemius account page through the link you received in your e-mail after buying Ultimate Multisite: - -![](https://wp-ultimo-space.fra1.cdn.digitaloceanspaces.com/hs-602125811f25b9041bebc762-kFXPifHWo-Account.png) - -Use the email you provided during the purchase and your password. - -![](https://wp-ultimo-space.fra1.cdn.digitaloceanspaces.com/hs-602125811f25b9041bebc762-sz4pZH3oz-Login.png) - -This is your Account Page inicial screen: - -![](https://support.delta.nextpress.co/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ3NnIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--157d6544a77090f7ae56e4a26e04a9adddb53f4c/Freemius%20account.png) - -To cancel your subscription, on the menu on the left, go to _Renewing & Billing._ - -![](https://support.delta.nextpress.co/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ3dnIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c00d8fe9945900093d923bb60ac1560f6e5edd94/Freemius%20account%202.png) - -Click the arrow on the right to open a side window. Then, you should select the option that will cancel the **auto-renew**. - -The system will show you a confirmation message. - -If you are sure you want to cancel your subscription, just click the _**Cancel Renewals**_ button. After this action, you will be asked to answer a quick survey. - -Done! Your subscription won't be automatically renewed. - -**You will have a valid key until your subscription expires**. In case you want to reactivate your subscription, you will need to do it manually. From 281133f5f669d34fa40b333c7c770546f95476cf Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 24 Feb 2026 13:18:27 -0700 Subject: [PATCH 07/33] Improve integration wizard --- ...-hosting-integration-wizard-admin-page.php | 30 +++++++------------ .../class-cloudflare-integration.php | 4 +++ .../cloudways/class-cloudways-integration.php | 4 +++ .../cpanel/class-cpanel-integration.php | 6 ++++ .../hestia/class-hestia-integration.php | 3 ++ .../rocket/class-rocket-integration.php | 3 ++ .../runcloud/class-runcloud-integration.php | 4 +++ .../class-serverpilot-integration.php | 4 +++ .../host-integrations/configuration.php | 19 +++++++----- 9 files changed, 51 insertions(+), 26 deletions(-) diff --git a/inc/admin-pages/class-hosting-integration-wizard-admin-page.php b/inc/admin-pages/class-hosting-integration-wizard-admin-page.php index 8d0349bb..80d39eb5 100644 --- a/inc/admin-pages/class-hosting-integration-wizard-admin-page.php +++ b/inc/admin-pages/class-hosting-integration-wizard-admin-page.php @@ -388,24 +388,6 @@ public function section_test(): void { wp_enqueue_script('wu-vue'); - wu_get_template( - 'wizards/host-integrations/test', - [ - 'screen' => get_current_screen(), - 'page' => $this, - 'integration' => $this->integration, - ] - ); - } - - /** - * Register the script for the test page. - * - * @return void - */ - public function register_scripts() { - parent::register_scripts(); - wp_enqueue_script( 'wu-integration-test', wu_get_asset('integration-test.js', 'js'), @@ -420,9 +402,19 @@ public function register_scripts() { 'wu-integration-test', 'var wu_integration_test_data = { integration_id: "' . esc_js($this->integration->get_id()) . '", - waiting_message: "' . esc_js(__('Waiting for results...', 'ultimate-multisite')) . '" + waiting_message: "' . esc_js(__('Waiting for results...', 'ultimate-multisite')) . '", + error_message: "' . esc_js(__('Connection test failed. Please try again.', 'ultimate-multisite')) . '" };', 'before' ); + + wu_get_template( + 'wizards/host-integrations/test', + [ + 'screen' => get_current_screen(), + 'page' => $this, + 'integration' => $this->integration, + ] + ); } } diff --git a/inc/integrations/providers/cloudflare/class-cloudflare-integration.php b/inc/integrations/providers/cloudflare/class-cloudflare-integration.php index 5304a0e8..bf9bcafc 100644 --- a/inc/integrations/providers/cloudflare/class-cloudflare-integration.php +++ b/inc/integrations/providers/cloudflare/class-cloudflare-integration.php @@ -86,6 +86,10 @@ public function get_fields(): array { 'WU_CLOUDFLARE_API_KEY' => [ 'title' => __('API Key', 'ultimate-multisite'), 'placeholder' => __('e.g. xKGbxxVDpdcUv9dUzRf4i4ngv0QNf1wCtbehiec_o', 'ultimate-multisite'), + 'type' => 'password', + 'html_attr' => [ + 'autocomplete' => 'new-password', + ], ], ]; } diff --git a/inc/integrations/providers/cloudways/class-cloudways-integration.php b/inc/integrations/providers/cloudways/class-cloudways-integration.php index f8342e24..4078f472 100644 --- a/inc/integrations/providers/cloudways/class-cloudways-integration.php +++ b/inc/integrations/providers/cloudways/class-cloudways-integration.php @@ -85,6 +85,10 @@ public function get_fields(): array { 'title' => __('Cloudways API Key', 'ultimate-multisite'), 'desc' => __('The API Key retrieved in the previous step.', 'ultimate-multisite'), 'placeholder' => __('e.g. eYP0Jo3Fzzm5SOZCi5nLR0Mki2lbYZ', 'ultimate-multisite'), + 'type' => 'password', + 'html_attr' => [ + 'autocomplete' => 'new-password', + ], ], 'WU_CLOUDWAYS_SERVER_ID' => [ 'title' => __('Cloudways Server ID', 'ultimate-multisite'), diff --git a/inc/integrations/providers/cpanel/class-cpanel-integration.php b/inc/integrations/providers/cpanel/class-cpanel-integration.php index dec90d5d..301734d5 100644 --- a/inc/integrations/providers/cpanel/class-cpanel-integration.php +++ b/inc/integrations/providers/cpanel/class-cpanel-integration.php @@ -97,12 +97,18 @@ public function get_fields(): array { ], 'WU_CPANEL_API_TOKEN' => [ 'type' => 'password', + 'html_attr' => [ + 'autocomplete' => 'new-password', + ], 'title' => __('cPanel API Token (Recommended)', 'ultimate-multisite'), 'desc' => __('Create in cPanel → Security → Manage API Tokens. More secure than password authentication.', 'ultimate-multisite'), 'placeholder' => __('e.g. U7HMR63FHY282DQZ4H5BIH16JLYSO01M', 'ultimate-multisite'), ], 'WU_CPANEL_PASSWORD' => [ 'type' => 'password', + 'html_attr' => [ + 'autocomplete' => 'new-password', + ], 'title' => __('cPanel Password (Alternative)', 'ultimate-multisite'), 'desc' => __('Only required if not using an API token above. If you use SSO to access cPanel, you may need to request direct credentials from your host.', 'ultimate-multisite'), 'placeholder' => __('password', 'ultimate-multisite'), diff --git a/inc/integrations/providers/hestia/class-hestia-integration.php b/inc/integrations/providers/hestia/class-hestia-integration.php index 5a37d5b6..11494275 100644 --- a/inc/integrations/providers/hestia/class-hestia-integration.php +++ b/inc/integrations/providers/hestia/class-hestia-integration.php @@ -87,6 +87,9 @@ public function get_fields(): array { ], 'WU_HESTIA_API_PASSWORD' => [ 'type' => 'password', + 'html_attr' => [ + 'autocomplete' => 'new-password', + ], 'title' => __('Hestia API Password', 'ultimate-multisite'), 'desc' => __('Optional if using API hash authentication.', 'ultimate-multisite'), 'placeholder' => __('••••••••', 'ultimate-multisite'), diff --git a/inc/integrations/providers/rocket/class-rocket-integration.php b/inc/integrations/providers/rocket/class-rocket-integration.php index 6dad481e..750b93c6 100644 --- a/inc/integrations/providers/rocket/class-rocket-integration.php +++ b/inc/integrations/providers/rocket/class-rocket-integration.php @@ -92,6 +92,9 @@ public function get_fields(): array { 'desc' => __('Your Rocket.net account password.', 'ultimate-multisite'), 'placeholder' => __('Enter your password', 'ultimate-multisite'), 'type' => 'password', + 'html_attr' => [ + 'autocomplete' => 'new-password', + ], ], 'WU_ROCKET_SITE_ID' => [ 'title' => __('Rocket.net Site ID', 'ultimate-multisite'), diff --git a/inc/integrations/providers/runcloud/class-runcloud-integration.php b/inc/integrations/providers/runcloud/class-runcloud-integration.php index 2f3a7a07..80102336 100644 --- a/inc/integrations/providers/runcloud/class-runcloud-integration.php +++ b/inc/integrations/providers/runcloud/class-runcloud-integration.php @@ -76,6 +76,10 @@ public function get_fields(): array { 'title' => __('RunCloud API Token', 'ultimate-multisite'), 'desc' => __('The API Token generated in RunCloud.', 'ultimate-multisite'), 'placeholder' => __('e.g. your-api-token-here', 'ultimate-multisite'), + 'type' => 'password', + 'html_attr' => [ + 'autocomplete' => 'new-password', + ], ], 'WU_RUNCLOUD_SERVER_ID' => [ 'title' => __('RunCloud Server ID', 'ultimate-multisite'), diff --git a/inc/integrations/providers/serverpilot/class-serverpilot-integration.php b/inc/integrations/providers/serverpilot/class-serverpilot-integration.php index 1753c91c..71e24a3f 100644 --- a/inc/integrations/providers/serverpilot/class-serverpilot-integration.php +++ b/inc/integrations/providers/serverpilot/class-serverpilot-integration.php @@ -83,6 +83,10 @@ public function get_fields(): array { 'title' => __('ServerPilot API Key', 'ultimate-multisite'), 'desc' => __('The API Key retrieved in the previous step.', 'ultimate-multisite'), 'placeholder' => __('e.g. eYP0Jo3Fzzm5SOZCi5nLR0Mki2lbYZ', 'ultimate-multisite'), + 'type' => 'password', + 'html_attr' => [ + 'autocomplete' => 'new-password', + ], ], 'WU_SERVER_PILOT_APP_ID' => [ 'title' => __('ServerPilot App ID', 'ultimate-multisite'), diff --git a/views/wizards/host-integrations/configuration.php b/views/wizards/host-integrations/configuration.php index 51728d59..ea2715d4 100644 --- a/views/wizards/host-integrations/configuration.php +++ b/views/wizards/host-integrations/configuration.php @@ -5,6 +5,10 @@ * @since 2.0.0 */ defined('ABSPATH') || exit; + +/** @var \WP_Ultimo\Admin_Pages\Wizard_Admin_Page $page */ +$back_url = $page->get_prev_section_link(); +/** @var \WP_Ultimo\UI\Form $form */ ?>

@@ -29,13 +33,14 @@
- - - - + + + + + From 86cb7a237701eb1d2b2113e2900843a76708ac45 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 12:52:14 -0700 Subject: [PATCH 08/33] Improve payments --- .../class-payment-edit-admin-page.php | 126 +++++++ .../class-payment-list-admin-page.php | 291 +++++++++++++++ .../class-account-admin-page.php | 8 +- inc/functions/checkout-form.php | 8 + inc/gateways/class-base-gateway.php | 32 ++ inc/gateways/class-base-stripe-gateway.php | 346 +++++++++++------- inc/gateways/class-paypal-gateway.php | 41 ++- inc/managers/class-email-manager.php | 52 +++ inc/managers/class-event-manager.php | 55 +++ inc/managers/class-payment-manager.php | 23 +- inc/models/class-checkout-form.php | 60 +++ inc/models/class-payment.php | 22 +- inc/ui/class-payment-methods-element.php | 155 ++++---- inc/ui/class-site-actions-element.php | 188 +++++----- 14 files changed, 1086 insertions(+), 321 deletions(-) diff --git a/inc/admin-pages/class-payment-edit-admin-page.php b/inc/admin-pages/class-payment-edit-admin-page.php index c6ae6635..74d4b7de 100644 --- a/inc/admin-pages/class-payment-edit-admin-page.php +++ b/inc/admin-pages/class-payment-edit-admin-page.php @@ -137,6 +137,18 @@ public function register_forms(): void { ] ); + /* + * Resend Invoice + */ + wu_register_form( + 'resend_invoice', + [ + 'render' => [$this, 'render_resend_invoice_modal'], + 'handler' => [$this, 'handle_resend_invoice_modal'], + 'capability' => 'wu_edit_payments', + ] + ); + /* * Delete - Confirmation modal */ @@ -935,6 +947,108 @@ public function display_tax_breakthrough(): void { ); } + /** + * Renders the resend invoice confirmation modal. + * + * @since 2.5.0 + * @return void + */ + public function render_resend_invoice_modal(): void { + + $payment = wu_get_payment(wu_request('id')); + + if ( ! $payment) { + return; + } + + $fields = [ + 'invoice_message' => [ + 'type' => 'textarea', + 'title' => __('Message (optional)', 'ultimate-multisite'), + 'placeholder' => __('Add a personal note to include in the email...', 'ultimate-multisite'), + 'value' => '', + 'html_attr' => [ + 'rows' => 3, + ], + ], + 'submit_button' => [ + 'type' => 'submit', + 'title' => __('Send Invoice Email', 'ultimate-multisite'), + 'value' => 'save', + 'classes' => 'wu-w-full button button-primary', + 'wrapper_classes' => 'wu-items-end', + ], + 'id' => [ + 'type' => 'hidden', + 'value' => $payment->get_id(), + ], + ]; + + $form = new \WP_Ultimo\UI\Form( + 'resend_invoice', + $fields, + [ + 'views' => 'admin-pages/fields', + 'classes' => 'wu-modal-form wu-widget-list wu-striped wu-m-0 wu-mt-0', + 'field_wrapper_classes' => 'wu-w-full wu-box-border wu-items-center wu-flex wu-justify-between wu-p-4 wu-m-0 wu-border-t wu-border-l-0 wu-border-r-0 wu-border-b-0 wu-border-gray-300 wu-border-solid', + 'html_attr' => [ + 'data-wu-app' => 'resend_invoice', + 'data-state' => wu_convert_to_state([]), + ], + ] + ); + + $form->render(); + } + + /** + * Handles the resend invoice form submission. + * + * @since 2.5.0 + * @return void + */ + public function handle_resend_invoice_modal(): void { + + $payment = wu_get_payment(wu_request('id')); + + if ( ! $payment) { + wp_send_json_error(new \WP_Error('not-found', __('Payment not found.', 'ultimate-multisite'))); + + return; + } + + $customer = $payment->get_customer(); + + if ( ! $customer) { + wp_send_json_error(new \WP_Error('no-customer', __('No customer found for this payment.', 'ultimate-multisite'))); + + return; + } + + $payload = array_merge( + wu_generate_event_payload('payment', $payment), + wu_generate_event_payload('customer', $customer), + [ + 'payment_url' => $payment->get_payment_url() ?: '', + 'invoice_message' => sanitize_textarea_field(wu_request('invoice_message', '')), + ] + ); + + wu_do_event('invoice_sent', $payload); + + wp_send_json_success( + [ + 'redirect_url' => wu_network_admin_url( + 'wp-ultimo-edit-payment', + [ + 'id' => $payment->get_id(), + 'updated' => 1, + ] + ), + ] + ); + } + /** * Allow child classes to register widgets, if they need them. * @@ -1199,6 +1313,18 @@ public function action_links() { 'label' => __('Payment URL', 'ultimate-multisite'), 'icon' => 'wu-credit-card', ]; + + $actions[] = [ + 'url' => wu_get_form_url( + 'resend_invoice', + [ + 'id' => $payment->get_id(), + ] + ), + 'label' => __('Send Invoice Email', 'ultimate-multisite'), + 'icon' => 'wu-mail', + 'classes' => 'wubox', + ]; } } diff --git a/inc/admin-pages/class-payment-list-admin-page.php b/inc/admin-pages/class-payment-list-admin-page.php index 862ff700..040c0e43 100644 --- a/inc/admin-pages/class-payment-list-admin-page.php +++ b/inc/admin-pages/class-payment-list-admin-page.php @@ -74,6 +74,18 @@ public function register_forms(): void { 'capability' => 'wu_edit_payments', ] ); + + /* + * Send Invoice + */ + wu_register_form( + 'send_invoice', + [ + 'render' => [$this, 'render_send_invoice_modal'], + 'handler' => [$this, 'handle_send_invoice_modal'], + 'capability' => 'wu_edit_payments', + ] + ); } /** @@ -211,6 +223,279 @@ public function handle_add_new_payment_modal() { ); } + /** + * Renders the Send Invoice modal form. + * + * @since 2.5.0 + * @return void + */ + public function render_send_invoice_modal(): void { + + $fields = [ + 'customer_id' => [ + 'type' => 'model', + 'title' => __('Customer', 'ultimate-multisite'), + 'placeholder' => __('Search Customers...', 'ultimate-multisite'), + 'desc' => __('The customer to send the invoice to.', 'ultimate-multisite'), + 'value' => '', + 'tooltip' => '', + 'html_attr' => [ + 'data-model' => 'customer', + 'data-value-field' => 'id', + 'data-label-field' => 'display_name', + 'data-search-field' => 'display_name', + 'data-max-items' => 1, + ], + ], + 'products' => [ + 'type' => 'model', + 'title' => __('Products', 'ultimate-multisite'), + 'placeholder' => __('Search Products...', 'ultimate-multisite'), + 'desc' => __('Select products to include as line items. Leave empty for custom-only invoices.', 'ultimate-multisite'), + 'value' => '', + 'tooltip' => '', + 'html_attr' => [ + 'data-model' => 'product', + 'data-value-field' => 'id', + 'data-label-field' => 'name', + 'data-search-field' => 'name', + 'data-max-items' => 10, + ], + ], + 'custom_line_items' => [ + 'type' => 'group', + 'title' => __('Custom Line Items', 'ultimate-multisite'), + 'desc' => __('Add custom charges (e.g. consulting hours). Use comma-separated entries in the format: description|amount|quantity.', 'ultimate-multisite'), + 'wrapper_html_attr' => [ + 'v-show' => 'show_custom', + ], + 'fields' => [ + 'custom_items' => [ + 'type' => 'textarea', + 'placeholder' => "Consulting - 3 hours|150|3\nSetup assistance|50|1", + 'value' => '', + 'wrapper_classes' => 'wu-w-full', + 'html_attr' => [ + 'rows' => 3, + ], + ], + ], + ], + 'show_custom_btn' => [ + 'type' => 'note', + 'desc' => '' . __('+ Add custom line items', 'ultimate-multisite') . '', + 'wrapper_html_attr' => [ + 'v-show' => '!show_custom', + ], + ], + 'membership_id' => [ + 'type' => 'model', + 'title' => __('Membership (optional)', 'ultimate-multisite'), + 'placeholder' => __('Search Membership...', 'ultimate-multisite'), + 'desc' => __('Optionally link this invoice to an existing membership.', 'ultimate-multisite'), + 'value' => '', + 'tooltip' => '', + 'html_attr' => [ + 'data-model' => 'membership', + 'data-value-field' => 'id', + 'data-label-field' => 'reference_code', + 'data-max-items' => 1, + 'data-selected' => '', + ], + ], + 'invoice_message' => [ + 'type' => 'textarea', + 'title' => __('Message (optional)', 'ultimate-multisite'), + 'placeholder' => __('Add a personal note to include in the invoice email...', 'ultimate-multisite'), + 'desc' => __('This note will be included in the email sent to the customer.', 'ultimate-multisite'), + 'value' => '', + 'html_attr' => [ + 'rows' => 3, + ], + ], + 'send_notification' => [ + 'type' => 'toggle', + 'title' => __('Send Email Notification', 'ultimate-multisite'), + 'desc' => __('Send the customer an email with a link to pay this invoice.', 'ultimate-multisite'), + 'value' => 1, + ], + 'submit_button' => [ + 'type' => 'submit', + 'title' => __('Create Invoice', 'ultimate-multisite'), + 'value' => 'save', + 'classes' => 'wu-w-full button button-primary', + 'wrapper_classes' => 'wu-items-end', + ], + ]; + + $form = new \WP_Ultimo\UI\Form( + 'send_invoice', + $fields, + [ + 'views' => 'admin-pages/fields', + 'classes' => 'wu-modal-form wu-widget-list wu-striped wu-m-0 wu-mt-0', + 'field_wrapper_classes' => 'wu-w-full wu-box-border wu-items-center wu-flex wu-justify-between wu-p-4 wu-m-0 wu-border-t wu-border-l-0 wu-border-r-0 wu-border-b-0 wu-border-gray-300 wu-border-solid', + 'html_attr' => [ + 'data-wu-app' => 'send_invoice', + 'data-state' => wu_convert_to_state( + [ + 'show_custom' => false, + ] + ), + ], + ] + ); + + $form->render(); + } + + /** + * Handles the Send Invoice form submission. + * + * @since 2.5.0 + * @return void + */ + public function handle_send_invoice_modal(): void { + + $customer_id = absint(wu_request('customer_id')); + $customer = wu_get_customer($customer_id); + + if ( ! $customer) { + wp_send_json_error(new \WP_Error('invalid-customer', __('Please select a valid customer.', 'ultimate-multisite'))); + + return; + } + + $membership_id = absint(wu_request('membership_id', 0)); + $products_raw = wu_request('products', ''); + $custom_items = wu_request('custom_items', ''); + + /* + * Build line items from products. + */ + $line_items = []; + + if ( ! empty($products_raw)) { + $product_ids = array_filter(array_map('absint', explode(',', (string) $products_raw))); + + foreach ($product_ids as $product_id) { + $product = wu_get_product($product_id); + + if ( ! $product) { + continue; + } + + $line_items[] = new \WP_Ultimo\Checkout\Line_Item( + [ + 'product' => $product, + 'quantity' => 1, + 'unit_price' => $product->get_amount(), + 'title' => $product->get_name(), + ] + ); + } + } + + /* + * Build line items from custom entries. + */ + if ( ! empty($custom_items)) { + $lines = array_filter(array_map('trim', explode("\n", (string) $custom_items))); + + foreach ($lines as $line) { + $parts = array_map('trim', explode('|', $line)); + $title = $parts[0] ?? ''; + $unit_price = isset($parts[1]) ? wu_to_float($parts[1]) : 0; + $quantity = isset($parts[2]) ? absint($parts[2]) : 1; + + if (empty($title) || $unit_price <= 0) { + continue; + } + + $line_items[] = new \WP_Ultimo\Checkout\Line_Item( + [ + 'type' => 'fee', + 'hash' => uniqid(), + 'title' => sanitize_text_field($title), + 'unit_price' => $unit_price, + 'quantity' => max(1, $quantity), + ] + ); + } + } + + if (empty($line_items)) { + wp_send_json_error(new \WP_Error('no-items', __('Please add at least one product or custom line item.', 'ultimate-multisite'))); + + return; + } + + /* + * Calculate totals from line items. + */ + $subtotal = 0; + $tax_total = 0; + $total = 0; + + foreach ($line_items as $line_item) { + $line_item->recalculate_totals(); + + $subtotal += $line_item->get_subtotal(); + $tax_total += $line_item->get_tax_total(); + $total += $line_item->get_total(); + } + + /* + * Create the pending payment. + */ + $payment_data = [ + 'customer_id' => $customer->get_id(), + 'membership_id' => $membership_id, + 'status' => Payment_Status::PENDING, + 'subtotal' => $subtotal, + 'tax_total' => $tax_total, + 'total' => $total, + 'line_items' => $line_items, + ]; + + $payment = wu_create_payment($payment_data); + + if (is_wp_error($payment)) { + wp_send_json_error($payment); + + return; + } + + /* + * Fire the invoice_sent event. + */ + $send_notification = wu_request('send_notification'); + + if ($send_notification) { + $payload = array_merge( + wu_generate_event_payload('payment', $payment), + wu_generate_event_payload('customer', $customer), + [ + 'payment_url' => $payment->get_payment_url(), + 'invoice_message' => sanitize_textarea_field(wu_request('invoice_message', '')), + ] + ); + + wu_do_event('invoice_sent', $payload); + } + + wp_send_json_success( + [ + 'redirect_url' => wu_network_admin_url( + 'wp-ultimo-edit-payment', + [ + 'id' => $payment->get_id(), + ] + ), + ] + ); + } + /** * Allow child classes to register widgets, if they need them. * @@ -281,6 +566,12 @@ public function action_links() { 'classes' => 'wubox', 'url' => wu_get_form_url('add_new_payment'), ], + [ + 'label' => __('Send Invoice', 'ultimate-multisite'), + 'icon' => 'wu-mail', + 'classes' => 'wubox', + 'url' => wu_get_form_url('send_invoice'), + ], ]; } diff --git a/inc/admin-pages/customer-panel/class-account-admin-page.php b/inc/admin-pages/customer-panel/class-account-admin-page.php index 850f57dd..9e4c66f9 100644 --- a/inc/admin-pages/customer-panel/class-account-admin-page.php +++ b/inc/admin-pages/customer-panel/class-account-admin-page.php @@ -138,7 +138,13 @@ protected function add_notices() { return; } - $update_message = apply_filters('wu_account_update_message', __('Your account was successfully updated.', 'ultimate-multisite'), $update_type); + if ('payment_method' === $update_type) { + $update_message = __('Your payment method was successfully updated.', 'ultimate-multisite'); + } else { + $update_message = __('Your account was successfully updated.', 'ultimate-multisite'); + } + + $update_message = apply_filters('wu_account_update_message', $update_message, $update_type); WP_Ultimo()->notices->add($update_message); } diff --git a/inc/functions/checkout-form.php b/inc/functions/checkout-form.php index fcae9a94..11c8e057 100644 --- a/inc/functions/checkout-form.php +++ b/inc/functions/checkout-form.php @@ -83,6 +83,14 @@ function wu_get_checkout_form_by_slug($checkout_form_slug) { $checkout_form->set_settings($checkout_fields); + return $checkout_form; + } elseif ('wu-pay-invoice' === $checkout_form_slug) { + $checkout_form = new \WP_Ultimo\Models\Checkout_Form(); + + $checkout_fields = Checkout_Form::pay_invoice_form_fields(); + + $checkout_form->set_settings($checkout_fields); + return $checkout_form; } diff --git a/inc/gateways/class-base-gateway.php b/inc/gateways/class-base-gateway.php index cd6657ba..35974ea1 100644 --- a/inc/gateways/class-base-gateway.php +++ b/inc/gateways/class-base-gateway.php @@ -198,6 +198,38 @@ final public function get_id() { return $this->id; } + /** + * Returns payment method display info for the given membership. + * + * Gateways that support displaying the payment method (e.g. card brand + last4) + * should override this method. + * + * @since 2.5.0 + * + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @return array{brand: string, last4: string}|null Payment method info, or null. + */ + public function get_payment_method_display($membership): ?array { + unset($membership); + return null; + } + + /** + * Returns a URL for changing the payment method for the given membership. + * + * Gateways that support payment method changes (e.g. Stripe Billing Portal) + * should override this method. + * + * @since 2.5.0 + * + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @return string|null The URL, or null if not supported. + */ + public function get_change_payment_method_url($membership) { + unset($membership); + return null; + } + /* * Required Methods. * diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php index 2ffc5625..af7f5e84 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -13,12 +13,17 @@ use Psr\Log\LogLevel; use Stripe; +use Stripe\Customer; +use Stripe\PaymentMethod; use Stripe\StripeClient; +use Stripe\Subscription; use Stripe\WebhookEndpoint; +use WP_Ultimo\Checkout\Cart; +use WP_Ultimo\Exception\Runtime_Exception; use WP_Ultimo\Models\Membership; use WP_Ultimo\Database\Payments\Payment_Status; use WP_Ultimo\Checkout\Line_Item; -use WP_Ultimo\Models\Site; +use WP_Ultimo\Models\Payment; // Exit if accessed directly defined('ABSPATH') || exit; @@ -136,9 +141,16 @@ class Base_Stripe_Gateway extends Base_Gateway { * to the connected account. * * @return StripeClient + * @throws Runtime_Exception Other Error. */ protected function get_stripe_client(): StripeClient { if (! isset($this->stripe_client)) { + if (empty($this->secret_key)) { + throw new Runtime_Exception( + esc_html__('Stripe API key is not configured. Please add your Stripe API keys in the Ultimate Multisite settings.', 'ultimate-multisite') + ); + } + $client_config = [ 'api_key' => $this->secret_key, ]; @@ -255,8 +267,6 @@ public function setup_api_keys($id = false): void { if ($this->secret_key && Stripe\Stripe::getApiKey() !== $this->secret_key) { Stripe\Stripe::setApiKey($this->secret_key); - - Stripe\Stripe::setApiVersion('2019-05-16'); } } @@ -677,8 +687,6 @@ public function hooks(): void { add_filter('wu_pre_save_settings', [$this, 'fix_saving_settings'], 10, 3); - add_filter('wu_element_get_site_actions', [$this, 'add_site_actions'], 10, 4); - /** * We need to check if we should redirect after instantiate the Currents */ @@ -704,49 +712,79 @@ public function hooks(): void { public function allow_stripe_redirect_host(array $hosts): array { $hosts[] = 'connect.stripe.com'; + $hosts[] = 'dashboard.stripe.com'; + $hosts[] = 'checkout.stripe.com'; + $hosts[] = 'billing.stripe.com'; return $hosts; } /** - * Adds Stripe Billing Portal link to the site actions. + * Returns the Stripe Billing Portal URL for changing payment method. * - * @since 2.1.2 + * @since 2.5.0 * - * @param array $actions The site actions. - * @param array $atts The widget attributes. - * @param Site $site The current site object. - * @param Membership $membership The current membership object. - * @return array + * @param \WP_Ultimo\Models\Membership $membership The membership to change payment for. + * @return string|null The portal URL, or null if not supported. */ - public function add_site_actions($actions, $atts, $site, $membership) { + public function get_change_payment_method_url($membership) { $gateway_id = wu_replace_dashes($this->id); if ( ! wu_get_setting("{$gateway_id}_enable_portal")) { - return $actions; + return null; } - $payment_gateway = $membership ? $membership->get_gateway() : false; + $s_subscription_id = $membership->get_gateway_subscription_id(); - if (wu_get_isset($atts, 'show_change_payment_method') && in_array($payment_gateway, $this->other_ids, true)) { - $s_subscription_id = $membership->get_gateway_subscription_id(); + if (empty($s_subscription_id)) { + return null; + } - if ( ! empty($s_subscription_id)) { - $actions['change_payment_method'] = [ - 'label' => __('Change Payment Method', 'ultimate-multisite'), - 'icon_classes' => 'dashicons-wu-edit wu-align-middle', - 'href' => add_query_arg( - [ - 'wu-stripe-portal' => true, - 'membership' => $membership->get_hash(), - ] - ), - ]; + return add_query_arg( + [ + 'wu-stripe-portal' => true, + 'membership' => $membership->get_hash(), + ], + home_url() + ); + } + + /** + * Returns payment method display info from the Stripe subscription. + * + * @since 2.5.0 + * + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @return array{brand: string, last4: string}|null Payment method info, or null. + */ + public function get_payment_method_display($membership): ?array { + + try { + $sub_id = $membership->get_gateway_subscription_id(); + + if (empty($sub_id)) { + return null; + } + + $subscription = $this->get_stripe_client()->subscriptions->retrieve( + $sub_id, + ['expand' => ['default_payment_method']] + ); + + $pm = $subscription->default_payment_method; + + if ( ! $pm || ! $pm->card) { + return null; } - } - return $actions; + return [ + 'brand' => ucfirst($pm->card->brand ?? ''), + 'last4' => $pm->card->last4 ?? '', + ]; + } catch (\Throwable $e) { + return null; + } } /** @@ -787,86 +825,102 @@ public function maybe_redirect_to_portal(): void { $customer_id = $membership->get_customer_id(); $s_customer_id = $membership->get_gateway_customer_id(); - $return_url = remove_query_arg('wu-stripe-portal', wu_get_current_url()); - // If customer is not set, get from checkout session - if (empty($s_customer_id)) { - /** - * Filter Stripe Subscription data. Can override success_url or cancel_url. - * - * @since 2.4.2 - * - * @param array $subscription_data An array of parameters to pass to Stripe. - * @param Base_Gateway $gateway The current Stripe Gateway object. - */ - $subscription_data = apply_filters( - 'wu_stripe_checkout_subscription_data', - [ - 'payment_method_types' => $allowed_payment_method_types, - 'mode' => 'setup', - 'success_url' => $return_url, - 'cancel_url' => wu_get_current_url(), - 'billing_address_collection' => 'required', - 'client_reference_id' => $customer_id, - 'customer' => $s_customer_id, - ], - $gateway - ); + $stored_redirect = get_user_meta(get_current_user_id(), '_wu_change_payment_redirect', true); - $session = $this->get_stripe_client()->checkout->sessions->create($subscription_data); - $s_customer_id = $session->customer; + if ($stored_redirect) { + delete_user_meta(get_current_user_id(), '_wu_change_payment_redirect'); + $return_url = add_query_arg('updated', 'payment_method', $stored_redirect); + } else { + $return_url = remove_query_arg('wu-stripe-portal', wu_get_current_url()); } - $portal_config_id = get_site_option('wu_stripe_portal_config_id'); + try { + // If customer is not set, get from checkout session + if (empty($s_customer_id)) { + /** + * Filter Stripe Subscription data. Can override success_url or cancel_url. + * + * @since 2.4.2 + * + * @param array $subscription_data An array of parameters to pass to Stripe. + * @param Base_Gateway $gateway The current Stripe Gateway object. + */ + $subscription_data = apply_filters( + 'wu_stripe_checkout_subscription_data', + [ + 'payment_method_types' => $allowed_payment_method_types, + 'mode' => 'setup', + 'success_url' => $return_url, + 'cancel_url' => wu_get_current_url(), + 'billing_address_collection' => 'required', + 'client_reference_id' => $customer_id, + 'customer' => $s_customer_id, + ], + $gateway + ); - if ( ! $portal_config_id) { - $portal_config = $this->get_stripe_client()->billingPortal->configurations->create( - [ - 'features' => [ - 'invoice_history' => [ - 'enabled' => true, - ], - 'payment_method_update' => [ - 'enabled' => true, - ], - 'subscription_cancel' => [ - 'enabled' => true, - 'mode' => 'at_period_end', - 'cancellation_reason' => [ + $session = $this->get_stripe_client()->checkout->sessions->create($subscription_data); + $s_customer_id = $session->customer; + } + + $portal_config_id = get_site_option('wu_stripe_portal_config_id'); + + if ( ! $portal_config_id) { + $portal_config = $this->get_stripe_client()->billingPortal->configurations->create( + [ + 'features' => [ + 'invoice_history' => [ 'enabled' => true, - 'options' => [ - 'too_expensive', - 'missing_features', - 'switched_service', - 'unused', - 'customer_service', - 'too_complex', - 'other', + ], + 'payment_method_update' => [ + 'enabled' => true, + ], + 'subscription_cancel' => [ + 'enabled' => true, + 'mode' => 'at_period_end', + 'cancellation_reason' => [ + 'enabled' => true, + 'options' => [ + 'too_expensive', + 'missing_features', + 'switched_service', + 'unused', + 'customer_service', + 'too_complex', + 'other', + ], ], ], ], - ], - 'business_profile' => [ - 'headline' => __('Manage your membership payment methods.', 'ultimate-multisite'), - ], - ] - ); + 'business_profile' => [ + 'headline' => __('Manage your membership payment methods.', 'ultimate-multisite'), + ], + ] + ); - $portal_config_id = $portal_config->id; + $portal_config_id = $portal_config->id; - update_site_option('wu_stripe_portal_config_id', $portal_config_id); - } + update_site_option('wu_stripe_portal_config_id', $portal_config_id); + } - $subscription_data = [ - 'return_url' => $return_url, - 'customer' => $s_customer_id, - 'configuration' => $portal_config_id, - ]; + $subscription_data = [ + 'return_url' => $return_url, + 'customer' => $s_customer_id, + 'configuration' => $portal_config_id, + ]; - $session = $this->get_stripe_client()->billingPortal->sessions->create($subscription_data); + $session = $this->get_stripe_client()->billingPortal->sessions->create($subscription_data); - wp_redirect($session->url); - exit; + wp_safe_redirect($session->url); + exit; + } catch (\Throwable $e) { + wp_die( + esc_html($e->getMessage()), + esc_html__('Stripe Error', 'ultimate-multisite'), + ['back_link' => true] + ); + } } /** @@ -1392,7 +1446,7 @@ public function get_or_create_customer($customer_id = 0, $user_id = 0, $stripe_c if ( $stripe_customer && (! isset($stripe_customer->deleted) || ! $stripe_customer->deleted)) { $customer_exists = true; } - } catch (\Exception $e) { + } catch (\Exception $e) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch /** * Silence is golden. @@ -1506,14 +1560,14 @@ public function process_checkout($payment, $membership, $customer, $cart, $type) /** * Create a recurring subscription in Stripe. * - * @since 2.0.0 - * - * @param \WP_Ultimo\Models\Membership $membership The membership. - * @param \WP_Ultimo\Checkout\Cart $cart The cart object. - * @param Stripe\PaymentMethod $payment_method The save payment method on Stripe. - * @param Stripe\Customer $s_customer The Stripe customer. + * @param Membership $membership The membership. + * @param Cart $cart The cart object. + * @param PaymentMethod $payment_method The save payment method on Stripe. + * @param Customer $s_customer The Stripe customer. * - * @return Stripe\Subscription|bool The Stripe subscription object or false if the creation is running in another process. + * @return Subscription|bool The Stripe subscription object or false if the creation is running in another process. + * @throws \Exception Other Error. + * @since 2.0.0 */ protected function create_recurring_payment($membership, $cart, $payment_method, $s_customer) { /** @@ -1734,16 +1788,18 @@ protected function create_recurring_payment($membership, $cart, $payment_method, return $subscription; } + /** * Checks if we need to create a pro-rate/credit coupon based on the cart data. * * Will return an array with coupon arguments for stripe if * there is credit to be added and false if not. * - * @since 2.0.0 - * * @param \WP_Ultimo\Checkout\Cart $cart The current cart. + * * @return string|false + * @throws \Exception Exception. + * @since 2.0.0 */ protected function get_credit_coupon($cart) { @@ -1779,10 +1835,11 @@ protected function get_credit_coupon($cart) { * Checks to see if the coupon exists, and if so, returns the ID of * that coupon. If not, a new coupon is created. * - * @since 2.0.18 - * * @param array $coupon_data The cart/order object. + * * @return string + * @throws \Exception Other Error. + * @since 2.0.18 */ protected function get_stripe_coupon($coupon_data) { @@ -1798,7 +1855,7 @@ protected function get_stripe_coupon($coupon_data) { ); return $coupon->id; - } catch (\Exception $e) { + } catch (\Exception $e) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // silence is golden } @@ -1853,19 +1910,35 @@ protected function build_non_recurring_cart($cart, $include_recurring_products = continue; } - $cart_items[ $line_item->get_id() ] = [ - 'name' => $line_item->get_title(), - 'quantity' => $line_item->get_quantity(), - 'amount' => $line_item->get_unit_price() * wu_stripe_get_currency_multiplier(), - 'currency' => strtolower($cart->get_currency()), + $unit_amount = round($line_item->get_unit_price() * wu_stripe_get_currency_multiplier()); + + /* + * Skip zero-amount items. + * These would cause errors in Stripe Checkout Sessions. + */ + if ($unit_amount <= 0) { + continue; + } + + $product_data = [ + 'name' => $line_item->get_title(), ]; $description = $line_item->get_description(); if ( ! empty($description)) { - $cart_items[ $line_item->get_id() ]['description'] = $description; + $product_data['description'] = $description; } + $cart_items[ $line_item->get_id() ] = [ + 'price_data' => [ + 'currency' => strtolower($cart->get_currency()), + 'unit_amount' => (int) $unit_amount, + 'product_data' => $product_data, + ], + 'quantity' => $line_item->get_quantity(), + ]; + /* * Now, we handle the taxable status * of the payment. @@ -2022,7 +2095,7 @@ protected function get_ultimo_line_items_from_invoice($invoice_line_items) { $line_item_data = [ 'title' => $title, 'description' => $s_line_item->description, - 'tax_inclusive' => 'inclusive' === $s_line_item->taxes[0]->tax_behavior, // $s_line_item->amount !== $s_line_item->taxes->amount_excluding_tax, + 'tax_inclusive' => 'inclusive' === $s_line_item->taxes[0]->tax_behavior, 'unit_price' => (float) $s_line_item->pricing->unit_amount_decimal / $currency_multiplier, 'quantity' => $quantity, ]; @@ -2254,13 +2327,14 @@ public function should_apply_application_fee(): bool { * It takes the data concerning * a refund and process it. * - * @since 2.0.0 + * @param float $amount The amount to refund. + * @param Payment $payment The payment associated with the checkout. + * @param Membership $membership The membership. + * @param \WP_Ultimo\Models\Customer $customer The customer checking out. * - * @param float $amount The amount to refund. - * @param \WP_Ultimo\Models\Payment $payment The payment associated with the checkout. - * @param \WP_Ultimo\Models\Membership $membership The membership. - * @param \WP_Ultimo\Models\Customer $customer The customer checking out. - * @return void|bool + * @return bool + * @throws \Exception Other Error. + * @since 2.0.0 */ public function process_refund($amount, $payment, $membership, $customer): bool { @@ -2401,8 +2475,7 @@ public function get_stripe_max_billing_cycle_anchor($interval, $interval_unit, $ try { $stripe_max_anchor = new \DateTime(date('Y-m-t H:i:s', $proposed_next_bill_date->getTimestamp())); // phpcs:ignore - } catch (\Exception $exception) { - + } catch (\Exception $exception) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Silence is golden } } @@ -2479,10 +2552,10 @@ public function before_backwards_compatible_webhook(): void { /** * Process webhooks * - * @since 2.0.0 - * @throws Ignorable_Exception When the webhook should be ignored (duplicate payments, wrong gateway, etc.). - * @throws Stripe\Exception\ApiErrorException When Stripe API calls fail. * @return void + * @throws \Exception Other Error. + * @throws Ignorable_Exception Something that can be ignored. + * @since 2.0.0 */ public function process_webhooks() { @@ -2930,6 +3003,20 @@ public function process_webhooks() { // Make sure this invoice is tied to a subscription and is the user's current subscription. if ( ! empty($event->data->object->subscription) && $membership->get_gateway_subscription_id() === $event->data->object->subscription) { do_action('wu_recurring_payment_failed', $membership, $this); + + $customer = $membership->get_customer(); + + if ($customer) { + $payload = array_merge( + wu_generate_event_payload('membership', $membership), + wu_generate_event_payload('customer', $customer), + [ + 'payment_gateway' => $this->get_id(), + ] + ); + + wu_do_event('payment_failed', $payload); + } } do_action('wu_stripe_charge_failed', $payment_event, $event, $membership); @@ -3204,7 +3291,7 @@ public function maybe_create_plan($args) { $plan = $this->get_stripe_client()->plans->retrieve($existing_plan_id); return $plan->id; - } catch (\Exception $e) { + } catch (\Exception $e) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // silence is golden } @@ -3214,7 +3301,6 @@ public function maybe_create_plan($args) { $product = $this->get_stripe_client()->products->create( [ 'name' => $args['name'] . ' - ' . $args['currency'], - 'type' => 'service', ] ); @@ -3292,7 +3378,7 @@ private function maybe_create_product($name, $id = '') { $product = $this->get_stripe_client()->products->retrieve($existing_product_id); return $product->id; - } catch (\Exception $e) { + } catch (\Exception $e) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // silence is golden } diff --git a/inc/gateways/class-paypal-gateway.php b/inc/gateways/class-paypal-gateway.php index 3953ab01..8f2bc9d8 100644 --- a/inc/gateways/class-paypal-gateway.php +++ b/inc/gateways/class-paypal-gateway.php @@ -412,14 +412,15 @@ public function process_membership_update(&$membership, $customer) { * It takes the data concerning * a new checkout and process it. * - * @since 2.0.0 - * * @param \WP_Ultimo\Models\Payment $payment The payment associated with the checkout. * @param \WP_Ultimo\Models\Membership $membership The membership. * @param \WP_Ultimo\Models\Customer $customer The customer checking out. * @param \WP_Ultimo\Checkout\Cart $cart The cart object. * @param string $type The checkout type. Can be 'new', 'retry', 'upgrade', 'downgrade', 'addon'. + * * @return void + * @throws \Exception If something goes really wrong. + * @since 2.0.0 */ public function process_checkout($payment, $membership, $customer, $cart, $type): void { /* @@ -649,7 +650,7 @@ public function process_checkout($payment, $membership, $customer, $cart, $type) * * Redirect to the PayPal checkout URL. */ - wp_redirect($this->checkout_url . $body['TOKEN']); + wp_redirect($this->checkout_url . $body['TOKEN']); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect exit; } @@ -1078,6 +1079,22 @@ public function process_webhooks(): bool { // translators: %s: Transaction ID $membership->add_note(['text' => sprintf(__('Transaction ID %s failed in PayPal.', 'ultimate-multisite'), $posted['txn_id'])]); + do_action('wu_recurring_payment_failed', $membership, $this); + + $customer = $membership->get_customer(); + + if ($customer) { + $payload = array_merge( + wu_generate_event_payload('membership', $membership), + wu_generate_event_payload('customer', $customer), + [ + 'payment_gateway' => $this->get_id(), + ] + ); + + wu_do_event('payment_failed', $payload); + } + die('Subscription payment failed'); } elseif ('pending' === strtolower((string) $posted['payment_status'])) { @@ -1152,6 +1169,20 @@ public function process_webhooks(): bool { case 'recurring_payment_suspended_due_to_max_failed_payment': // Same case as before wu_log_add('paypal', 'Processing PayPal Express recurring_payment_failed or recurring_payment_suspended_due_to_max_failed_payment IPN.'); + $customer = $membership->get_customer(); + + if ($customer) { + $payload = array_merge( + wu_generate_event_payload('membership', $membership), + wu_generate_event_payload('customer', $customer), + [ + 'payment_gateway' => $this->get_id(), + ] + ); + + wu_do_event('payment_failed', $payload); + } + if ( ! in_array($membership->get_status(), ['cancelled', 'expired'], true)) { $membership->set_status('expired'); } @@ -1553,7 +1584,7 @@ protected function complete_single_payment($details, $cart, $payment, $membershi * Display the confirmation form. * * @since 2.1 - * @return string + * @return void */ public function confirmation_form() { @@ -1565,7 +1596,7 @@ public function confirmation_form() { $error = is_wp_error($checkout_details) ? $checkout_details->get_error_message() : __('Invalid response code from PayPal', 'ultimate-multisite'); // translators: %s is the paypal error message. - return '

' . sprintf(__('An unexpected PayPal error occurred. Error message: %s.', 'ultimate-multisite'), $error) . '

'; + echo '

' . sprintf(esc_html__('An unexpected PayPal error occurred. Error message: %s.', 'ultimate-multisite'), esc_html($error)) . '

'; } /* diff --git a/inc/managers/class-email-manager.php b/inc/managers/class-email-manager.php index d9599c1f..852a8f75 100644 --- a/inc/managers/class-email-manager.php +++ b/inc/managers/class-email-manager.php @@ -481,6 +481,58 @@ public function register_all_default_system_emails(): void { ] ); + /* + * Payment Failed - Customer + */ + $this->register_default_system_email( + [ + 'event' => 'payment_failed', + 'slug' => 'payment_failed_customer', + 'target' => 'customer', + 'title' => __('Your payment could not be processed', 'ultimate-multisite'), + 'content' => wu_get_template_contents('emails/customer/payment-failed'), + ] + ); + + /* + * Payment Failed - Admin + */ + $this->register_default_system_email( + [ + 'event' => 'payment_failed', + 'slug' => 'payment_failed_admin', + 'target' => 'admin', + 'title' => __('A recurring payment has failed!', 'ultimate-multisite'), + 'content' => wu_get_template_contents('emails/admin/payment-failed'), + ] + ); + + /* + * Membership Expired - Customer + */ + $this->register_default_system_email( + [ + 'event' => 'membership_expired', + 'slug' => 'membership_expired_customer', + 'target' => 'customer', + 'title' => __('Your membership has expired', 'ultimate-multisite'), + 'content' => wu_get_template_contents('emails/customer/membership-expired'), + ] + ); + + /* + * Membership Expired - Admin + */ + $this->register_default_system_email( + [ + 'event' => 'membership_expired', + 'slug' => 'membership_expired_admin', + 'target' => 'admin', + 'title' => __('A membership has expired!', 'ultimate-multisite'), + 'content' => wu_get_template_contents('emails/admin/membership-expired'), + ] + ); + do_action('wu_system_emails_after_register'); } diff --git a/inc/managers/class-event-manager.php b/inc/managers/class-event-manager.php index 3d6d25dd..295717f9 100644 --- a/inc/managers/class-event-manager.php +++ b/inc/managers/class-event-manager.php @@ -473,6 +473,61 @@ public function register_all_events(): void { ] ); + /** + * Invoice Sent + */ + wu_register_event_type( + 'invoice_sent', + [ + 'name' => __('Invoice Sent', 'ultimate-multisite'), + 'desc' => __('This event is fired every time an invoice is sent to a customer by a network admin.', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + [ + 'payment_url' => 'https://linktopayment.com', + 'invoice_message' => 'Example message to the customer.', + ], + wu_generate_event_payload('payment'), + wu_generate_event_payload('customer') + ), + 'deprecated_args' => [], + ] + ); + + /** + * Payment Failed. + */ + wu_register_event_type( + 'payment_failed', + [ + 'name' => __('Recurring Payment Failed', 'ultimate-multisite'), + 'desc' => __('Fired when an auto-renewing payment fails (Stripe/PayPal).', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + wu_generate_event_payload('membership'), + wu_generate_event_payload('customer'), + [ + 'payment_gateway' => 'stripe', + ] + ), + 'deprecated_args' => [], + ] + ); + + /** + * Membership Expired. + */ + wu_register_event_type( + 'membership_expired', + [ + 'name' => __('Membership Expired', 'ultimate-multisite'), + 'desc' => __('Fired when a membership transitions to expired status.', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + wu_generate_event_payload('membership'), + wu_generate_event_payload('customer') + ), + 'deprecated_args' => [], + ] + ); + $models = $this->models_events; foreach ($models as $model => $params) { diff --git a/inc/managers/class-payment-manager.php b/inc/managers/class-payment-manager.php index 5b9ea4e4..34a6a60d 100644 --- a/inc/managers/class-payment-manager.php +++ b/inc/managers/class-payment-manager.php @@ -105,11 +105,24 @@ function () { */ public function handle_payment_success($payment, $membership, $gateway): void { - $payload = array_merge( - wu_generate_event_payload('payment', $payment), - wu_generate_event_payload('membership', $membership), - wu_generate_event_payload('customer', $membership->get_customer()) - ); + $payload = wu_generate_event_payload('payment', $payment); + + if ($membership) { + $payload = array_merge( + $payload, + wu_generate_event_payload('membership', $membership), + wu_generate_event_payload('customer', $membership->get_customer()) + ); + } else { + $customer = $payment->get_customer(); + + if ($customer) { + $payload = array_merge( + $payload, + wu_generate_event_payload('customer', $customer) + ); + } + } wu_do_event('payment_received', $payload); } diff --git a/inc/models/class-checkout-form.php b/inc/models/class-checkout-form.php index eb754c28..59279e3e 100644 --- a/inc/models/class-checkout-form.php +++ b/inc/models/class-checkout-form.php @@ -1251,6 +1251,66 @@ public static function finish_checkout_form_fields() { return apply_filters('wu_checkout_form_finish_checkout_form_fields', $steps); } + /** + * Minimal form fields for paying a standalone invoice. + * + * Shows order summary, payment method selector, and submit button. + * + * @since 2.5.0 + * @return array + */ + public static function pay_invoice_form_fields(): array { + + $payment = wu_get_payment_by_hash(wu_request('payment')); + + if ( ! $payment && wu_request('payment_id')) { + $payment = wu_get_payment(wu_request('payment_id')); + } + + if ( ! $payment && current_user_can('manage_options')) { + $payment = wu_mock_payment(); + } + + if ( ! $payment) { + return []; + } + + $fields = [ + [ + 'step' => 'checkout', + 'name' => __('Invoice Summary', 'ultimate-multisite'), + 'type' => 'order_summary', + 'id' => 'order_summary', + 'order_summary_template' => 'clean', + 'table_columns' => 'simple', + ], + [ + 'step' => 'checkout', + 'name' => __('Payment Method', 'ultimate-multisite'), + 'type' => 'payment', + 'id' => 'payment', + ], + [ + 'step' => 'checkout', + 'name' => __('Pay Invoice', 'ultimate-multisite'), + 'type' => 'submit_button', + 'id' => 'checkout', + 'order' => 0, + ], + ]; + + $steps = [ + [ + 'id' => 'checkout', + 'name' => __('Pay Invoice', 'ultimate-multisite'), + 'desc' => '', + 'fields' => $fields, + ], + ]; + + return apply_filters('wu_checkout_form_pay_invoice_form_fields', $steps); + } + /** * Custom fields for back-end upgrade/downgrades and such. * diff --git a/inc/models/class-payment.php b/inc/models/class-payment.php index 84d5562c..48af84f9 100644 --- a/inc/models/class-payment.php +++ b/inc/models/class-payment.php @@ -236,7 +236,7 @@ public function validation_rules(): array { return [ 'customer_id' => 'required|integer|exists:\WP_Ultimo\Models\Customer,id', - 'membership_id' => 'required|integer|exists:\WP_Ultimo\Models\Membership,id', + 'membership_id' => 'integer|exists:\WP_Ultimo\Models\Membership,id|default:0', 'parent_id' => 'integer|default:', 'currency' => "default:{$currency}", 'subtotal' => 'required|numeric', @@ -782,14 +782,20 @@ public function get_payment_url() { return false; } - $slug = $this->get_hash(); + $args = [ + 'payment' => $this->get_hash(), + ]; - return add_query_arg( - [ - 'payment' => $slug, - ], - wu_get_registration_url() - ); + /* + * For standalone payments (no membership), + * use the dedicated pay-invoice checkout form + * which shows a minimal order summary + payment UI. + */ + if ( ! $this->get_membership_id()) { + $args['checkout_form'] = 'wu-pay-invoice'; + } + + return add_query_arg($args, wu_get_registration_url()); } /** diff --git a/inc/ui/class-payment-methods-element.php b/inc/ui/class-payment-methods-element.php index 50e99d0e..441301b0 100644 --- a/inc/ui/class-payment-methods-element.php +++ b/inc/ui/class-payment-methods-element.php @@ -13,7 +13,7 @@ defined('ABSPATH') || exit; /** - * Adds the Checkout Element UI to the Admin Panel. + * Displays the customer's current payment method and allows changes. * * @since 2.0.0 */ @@ -24,21 +24,37 @@ class Payment_Methods_Element extends Base_Element { /** * The id of the element. * - * Something simple, without prefixes, like 'checkout', or 'pricing-tables'. - * - * This is used to construct shortcodes by prefixing the id with 'wu_' - * e.g. an id checkout becomes the shortcode 'wu_checkout' and - * to generate the Gutenberg block by prefixing it with 'wp-ultimo/' - * e.g. checkout would become the block 'wp-ultimo/checkout'. - * * @since 2.0.0 * @var string */ public $id = 'payment-methods'; + /** + * Controls if this is a public element to be used in pages/shortcodes by user. + * + * @since 2.5.0 + * @var boolean + */ + protected $public = true; + + /** + * The current membership. + * + * @since 2.5.0 + * @var \WP_Ultimo\Models\Membership|null + */ + protected $membership; + + /** + * The current customer. + * + * @since 2.5.0 + * @var \WP_Ultimo\Models\Customer|null + */ + protected $customer; + /** * The icon of the UI element. - * e.g. return fa fa-search * * @since 2.0.0 * @param string $context One of the values: block, elementor or bb. @@ -47,19 +63,15 @@ class Payment_Methods_Element extends Base_Element { public function get_icon($context = 'block') { if ('elementor' === $context) { - return 'eicon-info-circle-o'; + return 'eicon-credit-card'; } - return 'fa fa-search'; + return 'fa fa-credit-card'; } /** * The title of the UI element. * - * This is used on the Blocks list of Gutenberg. - * You should return a string with the localized title. - * e.g. return __('My Element', 'ultimate-multisite'). - * * @since 2.0.0 * @return string */ @@ -71,11 +83,6 @@ public function get_title() { /** * The description of the UI element. * - * This is also used on the Gutenberg block list - * to explain what this block is about. - * You should return a string with the localized title. - * e.g. return __('Adds a checkout form to the page', 'ultimate-multisite'). - * * @since 2.0.0 * @return string */ @@ -87,17 +94,6 @@ public function get_description() { /** * The list of fields to be added to Gutenberg. * - * If you plan to add Gutenberg controls to this block, - * you'll need to return an array of fields, following - * our fields interface (@see inc/ui/class-field.php). - * - * You can create new Gutenberg panels by adding fields - * with the type 'header'. See the Checkout Elements for reference. - * - * @see inc/ui/class-checkout-element.php - * - * Return an empty array if you don't have controls to add. - * * @since 2.0.0 * @return array */ @@ -111,20 +107,12 @@ public function fields() { 'type' => 'header', ]; - $fields['password_strength'] = [ - 'type' => 'toggle', - 'title' => __('Password Strength Meter', 'ultimate-multisite'), - 'desc' => __('Set this customer as a VIP.', 'ultimate-multisite'), - 'tooltip' => '', - 'value' => 1, - ]; - - $fields['apply_styles'] = [ - 'type' => 'toggle', - 'title' => __('Apply Styles', 'ultimate-multisite'), - 'desc' => __('Set this customer as a VIP.', 'ultimate-multisite'), + $fields['title'] = [ + 'type' => 'text', + 'title' => __('Title', 'ultimate-multisite'), + 'value' => __('Payment Method', 'ultimate-multisite'), + 'desc' => __('Leave blank to hide the title completely.', 'ultimate-multisite'), 'tooltip' => '', - 'value' => 1, ]; return $fields; @@ -133,17 +121,6 @@ public function fields() { /** * The list of keywords for this element. * - * Return an array of strings with keywords describing this - * element. Gutenberg uses this to help customers find blocks. - * - * e.g.: - * return array( - * 'Ultimate Multisite', - * 'Payment Methods', - * 'Form', - * 'Cart', - * ); - * * @since 2.0.0 * @return array */ @@ -153,36 +130,54 @@ public function keywords() { 'WP Ultimo', 'Ultimate Multisite', 'Payment Methods', - 'Form', - 'Cart', + 'Credit Card', + 'Billing', ]; } /** * List of default parameters for the element. * - * If you are planning to add controls using the fields, - * it might be a good idea to use this method to set defaults - * for the parameters you are expecting. - * - * These defaults will be used inside a 'wp_parse_args' call - * before passing the parameters down to the block render - * function and the shortcode render function. - * * @since 2.0.0 * @return array */ public function defaults() { - return []; + return [ + 'title' => __('Payment Method', 'ultimate-multisite'), + ]; } /** - * The content to be output on the screen. + * Runs early on the request lifecycle as soon as we detect the shortcode is present. * - * Should return HTML markup to be used to display the block. - * This method is shared between the block render method and - * the shortcode implementation. + * @since 2.5.0 + * @return void + */ + public function setup(): void { + + $this->membership = WP_Ultimo()->currents->get_membership(); + $this->customer = WP_Ultimo()->currents->get_customer(); + + if ( ! $this->membership) { + $this->set_display(false); + } + } + + /** + * Allows the setup in the context of previews. + * + * @since 2.5.0 + * @return void + */ + public function setup_preview(): void { + + $this->membership = wu_mock_membership(); + $this->customer = wu_mock_customer(); + } + + /** + * The content to be output on the screen. * * @since 2.0.0 * @@ -192,6 +187,24 @@ public function defaults() { */ public function output($atts, $content = null): void { - echo 'lol'; + $gateway_id = $this->membership ? $this->membership->get_gateway() : ''; + $gateway = $gateway_id ? wu_get_gateway($gateway_id) : null; + $payment_info = null; + $change_url = null; + $gateway_display = ''; + + if ($gateway) { + $gateway_display = $gateway->get_title(); + $payment_info = $gateway->get_payment_method_display($this->membership); + $change_url = $gateway->get_change_payment_method_url($this->membership); + } + + $atts['membership'] = $this->membership; + $atts['customer'] = $this->customer; + $atts['gateway_display'] = $gateway_display; + $atts['payment_info'] = $payment_info; + $atts['change_url'] = $change_url; + + wu_get_template('dashboard-widgets/payment-methods', $atts); } } diff --git a/inc/ui/class-site-actions-element.php b/inc/ui/class-site-actions-element.php index dd066ad8..372fbbae 100644 --- a/inc/ui/class-site-actions-element.php +++ b/inc/ui/class-site-actions-element.php @@ -177,7 +177,7 @@ public function fields() { $fields['show_change_payment_method'] = [ 'type' => 'toggle', 'title' => __('Show Change Payment Method', 'ultimate-multisite'), - 'desc' => __('Toggle to show/hide the option to cancel the current payment method.', 'ultimate-multisite'), + 'desc' => __('Toggle to show/hide the option to change the current payment method.', 'ultimate-multisite'), 'tooltip' => '', 'value' => 1, ]; @@ -355,10 +355,10 @@ public function register_forms(): void { ); wu_register_form( - 'cancel_payment_method', + 'change_payment_method', [ - 'render' => [$this, 'render_cancel_payment_method'], - 'handler' => [$this, 'handle_cancel_payment_method'], + 'render' => [$this, 'render_change_payment_method'], + 'handler' => [$this, 'handle_change_payment_method'], 'capability' => 'exist', ] ); @@ -437,18 +437,23 @@ public function get_actions($atts) { $payment_gateway = $this->membership ? $this->membership->get_gateway() : false; if (wu_get_isset($atts, 'show_change_payment_method') && $payment_gateway) { - $actions['cancel_payment_method'] = [ - 'label' => __('Cancel Current Payment Method', 'ultimate-multisite'), - 'icon_classes' => 'dashicons-wu-edit wu-align-middle', - 'classes' => 'wubox', - 'href' => wu_get_form_url( - 'cancel_payment_method', - [ - 'membership' => $this->membership->get_hash(), - 'redirect_url' => wu_get_current_url(), - ] - ), - ]; + $gateway = wu_get_gateway($payment_gateway); + $change_url = $gateway ? $gateway->get_change_payment_method_url($this->membership) : null; + + if ($change_url) { + $actions['change_payment_method'] = [ + 'label' => __('Change Payment Method', 'ultimate-multisite'), + 'icon_classes' => 'dashicons-wu-edit wu-align-middle', + 'classes' => 'wubox', + 'href' => wu_get_form_url( + 'change_payment_method', + [ + 'membership' => $this->membership->get_hash(), + 'redirect_url' => wu_get_current_url(), + ] + ), + ]; + } } return apply_filters('wu_element_get_site_actions', $actions, $atts, $this->site, $this->membership); @@ -999,12 +1004,15 @@ public function handle_change_default_site(): void { } /** - * Renders the cancel payment method modal. + * Renders the change payment method modal. * - * @since 2.1.2 + * Shows a description and a button that redirects the customer + * to the gateway's payment method update page. + * + * @since 2.5.0 * @return void */ - public function render_cancel_payment_method(): void { + public function render_change_payment_method(): void { $membership = wu_get_membership_by_hash(wu_request('membership')); @@ -1016,10 +1024,25 @@ public function render_cancel_payment_method(): void { $customer = wu_get_current_customer(); - if ( ! is_super_admin() && (! $customer || $customer->get_id() !== $membership->get_customer_id())) { + if ( ! $error && ! is_super_admin() && (! $customer || $customer->get_id() !== $membership->get_customer_id())) { $error = __('You are not allowed to do this.', 'ultimate-multisite'); } + $gateway_id = $membership ? $membership->get_gateway() : ''; + $gateway = ! empty($gateway_id) ? wu_get_gateway($gateway_id) : null; + $change_url = $gateway ? $gateway->get_change_payment_method_url($membership) : null; + + if ( ! $error && ! $change_url) { + $error = __('This payment method does not support online changes.', 'ultimate-multisite'); + } + + // Store redirect URL so gateways can redirect back after payment method change. + $redirect_url = wu_request('redirect_url', ''); + + if ($redirect_url) { + update_user_meta(get_current_user_id(), '_wu_change_payment_redirect', esc_url_raw($redirect_url)); + } + if ( ! empty($error)) { $error_field = [ 'error_message' => [ @@ -1029,7 +1052,7 @@ public function render_cancel_payment_method(): void { ]; $form = new \WP_Ultimo\UI\Form( - 'cancel_payment_method', + 'change_payment_method', $error_field, [ 'views' => 'admin-pages/fields', @@ -1043,55 +1066,43 @@ public function render_cancel_payment_method(): void { return; } + $payment_info = $gateway->get_payment_method_display($membership); + + $description = __('You will be redirected to update your payment details.', 'ultimate-multisite'); + + if ($payment_info) { + $description = sprintf( + /* translators: 1: card brand (e.g. Visa), 2: last 4 digits */ + __('Your current payment method is %1$s ending in %2$s.
', 'ultimate-multisite'), + '' . esc_html($payment_info['brand']) . '', + '' . esc_html($payment_info['last4']) . '' + ) . $description; + } + $fields = [ - 'membership' => [ - 'type' => 'hidden', - 'value' => wu_request('membership'), - ], - 'redirect_url' => [ - 'type' => 'hidden', - 'value' => wu_request('redirect_url'), - ], - 'confirm' => [ - 'type' => 'toggle', - 'title' => __('Confirm Payment Method Cancellation', 'ultimate-multisite'), - 'desc' => __('This action can not be undone.', 'ultimate-multisite'), - 'html_attr' => [ - 'v-model' => 'confirmed', - ], - ], - 'wu-when' => [ - 'type' => 'hidden', - 'value' => base64_encode('init'), // phpcs:ignore + 'description' => [ + 'type' => 'note', + 'desc' => $description, ], 'submit_button' => [ - 'type' => 'submit', - 'title' => __('Cancel Payment Method', 'ultimate-multisite'), - 'placeholder' => __('Cancel Payment Method', 'ultimate-multisite'), - 'value' => 'save', - 'classes' => 'button button-primary wu-w-full', + 'type' => 'link', + 'display_value' => __('Change Payment Method', 'ultimate-multisite'), + 'classes' => 'button button-primary wu-w-full wu-text-center', 'wrapper_classes' => 'wu-items-end', 'html_attr' => [ - 'v-bind:disabled' => '!confirmed', + 'href' => $change_url, + 'target' => '_top', ], ], ]; $form = new \WP_Ultimo\UI\Form( - 'cancel_payment_method', + 'change_payment_method', $fields, [ 'views' => 'admin-pages/fields', 'classes' => 'wu-modal-form wu-widget-list wu-striped wu-m-0 wu-mt-0', 'field_wrapper_classes' => 'wu-w-full wu-box-border wu-items-center wu-flex wu-justify-between wu-p-4 wu-m-0 wu-border-t wu-border-l-0 wu-border-r-0 wu-border-b-0 wu-border-gray-300 wu-border-solid', - 'html_attr' => [ - 'data-wu-app' => 'cancel_payment_method', - 'data-state' => wu_convert_to_state( - [ - 'confirmed' => false, - ] - ), - ], ] ); @@ -1099,55 +1110,14 @@ public function render_cancel_payment_method(): void { } /** - * Handles the payment method cancellation. + * Handles the change payment method form. * - * @since 2.1.2 + * This is a no-op since the modal just redirects via a link button. + * + * @since 2.5.0 * @return void */ - public function handle_cancel_payment_method(): void { - - $membership = wu_get_membership_by_hash(wu_request('membership')); - - if ( ! $membership) { - $error = new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); - - wp_send_json_error($error); - - return; - } - - $customer = wu_get_current_customer(); - - if ( ! is_super_admin() && (! $customer || $customer->get_id() !== $membership->get_customer_id())) { - $error = new \WP_Error('error', __('You are not allowed to do this.', 'ultimate-multisite')); - - wp_send_json_error($error); - - return; - } - - $membership->set_gateway(''); - $membership->set_gateway_subscription_id(''); - $membership->set_gateway_customer_id(''); - $membership->set_auto_renew(false); - - $membership->save(); - - $redirect_url = wu_request('redirect_url'); - - $redirect_url = add_query_arg( - [ - 'payment_gateway_cancelled' => true, - ], - $redirect_url ?? user_admin_url() - ); - - wp_send_json_success( - [ - 'redirect_url' => $redirect_url, - ] - ); - } + public function handle_change_payment_method(): void {} /** * Renders the cancel payment method modal. @@ -1330,6 +1300,22 @@ public function handle_cancel_membership(): void { $reason = wu_get_isset($cancellation_options, wu_request('cancellation_reason'), ''); try { + /* + * Cancel the subscription on the payment gateway before + * cancelling the membership. This ensures the external + * subscription (e.g., WooCommerce Subscriptions, Stripe) + * is properly cancelled and won't continue to charge. + */ + $gateway_id = $membership->get_gateway(); + + if ( ! empty($gateway_id)) { + $gateway = wu_get_gateway($gateway_id); + + if ($gateway) { + $gateway->process_cancellation($membership, $customer); + } + } + $membership->cancel($reason); } catch (\Exception $e) { wp_send_json_error( From 27c5b03bbcd742f398b8681a147ad47e7db06c98 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 12:55:08 -0700 Subject: [PATCH 09/33] Add addon setting to top admin nav --- inc/admin-pages/class-top-admin-nav-menu.php | 61 +++++++++++++++----- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/inc/admin-pages/class-top-admin-nav-menu.php b/inc/admin-pages/class-top-admin-nav-menu.php index 84e2ce57..e348ddf5 100644 --- a/inc/admin-pages/class-top-admin-nav-menu.php +++ b/inc/admin-pages/class-top-admin-nav-menu.php @@ -195,31 +195,60 @@ public function add_top_bar_menus($wp_admin_bar): void { */ $settings_tabs = Settings::get_instance()->get_sections(); - $has_addons = false; + $addon_tabs = []; foreach ($settings_tabs as $tab => $tab_info) { if (wu_get_isset($tab_info, 'invisible')) { continue; } - $parent = 'wp-ultimo-settings'; - if (wu_get_isset($tab_info, 'addon', false)) { - $parent = 'wp-ultimo-settings-addons'; + $addon_tabs[ $tab ] = $tab_info; + + continue; } - $settings_tab = [ - 'id' => 'wp-ultimo-settings-' . $tab, - 'parent' => $parent, - 'title' => $tab_info['title'], - 'href' => network_admin_url('admin.php?page=wp-ultimo-settings&tab=') . $tab, - 'meta' => [ - 'class' => 'wp-ultimo-top-menu', - 'title' => __('Go to the settings page', 'ultimate-multisite'), - ], - ]; - - $wp_admin_bar->add_node($settings_tab); + $wp_admin_bar->add_node( + [ + 'id' => 'wp-ultimo-settings-' . $tab, + 'parent' => 'wp-ultimo-settings', + 'title' => $tab_info['title'], + 'href' => network_admin_url('admin.php?page=wp-ultimo-settings&tab=') . $tab, + 'meta' => [ + 'class' => 'wp-ultimo-top-menu', + 'title' => __('Go to the settings page', 'ultimate-multisite'), + ], + ] + ); + } + + if ($addon_tabs) { + $wp_admin_bar->add_node( + [ + 'id' => 'wp-ultimo-settings-addons', + 'parent' => 'wp-ultimo-settings', + 'group' => true, + 'title' => __('Addon Settings', 'ultimate-multisite'), + 'meta' => [ + 'class' => 'ab-sub-secondary', + ], + ] + ); + + foreach ($addon_tabs as $tab => $tab_info) { + $wp_admin_bar->add_node( + [ + 'id' => 'wp-ultimo-settings-' . $tab, + 'parent' => 'wp-ultimo-settings-addons', + 'title' => $tab_info['title'], + 'href' => network_admin_url('admin.php?page=wp-ultimo-settings&tab=') . $tab, + 'meta' => [ + 'class' => 'wp-ultimo-top-menu', + 'title' => __('Go to the settings page', 'ultimate-multisite'), + ], + ] + ); + } } } } From 5183b9f918394eba9a29f0b9a3fba282cdb569f4 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 12:56:44 -0700 Subject: [PATCH 10/33] work with independent billing cycles --- inc/checkout/class-cart.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/inc/checkout/class-cart.php b/inc/checkout/class-cart.php index e684f017..3ecf7996 100644 --- a/inc/checkout/class-cart.php +++ b/inc/checkout/class-cart.php @@ -1696,8 +1696,12 @@ public function add_product($product_id_or_slug, $quantity = 1): bool { * want access this to fetch price variations. */ if (empty($this->duration) || $product->is_recurring() === false) { - $this->duration = $product->get_duration(); - $this->duration_unit = $product->get_duration_unit(); + // Products with independent billing cycles (e.g. domain registrations) + // should not set the cart's duration, as they bill on their own schedule. + if ( ! wu_has_independent_billing_cycle($product->get_type())) { + $this->duration = $product->get_duration(); + $this->duration_unit = $product->get_duration_unit(); + } } if (empty($this->currency)) { From 03b0f6dbe85f5a7c7f850b2d29672b26994e090c Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 13:05:09 -0700 Subject: [PATCH 11/33] work with independent billing cycles --- inc/debug/class-debug.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/inc/debug/class-debug.php b/inc/debug/class-debug.php index edccc972..7af6a987 100644 --- a/inc/debug/class-debug.php +++ b/inc/debug/class-debug.php @@ -573,9 +573,8 @@ public function load(): void { add_filter('wu_tour_finished', '__return_false'); add_action( - 'plugins_loaded', + 'init', function () { - do_action('wp_ultimo_debug'); } ); From 6f6ecfbaba162eff2f86177d510652d388f56030 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 13:08:33 -0700 Subject: [PATCH 12/33] Add types to functions --- inc/functions/helper.php | 63 ++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/inc/functions/helper.php b/inc/functions/helper.php index 409686d7..29bf21a9 100644 --- a/inc/functions/helper.php +++ b/inc/functions/helper.php @@ -18,7 +18,7 @@ * @since 2.0.0 * @return string */ -function wu_get_version() { +function wu_get_version(): string { return class_exists(\WP_Ultimo::class) ? \WP_Ultimo::VERSION : ''; } @@ -29,7 +29,7 @@ function wu_get_version() { * @since 2.0.11 * @return bool */ -function wu_is_debug() { +function wu_is_debug(): bool { return defined('WP_ULTIMO_DEBUG') && WP_ULTIMO_DEBUG; } @@ -40,7 +40,7 @@ function wu_is_debug() { * @since 2.0.0 * @return bool */ -function wu_is_must_use() { +function wu_is_must_use(): bool { return defined('WP_ULTIMO_IS_MUST_USE') && WP_ULTIMO_IS_MUST_USE; } @@ -50,7 +50,7 @@ function wu_is_must_use() { * * If the key is not set, returns the $default parameter. * This function is a helper to serve as a shorthand for the tedious - * and ugly $var = isset($array['key'])) ? $array['key'] : $default. + * and ugly $var = isset($array['key']) ? $array['key'] : $default. * Using this, that same line becomes wu_get_isset($array, 'key', $default); * * Since PHP 7.4, this can be replaced by the null-coalesce operator (??) @@ -61,9 +61,10 @@ function wu_is_must_use() { * @param array|object $array_or_obj Array or object to check key. * @param string $key Key to check. * @param mixed $default_value Default value, if the key is not set. + * * @return mixed */ -function wu_get_isset($array_or_obj, $key, $default_value = false) { +function wu_get_isset($array_or_obj, string $key, $default_value = false) { if ( ! is_array($array_or_obj)) { $array_or_obj = (array) $array_or_obj; @@ -75,10 +76,11 @@ function wu_get_isset($array_or_obj, $key, $default_value = false) { /** * Returns the main site id for the network. * - * @since 2.0.0 * @return int + * @throws Runtime_Exception If ms_loaded did not happen. + * @since 2.0.0 */ -function wu_get_main_site_id() { +function wu_get_main_site_id(): int { _wu_require_hook('ms_loaded'); @@ -88,11 +90,12 @@ function wu_get_main_site_id() { /** * This function return 'slugfied' options terms to be used as options ids. * - * @since 0.0.1 * @param string $term Returns a string based on the term and this plugin slug. + * * @return string + * @since 0.0.1 */ -function wu_slugify($term) { +function wu_slugify(string $term): string { return "wp-ultimo_$term"; } @@ -100,10 +103,11 @@ function wu_slugify($term) { /** * Returns the full path to the plugin folder. * - * @since 2.0.11 * @param string $dir Path relative to the plugin root you want to access. + * + * @since 2.0.11 */ -function wu_path($dir): string { +function wu_path(string $dir): string { return WP_ULTIMO_PLUGIN_DIR . $dir; } @@ -111,11 +115,12 @@ function wu_path($dir): string { /** * Returns the URL to the plugin folder. * - * @since 2.0.11 * @param string $dir Path relative to the plugin root you want to access. + * * @return string + * @since 2.0.11 */ -function wu_url($dir) { +function wu_url(string $dir): string { return apply_filters('wp_ultimo_url', WP_ULTIMO_PLUGIN_URL . $dir); } @@ -123,13 +128,13 @@ function wu_url($dir) { /** * Shorthand to retrieving variables from $_GET, $_POST and $_REQUEST; * - * @since 2.0.0 - * * @param string $key Key to retrieve. * @param mixed $default_value Default value, when the variable is not available. + * * @return mixed + * @since 2.0.0 */ -function wu_request($key, $default_value = false) { +function wu_request(string $key, $default_value = false) { $value = isset($_REQUEST[ $key ]) ? wu_clean(stripslashes_deep($_REQUEST[ $key ])) : $default_value; // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized @@ -139,13 +144,13 @@ function wu_request($key, $default_value = false) { /** * Throws an exception if a given hook was not yet run. * - * @since 2.0.11 - * * @param string $hook The hook to check. Defaults to 'ms_loaded'. - * @throws Runtime_Exception When the hook has not yet run. + * * @return void + * @throws Runtime_Exception When the hook has not yet run. + * @since 2.0.11 */ -function _wu_require_hook($hook = 'ms_loaded') { // phpcs:ignore +function _wu_require_hook(string $hook = 'ms_loaded'): void { // phpcs:ignore if ( ! did_action($hook)) { $message = "This function can not yet be run as it relies on processing that happens on hook {$hook}."; @@ -163,7 +168,7 @@ function _wu_require_hook($hook = 'ms_loaded') { // phpcs:ignore * @since 2.0.11 * @return boolean */ -function wu_are_code_comments_available() { +function wu_are_code_comments_available(): bool { static $res; @@ -198,14 +203,14 @@ function wu_path_join(...$parts): string { /** * Add a log entry to chosen file. * - * @since 2.0.0 - * * @param string $handle Name of the log file to write to. * @param string|\WP_Error $message Log message to write. * @param string $log_level Log level to write. + * * @return void + * @since 2.0.0 */ -function wu_log_add($handle, $message, $log_level = LogLevel::INFO) { +function wu_log_add(string $handle, $message, string $log_level = LogLevel::INFO): void { \WP_Ultimo\Logger::add($handle, $message, $log_level); } @@ -218,7 +223,7 @@ function wu_log_add($handle, $message, $log_level = LogLevel::INFO) { * @param mixed $handle Name of the log file to clear. * @return void */ -function wu_log_clear($handle) { +function wu_log_clear($handle): void { \WP_Ultimo\Logger::clear($handle); } @@ -231,7 +236,7 @@ function wu_log_clear($handle) { * @param \Throwable $e The exception object. * @return void */ -function wu_maybe_log_error($e) { +function wu_maybe_log_error($e): void { if (defined('WP_DEBUG') && WP_DEBUG) { error_log($e); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log @@ -241,12 +246,12 @@ function wu_maybe_log_error($e) { /** * Get the function caller. * - * @since 2.0.0 - * * @param integer $depth The depth of the backtrace. + * * @return string|null + * @since 2.0.0 */ -function wu_get_function_caller($depth = 1) { +function wu_get_function_caller(int $depth = 1): ?string { $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $depth + 1); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace From fa4b9289d55f7c7cd470be58cf40a9168b2ca6b2 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 13:26:24 -0700 Subject: [PATCH 13/33] Fix stripe creating subscriptions --- .../class-stripe-checkout-gateway.php | 121 ++++++++++++------ 1 file changed, 81 insertions(+), 40 deletions(-) diff --git a/inc/gateways/class-stripe-checkout-gateway.php b/inc/gateways/class-stripe-checkout-gateway.php index ce857bf1..36837de1 100644 --- a/inc/gateways/class-stripe-checkout-gateway.php +++ b/inc/gateways/class-stripe-checkout-gateway.php @@ -11,9 +11,9 @@ namespace WP_Ultimo\Gateways; +use Stripe\Exception\ApiErrorException; use Stripe\PaymentMethod; -use Stripe; -use WP_Ultimo\Checkout\Cart; +use WP_Ultimo\Exception\Runtime_Exception; // Exit if accessed directly defined('ABSPATH') || exit; @@ -206,8 +206,10 @@ public function settings(): void { * intents for Stripe to make the experience more * streamlined. * + * @return \WP_Error|array + * @throws Runtime_Exception Something happened creating the client. + * @throws ApiErrorException Something bag happened sending an API request. * @since 2.0.0 - * @return void|array */ public function run_preflight() { @@ -293,27 +295,92 @@ public function run_preflight() { 'metadata' => $metadata, ]; - if ($this->order->should_auto_renew()) { - $stripe_cart = $this->build_stripe_cart($this->order); + if ($this->order->should_auto_renew() && $this->order->has_recurring()) { + $subscription_data['mode'] = 'subscription'; + + $stripe_cart = $this->build_stripe_cart($this->order); + + if (is_wp_error($stripe_cart)) { + return $stripe_cart; + } + + /* + * In subscription mode, recurring items go in line_items + * with their plan/price IDs. The deprecated subscription_data[items] + * parameter has been replaced by line_items. + */ + $line_items = []; + + foreach (array_values($stripe_cart) as $plan_data) { + $item = [ + 'price' => $plan_data['plan'], + 'quantity' => 1, + ]; + + if ( ! empty($plan_data['tax_rates'])) { + $item['tax_rates'] = $plan_data['tax_rates']; + } + + $line_items[] = $item; + } + + /* + * Add non-recurring items (setup fees, etc.) + */ $stripe_non_recurring_cart = $this->build_non_recurring_cart($this->order); + $line_items = array_merge($line_items, $stripe_non_recurring_cart); + + $subscription_data['line_items'] = $line_items; + $subscription_data['subscription_data'] = []; + + /** + * If its a downgrade, we need to set as a trial, + * billing_cycle_anchor isn't supported by Checkout. + * (https://stripe.com/docs/api/checkout/sessions/create) + */ + if ($this->order->get_cart_type() === 'downgrade') { + $next_charge = $this->order->get_billing_next_charge_date(); + $next_charge_date = \DateTime::createFromFormat('U', $next_charge); + $current_time = new \DateTime(); + + if ($current_time < $next_charge_date) { + + // The `trial_end` date has to be at least 2 days in the future. + $next_charge = $next_charge_date->diff($current_time)->days > 2 ? $next_charge : strtotime('+2 days'); + + $subscription_data['subscription_data']['trial_end'] = $next_charge; + } + } + /* - * Adds recurring stuff. + * Handle trial periods. */ - $subscription_data['subscription_data'] = [ - 'items' => array_values($stripe_cart), - ]; + if ($this->order->has_trial()) { + $subscription_data['subscription_data']['trial_end'] = $this->order->get_billing_start_date(); + } } else { + $subscription_data['mode'] = 'payment'; + /* * Create non-recurring only cart. */ $stripe_non_recurring_cart = $this->build_non_recurring_cart($this->order, true); - } - /* - * Add non-recurring line items - */ - $subscription_data['line_items'] = $stripe_non_recurring_cart; + /* + * If there are no payable line items (e.g. $0 checkout with 100% coupon), + * Stripe Checkout cannot process this. Return an error so the + * checkout falls back gracefully. + */ + if (empty($stripe_non_recurring_cart)) { + return new \WP_Error( + 'stripe-checkout-no-items', + __('No payable items for Stripe Checkout. The order total may be zero.', 'ultimate-multisite') + ); + } + + $subscription_data['line_items'] = $stripe_non_recurring_cart; + } /** * If we have pro-rata credit (in case of an upgrade, for example) @@ -327,32 +394,6 @@ public function run_preflight() { ]; } - /** - * If its a downgrade, we need to set as a trial, - * billing_cycle_anchor isn't supported by Checkout. - * (https://stripe.com/docs/api/checkout/sessions/create) - */ - if ($this->order->get_cart_type() === 'downgrade') { - $next_charge = $this->order->get_billing_next_charge_date(); - $next_charge_date = \DateTime::createFromFormat('U', $next_charge); - $current_time = new \DateTime(); - - if ($current_time < $next_charge_date) { - - // The `trial_end` date has to be at least 2 days in the future. - $next_charge = $next_charge_date->diff($current_time)->days > 2 ? $next_charge : strtotime('+2 days'); - - $subscription_data['subscription_data']['trial_end'] = $next_charge; - } - } - - /* - * Handle trial periods. - */ - if ($this->order->has_trial() && $this->order->has_recurring()) { - $subscription_data['subscription_data']['trial_end'] = $this->order->get_billing_start_date(); - } - $session = $this->get_stripe_client()->checkout->sessions->create(apply_filters('wu_stripe_checkout_subscription_data', $subscription_data, $this)); // Add the client secret to the JSON success data. From 3d24d3716f5661a3b46ab4184546e2fd973f3592 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 13:27:21 -0700 Subject: [PATCH 14/33] fix validation --- inc/helpers/validation-rules/class-exists.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/inc/helpers/validation-rules/class-exists.php b/inc/helpers/validation-rules/class-exists.php index 788ee2ef..e3877778 100644 --- a/inc/helpers/validation-rules/class-exists.php +++ b/inc/helpers/validation-rules/class-exists.php @@ -53,6 +53,11 @@ public function check($value): bool { ] ); + // Allow 0/empty as "no association" for optional foreign keys. + if (empty($value)) { + return true; + } + $column = $this->parameter('column'); $model = $this->parameter('model'); From 50cd575ba413d5e8e38f5be79a32ab6ada654e97 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 13:30:04 -0700 Subject: [PATCH 15/33] fix network install with some plugins --- .../class-multisite-network-installer.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/inc/installers/class-multisite-network-installer.php b/inc/installers/class-multisite-network-installer.php index a5ea01b6..14c8fa79 100644 --- a/inc/installers/class-multisite-network-installer.php +++ b/inc/installers/class-multisite-network-installer.php @@ -173,10 +173,23 @@ public function _install_create_network(): void { // phpcs:ignore PSR2.Methods.M } // On a single-site install, $wpdb doesn't have multisite table names set. - foreach ($wpdb->ms_global_tables as $table) { + // Explicitly set the core WordPress multisite tables that install_network() + // and wp_get_db_schema('global') reference via $wpdb->tablename interpolation. + // We cannot rely solely on $wpdb->ms_global_tables because plugins may have + // appended custom entries while the core defaults could be missing. + $wp_ms_tables = ['blogs', 'blogmeta', 'signups', 'site', 'sitemeta', 'registration_log']; + + foreach ($wp_ms_tables as $table) { $wpdb->$table = $wpdb->base_prefix . $table; } + // Also set any additional tables registered in ms_global_tables (e.g. by addons). + foreach ($wpdb->ms_global_tables as $table) { + if (! isset($wpdb->$table)) { + $wpdb->$table = $wpdb->base_prefix . $table; + } + } + install_network(); $result = populate_network( From a777472f7379595c110648a4d5beafe5eb47980d Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 13:30:59 -0700 Subject: [PATCH 16/33] Add required actions for domain seller --- inc/list-tables/class-domain-list-table.php | 10 +++ inc/loaders/class-table-loader.php | 13 +++- views/dashboard-widgets/domain-mapping.php | 79 +++++++++++++++------ 3 files changed, 79 insertions(+), 23 deletions(-) diff --git a/inc/list-tables/class-domain-list-table.php b/inc/list-tables/class-domain-list-table.php index 199105ba..da67c14b 100644 --- a/inc/list-tables/class-domain-list-table.php +++ b/inc/list-tables/class-domain-list-table.php @@ -84,6 +84,16 @@ public function column_domain($item): string { $html = "{$domain}"; + /** + * Filters the HTML output for the domain column. + * + * @since 2.4.0 + * + * @param string $html The column HTML. + * @param \WP_Ultimo\Models\Domain $item The domain object. + */ + $html = apply_filters('wu_domain_list_column_domain', $html, $item); + $actions = [ 'edit' => sprintf('%s', wu_network_admin_url('wp-ultimo-edit-domain', $url_atts), __('Edit', 'ultimate-multisite')), 'delete' => sprintf('%s', __('Delete', 'ultimate-multisite'), wu_get_form_url('delete_modal', $url_atts), __('Delete', 'ultimate-multisite')), diff --git a/inc/loaders/class-table-loader.php b/inc/loaders/class-table-loader.php index f31a3cd4..0fef8f10 100644 --- a/inc/loaders/class-table-loader.php +++ b/inc/loaders/class-table-loader.php @@ -33,6 +33,14 @@ class Table_Loader { */ public $domain_table; + /** + * The Domain Meta Table + * + * @since 2.4.0 + * @var \WP_Ultimo\Database\Domains\Domains_Meta_Table + */ + public $domainmeta_table; + /** * The Products Table * @@ -185,9 +193,10 @@ class Table_Loader { */ public function init(): void { /** - * Loads the Domain Mappings Table + * Loads the Domain Mappings (and Meta) Tables */ - $this->domain_table = new \WP_Ultimo\Database\Domains\Domains_Table(); + $this->domain_table = new \WP_Ultimo\Database\Domains\Domains_Table(); + $this->domainmeta_table = new \WP_Ultimo\Database\Domains\Domains_Meta_Table(); /** * Loads the Products (and Meta) Tables diff --git a/views/dashboard-widgets/domain-mapping.php b/views/dashboard-widgets/domain-mapping.php index bceb56f9..dd9e7773 100644 --- a/views/dashboard-widgets/domain-mapping.php +++ b/views/dashboard-widgets/domain-mapping.php @@ -32,6 +32,17 @@ + +

@@ -86,6 +97,52 @@ 'url' => $domain['delete_link'], ]; + /** + * Filters the action links for a domain row in the domain mapping widget. + * + * Allows addons to add extra actions (e.g. Manage DNS, Renew) for domain rows. + * + * @since 2.4.0 + * + * @param array $second_row_actions The action items for the row. + * @param object $item The domain object. + */ + $second_row_actions = apply_filters('wu_domain_mapping_row_actions', $second_row_actions, $item); + + $first_row = [ + 'primary' => [ + 'wrapper_classes' => $item->is_primary_domain() ? 'wu-text-blue-600' : '', + 'icon' => $item->is_primary_domain() ? 'dashicons-wu-filter_1 wu-align-text-bottom wu-mr-1' : 'dashicons-wu-plus-square wu-align-text-bottom wu-mr-1', + 'label' => '', + 'value' => function () use ($item) { + if ($item->is_primary_domain()) { + esc_html_e('Primary', 'ultimate-multisite'); + wu_tooltip(__('All other mapped domains will redirect to the primary domain.', 'ultimate-multisite'), 'dashicons-editor-help wu-align-middle wu-ml-1'); + } else { + esc_html_e('Alias', 'ultimate-multisite'); + } + }, + ], + 'secure' => [ + 'wrapper_classes' => $item->is_secure() ? 'wu-text-green-500' : '', + 'icon' => $item->is_secure() ? 'dashicons-wu-lock1 wu-align-text-bottom wu-mr-1' : 'dashicons-wu-lock1 wu-align-text-bottom wu-mr-1', + 'label' => '', + 'value' => $item->is_secure() ? __('Secure (HTTPS)', 'ultimate-multisite') : __('Not Secure (HTTP)', 'ultimate-multisite'), + ], + ]; + + /** + * Filters the info columns for a domain row in the domain mapping widget. + * + * Allows addons to add extra info (e.g. expiry date) for domain rows. + * + * @since 2.4.0 + * + * @param array $first_row The info columns for the row. + * @param object $item The domain object. + */ + $first_row = apply_filters('wu_domain_mapping_row_info', $first_row, $item); + wu_responsive_table_row( [ 'id' => false, @@ -93,27 +150,7 @@ 'url' => false, 'status' => $status, ], - [ - 'primary' => [ - 'wrapper_classes' => $item->is_primary_domain() ? 'wu-text-blue-600' : '', - 'icon' => $item->is_primary_domain() ? 'dashicons-wu-filter_1 wu-align-text-bottom wu-mr-1' : 'dashicons-wu-plus-square wu-align-text-bottom wu-mr-1', - 'label' => '', - 'value' => function () use ($item) { - if ($item->is_primary_domain()) { - esc_html_e('Primary', 'ultimate-multisite'); - wu_tooltip(__('All other mapped domains will redirect to the primary domain.', 'ultimate-multisite'), 'dashicons-editor-help wu-align-middle wu-ml-1'); - } else { - esc_html_e('Alias', 'ultimate-multisite'); - } - }, - ], - 'secure' => [ - 'wrapper_classes' => $item->is_secure() ? 'wu-text-green-500' : '', - 'icon' => $item->is_secure() ? 'dashicons-wu-lock1 wu-align-text-bottom wu-mr-1' : 'dashicons-wu-lock1 wu-align-text-bottom wu-mr-1', - 'label' => '', - 'value' => $item->is_secure() ? __('Secure (HTTPS)', 'ultimate-multisite') : __('Not Secure (HTTP)', 'ultimate-multisite'), - ], - ], + $first_row, $second_row_actions ); From bb0a5875122a3d0366006830bf74796dce26efef Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 13:33:26 -0700 Subject: [PATCH 17/33] Send email when sub renew fails --- inc/class-cron.php | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/inc/class-cron.php b/inc/class-cron.php index 7262b2ed..69dc7058 100644 --- a/inc/class-cron.php +++ b/inc/class-cron.php @@ -223,14 +223,14 @@ public function membership_trial_check(): void { * * @param int $membership_id The membership id. * @param bool $trial If the membership was in a trial state before. - * @return \WP_Error|bool + * @return void */ public function async_create_renewal_payment($membership_id, $trial = false) { $membership = wu_get_membership($membership_id); if (empty($membership)) { - return false; + return; } /* @@ -272,10 +272,8 @@ public function async_create_renewal_payment($membership_id, $trial = false) { wu_do_event('renewal_payment_created', $payload); - return $saved; + return; } - - return true; } /** @@ -332,14 +330,14 @@ public function membership_expired_check(): void { * @since 2.0.0 * * @param int $membership_id The membership ID. - * @return \WP_Error|true + * @return void */ public function async_mark_membership_as_expired($membership_id) { $membership = wu_get_membership($membership_id); if (empty($membership)) { - return false; + return; } /* @@ -354,6 +352,19 @@ public function async_mark_membership_as_expired($membership_id) { */ $membership->set_skip_validation(true); - return $membership->save(); + $result = $membership->save(); + + if ( ! is_wp_error($result) && $result) { + $customer = $membership->get_customer(); + + if ($customer) { + $payload = array_merge( + wu_generate_event_payload('membership', $membership), + wu_generate_event_payload('customer', $customer) + ); + + wu_do_event('membership_expired', $payload); + } + } } } From 2f26901ce47cbfd8da0e39355939bbc3b946106b Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 13:34:01 -0700 Subject: [PATCH 18/33] fix warnings with new psr --- composer.json | 3 +++ composer.lock | 2 +- patches/mpdf-psr-log-aware-trait-void-return.patch | 11 +++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 patches/mpdf-psr-log-aware-trait-void-return.patch diff --git a/composer.json b/composer.json index 54bbed26..0e0ca3be 100644 --- a/composer.json +++ b/composer.json @@ -161,6 +161,9 @@ ], "jasny/sso": [ "patches/jasny-sso-src-broker-cookies-php.patch" + ], + "mpdf/psr-log-aware-trait": [ + "patches/mpdf-psr-log-aware-trait-void-return.patch" ] }, "installer-paths": { diff --git a/composer.lock b/composer.lock index 9ea21a54..104940fa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4bb21993bc3865c098634b155018ca44", + "content-hash": "e9293151777f05f8193efea6fbb7d289", "packages": [ { "name": "amphp/amp", diff --git a/patches/mpdf-psr-log-aware-trait-void-return.patch b/patches/mpdf-psr-log-aware-trait-void-return.patch new file mode 100644 index 00000000..20d1672b --- /dev/null +++ b/patches/mpdf-psr-log-aware-trait-void-return.patch @@ -0,0 +1,11 @@ +--- /dev/null ++++ ../src/MpdfPsrLogAwareTrait.php +@@ -12,7 +12,7 @@ + */ + protected $logger; + +- public function setLogger(LoggerInterface $logger) ++ public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + if (property_exists($this, 'services') && is_array($this->services)) { \ No newline at end of file From fdb9c743a757bc7752c2008b79e3e0ee5631ad0b Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 15:06:11 -0700 Subject: [PATCH 19/33] Add meta data for domains --- .../domains/class-domains-meta-table.php | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 inc/database/domains/class-domains-meta-table.php diff --git a/inc/database/domains/class-domains-meta-table.php b/inc/database/domains/class-domains-meta-table.php new file mode 100644 index 00000000..463c1534 --- /dev/null +++ b/inc/database/domains/class-domains-meta-table.php @@ -0,0 +1,67 @@ +schema = "meta_id bigint(20) unsigned NOT NULL auto_increment, + wu_domain_id bigint(20) unsigned NOT NULL default '0', + meta_key varchar(255) DEFAULT NULL, + meta_value longtext DEFAULT NULL, + PRIMARY KEY (meta_id), + KEY wu_domain_id (wu_domain_id), + KEY meta_key (meta_key({$max_index_length}))"; + } +} From 8ab52ec1893a412d836222ab622449b3ddcc0b67 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 15:06:22 -0700 Subject: [PATCH 20/33] Add new element --- inc/class-wp-ultimo.php | 1 + 1 file changed, 1 insertion(+) diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 4dadf897..352947a5 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -511,6 +511,7 @@ protected function load_extra_components(): void { \WP_Ultimo\UI\Current_Membership_Element::get_instance(); \WP_Ultimo\UI\Billing_Info_Element::get_instance(); \WP_Ultimo\UI\Invoices_Element::get_instance(); + \WP_Ultimo\UI\Payment_Methods_Element::get_instance(); \WP_Ultimo\UI\Site_Actions_Element::get_instance(); \WP_Ultimo\UI\Account_Summary_Element::get_instance(); From acde7d2e9b41ddfd589150acee16cc0f144f94b0 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 15:06:40 -0700 Subject: [PATCH 21/33] add new email for sub expire/failed --- views/emails/admin/membership-expired.php | 71 ++++++++++++++++++ views/emails/admin/payment-failed.php | 75 ++++++++++++++++++++ views/emails/customer/membership-expired.php | 37 ++++++++++ views/emails/customer/payment-failed.php | 37 ++++++++++ 4 files changed, 220 insertions(+) create mode 100644 views/emails/admin/membership-expired.php create mode 100644 views/emails/admin/payment-failed.php create mode 100644 views/emails/customer/membership-expired.php create mode 100644 views/emails/customer/payment-failed.php diff --git a/views/emails/admin/membership-expired.php b/views/emails/admin/membership-expired.php new file mode 100644 index 00000000..1f876909 --- /dev/null +++ b/views/emails/admin/membership-expired.php @@ -0,0 +1,71 @@ + +

+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + +
+ {{membership_description}} +
+ {{membership_id}} +
+ {{membership_reference_code}} +
{{membership_date_expiration}}
+ +
+ +

+ + + + + + + + + + + + + + + + +
+ {{customer_name}} +
+ {{customer_user_email}} +
+ +
diff --git a/views/emails/admin/payment-failed.php b/views/emails/admin/payment-failed.php new file mode 100644 index 00000000..1e98de66 --- /dev/null +++ b/views/emails/admin/payment-failed.php @@ -0,0 +1,75 @@ + +

+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{membership_description}} +
+ {{membership_id}} +
+ {{membership_reference_code}} +
{{payment_gateway}}
{{membership_date_expiration}}
+ +
+ +

+ + + + + + + + + + + + + + + + +
+ {{customer_name}} +
+ {{customer_user_email}} +
+ +
diff --git a/views/emails/customer/membership-expired.php b/views/emails/customer/membership-expired.php new file mode 100644 index 00000000..9286b6a0 --- /dev/null +++ b/views/emails/customer/membership-expired.php @@ -0,0 +1,37 @@ + + +

+ +

+ +

+ +

+ + + + + + + + + + + + + + + + +
+ {{membership_description}} +
+ {{membership_reference_code}} +
{{membership_date_expiration}}
diff --git a/views/emails/customer/payment-failed.php b/views/emails/customer/payment-failed.php new file mode 100644 index 00000000..bdc53546 --- /dev/null +++ b/views/emails/customer/payment-failed.php @@ -0,0 +1,37 @@ + + +

+ +

+ +

+ +

+ +

+ + + + + + + + + + + + + + + + +
+ {{membership_description}} +
{{membership_date_expiration}}
{{payment_gateway}}
From af00716dfde606273061826c7f49e0abafa2fe59 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 15:06:48 -0700 Subject: [PATCH 22/33] more tests --- .../Payment_List_Admin_Page_Test.php | 2 +- tests/WP_Ultimo/Update_Check_Test.php | 261 ++++++++++++++++++ 2 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 tests/WP_Ultimo/Update_Check_Test.php diff --git a/tests/WP_Ultimo/Admin_Pages/Payment_List_Admin_Page_Test.php b/tests/WP_Ultimo/Admin_Pages/Payment_List_Admin_Page_Test.php index 0d7a0d59..95d9f556 100644 --- a/tests/WP_Ultimo/Admin_Pages/Payment_List_Admin_Page_Test.php +++ b/tests/WP_Ultimo/Admin_Pages/Payment_List_Admin_Page_Test.php @@ -97,7 +97,7 @@ public function test_action_links(): void { $links = $this->page->action_links(); $this->assertIsArray($links); - $this->assertCount(1, $links); + $this->assertCount(2, $links); } /** diff --git a/tests/WP_Ultimo/Update_Check_Test.php b/tests/WP_Ultimo/Update_Check_Test.php new file mode 100644 index 00000000..1795a96b --- /dev/null +++ b/tests/WP_Ultimo/Update_Check_Test.php @@ -0,0 +1,261 @@ +plugin_file); + + $this->assertEmpty( + $plugin_data['UpdateURI'], + 'Plugin must NOT set Update URI header. Setting it to a non-WordPress.org URL would prevent WordPress.org update checks and active install tracking.' + ); + } + + /** + * Test that the plugin text domain matches the expected WordPress.org slug. + */ + public function test_text_domain_matches_slug(): void { + + $plugin_data = get_plugin_data(WP_PLUGIN_DIR . '/' . $this->plugin_file); + + $this->assertSame( + 'ultimate-multisite', + $plugin_data['TextDomain'], + 'Text domain must match the WordPress.org slug.' + ); + } + + /** + * Test that the plugin directory name matches the WordPress.org slug. + * + * The directory name (first segment of the plugin basename) must match + * the WordPress.org SVN slug for update checks to work. + */ + public function test_plugin_directory_matches_slug(): void { + + $dir = dirname($this->plugin_file); + + $this->assertSame('ultimate-multisite', $dir); + } + + /** + * Test that the plugin appears in the data WordPress sends to api.wordpress.org. + * + * This simulates what wp_update_plugins() builds as the request body. + * The plugin must be present in the plugins array for WordPress.org to + * count it as an active install. + */ + public function test_plugin_included_in_update_check_payload(): void { + + $plugins = get_plugins(); + + $this->assertArrayHasKey( + $this->plugin_file, + $plugins, + 'Plugin must be discoverable by get_plugins().' + ); + + // Simulate the Update URI filtering that wp_update_plugins() performs. + $plugin_data = $plugins[ $this->plugin_file ]; + $update_uri = $plugin_data['UpdateURI'] ?? ''; + + if ($update_uri) { + $hostname = wp_parse_url($update_uri, PHP_URL_HOST); + $excluded = $hostname && ! in_array($hostname, ['wordpress.org', 'w.org'], true); + } else { + $excluded = false; + } + + $this->assertFalse( + $excluded, + 'Plugin must NOT be excluded from WordPress.org update checks by Update URI header.' + ); + } + + /** + * Test that http_request_args filters do not block api.wordpress.org requests. + * + * Runs all registered http_request_args filters against a simulated + * WordPress.org update check URL and verifies the request is not blocked. + */ + public function test_http_request_args_do_not_block_wporg(): void { + + $url = 'https://api.wordpress.org/plugins/update-check/1.1/'; + $args = [ + 'timeout' => 30, + 'body' => [ + 'plugins' => wp_json_encode([ + 'plugins' => [ + $this->plugin_file => [ + 'Name' => 'Ultimate Multisite', + 'Version' => '2.4.11', + ], + ], + 'active' => [ $this->plugin_file ], + ]), + ], + ]; + + $filtered_args = apply_filters('http_request_args', $args, $url); + + // The request args should not be fundamentally changed + $this->assertIsArray($filtered_args, 'http_request_args must return an array.'); + $this->assertArrayHasKey('body', $filtered_args, 'Request body must still exist after filtering.'); + + // Verify the plugins data is still intact + $body = $filtered_args['body']; + $plugins = json_decode($body['plugins'], true); + + $this->assertArrayHasKey( + $this->plugin_file, + $plugins['plugins'], + 'Plugin must still be in the request body after http_request_args filters.' + ); + } + + /** + * Test that pre_http_request filters do not block api.wordpress.org requests. + * + * The pre_http_request filter can short-circuit HTTP requests. We verify + * that no filter blocks WordPress.org update check requests. + */ + public function test_pre_http_request_does_not_block_wporg(): void { + + $url = 'https://api.wordpress.org/plugins/update-check/1.1/'; + $args = [ + 'timeout' => 30, + 'body' => [], + ]; + + $result = apply_filters('pre_http_request', false, $args, $url); + + $this->assertFalse( + $result, + 'pre_http_request must return false for api.wordpress.org requests, allowing them to proceed. A non-false return would block the update check.' + ); + } + + /** + * Test that the beta update filter does not remove WordPress.org data. + * + * The maybe_inject_beta_update method on site_transient_update_plugins + * should only ADD beta data when enabled, not remove WordPress.org entries. + * When beta updates are disabled (the default), the transient should pass + * through unchanged. + */ + public function test_beta_update_filter_does_not_remove_wporg_data(): void { + + // Create a mock transient with WordPress.org data + $transient = new \stdClass(); + $transient->last_checked = time(); + $transient->checked = [ $this->plugin_file => '2.4.10' ]; + $transient->response = []; + $transient->no_update = []; + $transient->no_update[ $this->plugin_file ] = (object) [ + 'id' => 'w.org/plugins/ultimate-multisite', + 'slug' => 'ultimate-multisite', + 'plugin' => $this->plugin_file, + 'new_version' => '2.4.10', + 'url' => 'https://wordpress.org/plugins/ultimate-multisite/', + 'package' => 'https://downloads.wordpress.org/plugin/ultimate-multisite.2.4.10.zip', + ]; + + // Ensure beta updates are disabled (the default) + wu_save_setting('enable_beta_updates', false); + + $filtered = apply_filters('site_transient_update_plugins', $transient); + + $this->assertIsObject($filtered, 'Transient must remain an object after filtering.'); + $this->assertArrayHasKey( + $this->plugin_file, + (array) $filtered->checked, + 'Plugin must remain in the checked array.' + ); + + // When WordPress.org says there's an update, verify it's preserved + $transient_with_update = clone $transient; + unset($transient_with_update->no_update[ $this->plugin_file ]); + $transient_with_update->response[ $this->plugin_file ] = (object) [ + 'id' => 'w.org/plugins/ultimate-multisite', + 'slug' => 'ultimate-multisite', + 'plugin' => $this->plugin_file, + 'new_version' => '2.4.12', + 'url' => 'https://wordpress.org/plugins/ultimate-multisite/', + 'package' => 'https://downloads.wordpress.org/plugin/ultimate-multisite.2.4.12.zip', + ]; + + $filtered2 = apply_filters('site_transient_update_plugins', $transient_with_update); + + $this->assertArrayHasKey( + $this->plugin_file, + (array) $filtered2->response, + 'WordPress.org update response must not be removed by filters when beta updates are disabled.' + ); + + $this->assertSame( + '2.4.12', + $filtered2->response[ $this->plugin_file ]->new_version, + 'WordPress.org update version must be preserved when beta updates are disabled.' + ); + } + + /** + * Test that no update_plugins_{hostname} filter hijacks our plugin. + * + * WordPress 5.8+ fires update_plugins_{hostname} for plugins with a custom + * Update URI. Since our plugin has no Update URI, this filter should not + * exist for our hostname. + */ + public function test_no_custom_update_hostname_filter(): void { + + global $wp_filter; + + // Check there's no filter for ultimatemultisite.com that could intercept core plugin updates + $filter_name = 'update_plugins_ultimatemultisite.com'; + + $has_filter = isset($wp_filter[ $filter_name ]) && $wp_filter[ $filter_name ]->has_filters(); + + $this->assertFalse( + $has_filter, + "Filter '$filter_name' should not be registered. This would only apply if the plugin had Update URI set to ultimatemultisite.com." + ); + } +} From ebbb08c12dd89940a841f11dfac7142cecdfab7a Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 15:08:00 -0700 Subject: [PATCH 23/33] preserve order --- views/settings/fields/field-select2.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/views/settings/fields/field-select2.php b/views/settings/fields/field-select2.php index 24a6003c..c3e4fe7d 100644 --- a/views/settings/fields/field-select2.php +++ b/views/settings/fields/field-select2.php @@ -22,8 +22,18 @@ From 05c495622452a7f3807d749ac919947a626d7a3f Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 15:08:27 -0700 Subject: [PATCH 24/33] Update --- readme.txt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/readme.txt b/readme.txt index 66a8aacd..10fadf8b 100644 --- a/readme.txt +++ b/readme.txt @@ -1,7 +1,7 @@ === Ultimate Multisite – WordPress Multisite SaaS & WaaS Platform === Contributors: aanduque, superdav42, vvwb, surferking Donate link: https://github.com/sponsors/superdav42/ -Tags: ultimate multisite, wordpress multisite, multisite plugin, multisite saas, waas, domain mapping, wp ultimo +Tags: multisite, domain mapping, wordpress multisite, multisite saas, waas Requires at least: 5.3 Requires PHP: 7.4.30 Tested up to: 6.9 @@ -9,7 +9,7 @@ Stable tag: 2.4.11 License: GPLv2 License URI: http://www.gnu.org/licenses/gpl-2.0.html -Ultimate Multisite is a WordPress Multisite plugin that turns your network into a complete Website-as-a-Service (WaaS) platform with subscriptions, site provisioning, domain mapping, and customer management. +Ultimate Multisite turns your WordPress network into a WaaS platform with subscriptions, site provisioning, and domain mapping. == Description == @@ -225,6 +225,9 @@ Data collected includes: No personal data, domains, IP addresses, or payment information are collected. == Changelog == +Version [2.4.11] - Released on 2026-XX-XX +- Fix: %2F being striped breaking some WC Urls. +- Improved: Support for changing payment methods of the membership. Version [2.4.11] - Released on 2026-02-16 - New: Settings API for remote settings management. From af91d3cb030ac474c700baea5070c6b872509686 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 15:12:29 -0700 Subject: [PATCH 25/33] Add view for payment methods --- views/dashboard-widgets/payment-methods.php | 90 +++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 views/dashboard-widgets/payment-methods.php diff --git a/views/dashboard-widgets/payment-methods.php b/views/dashboard-widgets/payment-methods.php new file mode 100644 index 00000000..5db3c77c --- /dev/null +++ b/views/dashboard-widgets/payment-methods.php @@ -0,0 +1,90 @@ + +
+ +
+ + + + +
+ +

+ +

+ +
+ + + +
+ + + +

+ +

+ + + +
+ +
+ + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + + + + + +
+ + + +

+ +

+ + + +
+ +
+ +
From d6d8d9f7cafae797ad1c532a76b445e57cf5d298 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 15:12:53 -0700 Subject: [PATCH 26/33] actually we don't want a dedicated addon page --- inc/class-wp-ultimo.php | 1 - 1 file changed, 1 deletion(-) diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 352947a5..662b66c1 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -799,7 +799,6 @@ protected function load_admin_only_pages(): void { new WP_Ultimo\Admin_Pages\Customer_Panel\Add_New_Site_Admin_Page(); new WP_Ultimo\Admin_Pages\Customer_Panel\Checkout_Admin_Page(); new WP_Ultimo\Admin_Pages\Customer_Panel\Template_Switching_Admin_Page(); - new WP_Ultimo\Admin_Pages\Customer_Panel\Addon_Catalog_Admin_Page(); new WP_Ultimo\Tax\Dashboard_Taxes_Tab(); From baa6b4c0ddb82cc137550061c7075f924a54f68a Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 16:23:39 -0700 Subject: [PATCH 27/33] Fix CI test failures for Update_Check_Test - Add plugin symlink in CI workflow so get_plugins()/get_plugin_data() can find the plugin - Exclude tests/ from WordPress.Files.FileName PHPCS rule (PHPUnit uses PascalCase) - Fix PHPCS formatting issues in Update_Check_Test.php Co-Authored-By: Claude Opus 4.6 --- .github/workflows/tests.yml | 1 + .phpcs.xml.dist | 5 +++++ tests/WP_Ultimo/Update_Check_Test.php | 28 ++++++++++++++------------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 041cbde8..36d7f568 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,6 +71,7 @@ jobs: run: | rm -rf /tmp/wordpress-tests-lib /tmp/wordpress/ bash bin/install-wp-tests.sh wordpress_test root root mysql latest + ln -s "$GITHUB_WORKSPACE" /tmp/wordpress/wp-content/plugins/ultimate-multisite - name: Run PHPUnit Tests if: matrix.php-version != '8.3' diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 6ec0c206..e6556dc4 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -9,6 +9,11 @@ /dependencies/ /../wordpress/ + + + /tests/ + + diff --git a/tests/WP_Ultimo/Update_Check_Test.php b/tests/WP_Ultimo/Update_Check_Test.php index 1795a96b..3ed3d212 100644 --- a/tests/WP_Ultimo/Update_Check_Test.php +++ b/tests/WP_Ultimo/Update_Check_Test.php @@ -122,15 +122,17 @@ public function test_http_request_args_do_not_block_wporg(): void { $args = [ 'timeout' => 30, 'body' => [ - 'plugins' => wp_json_encode([ - 'plugins' => [ - $this->plugin_file => [ - 'Name' => 'Ultimate Multisite', - 'Version' => '2.4.11', + 'plugins' => wp_json_encode( + [ + 'plugins' => [ + $this->plugin_file => [ + 'Name' => 'Ultimate Multisite', + 'Version' => '2.4.11', + ], ], - ], - 'active' => [ $this->plugin_file ], - ]), + 'active' => [$this->plugin_file], + ] + ), ], ]; @@ -184,11 +186,11 @@ public function test_pre_http_request_does_not_block_wporg(): void { public function test_beta_update_filter_does_not_remove_wporg_data(): void { // Create a mock transient with WordPress.org data - $transient = new \stdClass(); - $transient->last_checked = time(); - $transient->checked = [ $this->plugin_file => '2.4.10' ]; - $transient->response = []; - $transient->no_update = []; + $transient = new \stdClass(); + $transient->last_checked = time(); + $transient->checked = [$this->plugin_file => '2.4.10']; + $transient->response = []; + $transient->no_update = []; $transient->no_update[ $this->plugin_file ] = (object) [ 'id' => 'w.org/plugins/ultimate-multisite', 'slug' => 'ultimate-multisite', From 5a2305a1ade1ccd5cfec74a8ffcca8429800ebd0 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 17:05:52 -0700 Subject: [PATCH 28/33] Update inc/helpers/validation-rules/class-exists.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- inc/helpers/validation-rules/class-exists.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/helpers/validation-rules/class-exists.php b/inc/helpers/validation-rules/class-exists.php index e3877778..737c9d3f 100644 --- a/inc/helpers/validation-rules/class-exists.php +++ b/inc/helpers/validation-rules/class-exists.php @@ -53,8 +53,8 @@ public function check($value): bool { ] ); - // Allow 0/empty as "no association" for optional foreign keys. - if (empty($value)) { + // Allow explicit "no association" sentinels for optional foreign keys. + if ($value === null || $value === '' || $value === 0 || $value === '0') { return true; } From 85530e21476e1f2f27df41dc7b2afea1121604d9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 17:10:30 -0700 Subject: [PATCH 29/33] Use correct name --- views/dashboard-widgets/thank-you.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/dashboard-widgets/thank-you.php b/views/dashboard-widgets/thank-you.php index 6f8954f1..1854ab59 100644 --- a/views/dashboard-widgets/thank-you.php +++ b/views/dashboard-widgets/thank-you.php @@ -6,7 +6,7 @@ */ defined('ABSPATH') || exit; ?> -
+
From 25e683df148585d6e433f1ce88f93583e76de33c Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 17:10:44 -0700 Subject: [PATCH 30/33] Fix underscore warning --- inc/ui/class-tours.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/inc/ui/class-tours.php b/inc/ui/class-tours.php index 7abf87e0..068e01be 100644 --- a/inc/ui/class-tours.php +++ b/inc/ui/class-tours.php @@ -76,7 +76,7 @@ public function register_scripts(): void { WP_Ultimo()->scripts->register_script_module('shepherd.js', wu_get_asset('lib/shepherd.js', 'js')); WP_Ultimo()->scripts->register_style('shepherd', wu_get_asset('lib/shepherd.css', 'css')); - WP_Ultimo()->scripts->register_script_module('wu-tours', wu_get_asset('tours.js', 'js'), ['shepherd.js', 'underscore']); + WP_Ultimo()->scripts->register_script_module('wu-tours', wu_get_asset('tours.js', 'js'), ['shepherd.js']); } /** @@ -104,6 +104,7 @@ public function enqueue_scripts(): void { ] ); + wp_enqueue_script('underscore'); wp_enqueue_script_module('wu-tours'); wp_enqueue_style('shepherd'); } From f51f4017b6b8405c5733905e5db4c93fde03b41f Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 17:10:56 -0700 Subject: [PATCH 31/33] Be more protective --- inc/gateways/class-base-stripe-gateway.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php index af7f5e84..3d173948 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -2092,10 +2092,12 @@ protected function get_ultimo_line_items_from_invoice($invoice_line_items) { $title = preg_replace($description_pattern, '$1', (string) $s_line_item->description); + $has_taxes = ! empty($s_line_item->taxes); + $line_item_data = [ 'title' => $title, 'description' => $s_line_item->description, - 'tax_inclusive' => 'inclusive' === $s_line_item->taxes[0]->tax_behavior, + 'tax_inclusive' => $has_taxes && 'inclusive' === $s_line_item->taxes[0]->tax_behavior, 'unit_price' => (float) $s_line_item->pricing->unit_amount_decimal / $currency_multiplier, 'quantity' => $quantity, ]; @@ -2107,7 +2109,7 @@ protected function get_ultimo_line_items_from_invoice($invoice_line_items) { $line_item = new Line_Item($line_item_data); $subtotal = $s_line_item->amount / $currency_multiplier; - $tax_total = ($s_line_item->taxes[0]->amount) / $currency_multiplier; + $tax_total = $has_taxes ? $s_line_item->taxes[0]->amount / $currency_multiplier : 0; $total = $s_line_item->amount / $currency_multiplier; // Set this values after generate the line item to bypass the recalculate_totals From b465129a27de4723544c362b09434d7bc90ffea2 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 17:13:29 -0700 Subject: [PATCH 32/33] We don't want trials possible if we are downgrading --- inc/admin-pages/class-top-admin-nav-menu.php | 82 ++++++++++--------- .../class-stripe-checkout-gateway.php | 2 +- readme.txt | 6 +- 3 files changed, 46 insertions(+), 44 deletions(-) diff --git a/inc/admin-pages/class-top-admin-nav-menu.php b/inc/admin-pages/class-top-admin-nav-menu.php index e348ddf5..bbe0de41 100644 --- a/inc/admin-pages/class-top-admin-nav-menu.php +++ b/inc/admin-pages/class-top-admin-nav-menu.php @@ -191,55 +191,28 @@ public function add_top_bar_menus($wp_admin_bar): void { } /* - * Add the sub-menus. + * Add the settings sub-menus. */ - $settings_tabs = Settings::get_instance()->get_sections(); + if (current_user_can('wu_read_settings')) { + $settings_tabs = Settings::get_instance()->get_sections(); - $addon_tabs = []; + $addon_tabs = []; - foreach ($settings_tabs as $tab => $tab_info) { - if (wu_get_isset($tab_info, 'invisible')) { - continue; - } + foreach ($settings_tabs as $tab => $tab_info) { + if (wu_get_isset($tab_info, 'invisible')) { + continue; + } - if (wu_get_isset($tab_info, 'addon', false)) { - $addon_tabs[ $tab ] = $tab_info; + if (wu_get_isset($tab_info, 'addon', false)) { + $addon_tabs[ $tab ] = $tab_info; - continue; - } + continue; + } - $wp_admin_bar->add_node( - [ - 'id' => 'wp-ultimo-settings-' . $tab, - 'parent' => 'wp-ultimo-settings', - 'title' => $tab_info['title'], - 'href' => network_admin_url('admin.php?page=wp-ultimo-settings&tab=') . $tab, - 'meta' => [ - 'class' => 'wp-ultimo-top-menu', - 'title' => __('Go to the settings page', 'ultimate-multisite'), - ], - ] - ); - } - - if ($addon_tabs) { - $wp_admin_bar->add_node( - [ - 'id' => 'wp-ultimo-settings-addons', - 'parent' => 'wp-ultimo-settings', - 'group' => true, - 'title' => __('Addon Settings', 'ultimate-multisite'), - 'meta' => [ - 'class' => 'ab-sub-secondary', - ], - ] - ); - - foreach ($addon_tabs as $tab => $tab_info) { $wp_admin_bar->add_node( [ 'id' => 'wp-ultimo-settings-' . $tab, - 'parent' => 'wp-ultimo-settings-addons', + 'parent' => 'wp-ultimo-settings', 'title' => $tab_info['title'], 'href' => network_admin_url('admin.php?page=wp-ultimo-settings&tab=') . $tab, 'meta' => [ @@ -249,6 +222,35 @@ public function add_top_bar_menus($wp_admin_bar): void { ] ); } + + if ($addon_tabs) { + $wp_admin_bar->add_node( + [ + 'id' => 'wp-ultimo-settings-addons', + 'parent' => 'wp-ultimo-settings', + 'group' => true, + 'title' => __('Addon Settings', 'ultimate-multisite'), + 'meta' => [ + 'class' => 'ab-sub-secondary', + ], + ] + ); + + foreach ($addon_tabs as $tab => $tab_info) { + $wp_admin_bar->add_node( + [ + 'id' => 'wp-ultimo-settings-' . $tab, + 'parent' => 'wp-ultimo-settings-addons', + 'title' => $tab_info['title'], + 'href' => network_admin_url('admin.php?page=wp-ultimo-settings&tab=') . $tab, + 'meta' => [ + 'class' => 'wp-ultimo-top-menu', + 'title' => __('Go to the settings page', 'ultimate-multisite'), + ], + ] + ); + } + } } } } diff --git a/inc/gateways/class-stripe-checkout-gateway.php b/inc/gateways/class-stripe-checkout-gateway.php index 36837de1..1c8c430a 100644 --- a/inc/gateways/class-stripe-checkout-gateway.php +++ b/inc/gateways/class-stripe-checkout-gateway.php @@ -356,7 +356,7 @@ public function run_preflight() { /* * Handle trial periods. */ - if ($this->order->has_trial()) { + elseif ($this->order->has_trial()) { $subscription_data['subscription_data']['trial_end'] = $this->order->get_billing_start_date(); } } else { diff --git a/readme.txt b/readme.txt index 10fadf8b..d18299a5 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Tags: multisite, domain mapping, wordpress multisite, multisite saas, waas Requires at least: 5.3 Requires PHP: 7.4.30 Tested up to: 6.9 -Stable tag: 2.4.11 +Stable tag: 2.4.12 License: GPLv2 License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -225,8 +225,8 @@ Data collected includes: No personal data, domains, IP addresses, or payment information are collected. == Changelog == -Version [2.4.11] - Released on 2026-XX-XX -- Fix: %2F being striped breaking some WC Urls. +Version [2.4.12] - Released on 2026-XX-XX +- Fix: %2F being stripped breaking some WC Urls. - Improved: Support for changing payment methods of the membership. Version [2.4.11] - Released on 2026-02-16 From 67dbf7194d55b3206043f1733b7a73c4b8a11253 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 25 Feb 2026 17:29:29 -0700 Subject: [PATCH 33/33] Update 2.4.12 changelog with all PR #346 changes Co-Authored-By: Claude Opus 4.6 --- readme.txt | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/readme.txt b/readme.txt index d18299a5..b320bea6 100644 --- a/readme.txt +++ b/readme.txt @@ -226,8 +226,29 @@ No personal data, domains, IP addresses, or payment information are collected. == Changelog == Version [2.4.12] - Released on 2026-XX-XX -- Fix: %2F being stripped breaking some WC Urls. -- Improved: Support for changing payment methods of the membership. +- New: Send Invoice and Resend Invoice workflows for payments. +- New: Standalone "Pay Invoice" checkout form for invoice payments without a membership. +- New: Payment Methods element displaying current card info and change payment method flow via Stripe Billing Portal. +- New: System events for invoice sent, recurring payment failure, and membership expired with email notifications. +- New: Checkout form debug autofill button when WP_ULTIMO_DEBUG is enabled. +- New: Domain meta table for storing metadata on domain records. +- New: Extensibility hooks on domain mapping widget and domain list table. +- New: Node Management capability interface for hosting integrations. +- Fix: Password strength validation no longer blocks checkout when the meter element is absent. +- Fix: %2F being stripped from SSO redirect URLs breaking some WooCommerce URLs. +- Fix: Stripe Checkout gateway updated to current API — uses price_data format, proper subscription/payment mode, and skips zero-amount items. +- Fix: Removed deprecated Stripe API version pin and product type parameter. +- Fix: Membership cancellation now properly cancels the gateway subscription before the local membership. +- Fix: Payments no longer require a membership, enabling standalone invoices. +- Fix: Cart no longer overrides duration for products with independent billing cycles. +- Fix: Network installer correctly sets core multisite table names. +- Fix: Admin page save handlers now return proper bool values. +- Improved: "Change Payment Method" replaces the destructive "Cancel Payment Method" flow. +- Improved: Integration wizard API key fields use password input type to prevent browser autofill. +- Improved: Integration wizard shows error state on test failure and improved navigation. +- Improved: Addon settings grouped under dedicated admin bar submenu. +- Improved: Select2 multi-select preserves saved option ordering. +- Improved: PayPal fires payment_failed event on IPN failures. Version [2.4.11] - Released on 2026-02-16 - New: Settings API for remote settings management.