diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 041cbde80..36d7f568f 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 6ec0c206b..e6556dc49 100644
--- a/.phpcs.xml.dist
+++ b/.phpcs.xml.dist
@@ -9,6 +9,11 @@
/dependencies/
/../wordpress/
+
+
+ /tests/
+
+
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 d66372634..000000000
--- 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:
-
-
-
-Use the email you provided during the purchase and your password.
-
-
-
-This is your Account Page inicial screen:
-
-
-
-To cancel your subscription, on the menu on the left, go to _Renewing & Billing._
-
-
-
-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.
diff --git a/assets/js/checkout.js b/assets/js/checkout.js
index 7ecf40cfc..3fdf96780 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 e5600b6b7..d786eb1de 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/assets/js/integration-test.js b/assets/js/integration-test.js
index 1a476d5ad..68711d17a 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 a6bcd58bb..b95556b5a 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
diff --git a/composer.json b/composer.json
index 54bbed26f..0e0ca3bee 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 9ea21a541..104940fa5 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/inc/admin-pages/class-checkout-form-edit-admin-page.php b/inc/admin-pages/class-checkout-form-edit-admin-page.php
index 6432a1fdb..0b6701166 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 44b1a9ffb..e17f7765b 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 24ef8711b..00b169cb2 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 b71d2b7e4..67496d9ec 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 38d21640e..8caa82c26 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-hosting-integration-wizard-admin-page.php b/inc/admin-pages/class-hosting-integration-wizard-admin-page.php
index 8d0349bb7..80d39eb5b 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/admin-pages/class-payment-edit-admin-page.php b/inc/admin-pages/class-payment-edit-admin-page.php
index 8812f78e7..74d4b7de6 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',
+ ];
}
}
@@ -1310,9 +1436,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 +1454,6 @@ public function handle_save(): void {
}
}
- parent::handle_save();
+ return parent::handle_save();
}
}
diff --git a/inc/admin-pages/class-payment-list-admin-page.php b/inc/admin-pages/class-payment-list-admin-page.php
index 862ff7004..040c0e43b 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/class-product-edit-admin-page.php b/inc/admin-pages/class-product-edit-admin-page.php
index 9a8e0618c..b19438f8f 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/admin-pages/class-top-admin-nav-menu.php b/inc/admin-pages/class-top-admin-nav-menu.php
index 84e2ce575..bbe0de410 100644
--- a/inc/admin-pages/class-top-admin-nav-menu.php
+++ b/inc/admin-pages/class-top-admin-nav-menu.php
@@ -191,35 +191,66 @@ 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();
-
- $has_addons = false;
-
- foreach ($settings_tabs as $tab => $tab_info) {
- if (wu_get_isset($tab_info, 'invisible')) {
- continue;
+ if (current_user_can('wu_read_settings')) {
+ $settings_tabs = Settings::get_instance()->get_sections();
+
+ $addon_tabs = [];
+
+ 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;
+
+ 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'),
+ ],
+ ]
+ );
}
- $parent = 'wp-ultimo-settings';
-
- if (wu_get_isset($tab_info, 'addon', false)) {
- $parent = 'wp-ultimo-settings-addons';
+ 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'),
+ ],
+ ]
+ );
+ }
}
-
- $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);
}
}
}
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 850f57dd1..9e4c66f99 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/checkout/class-cart.php b/inc/checkout/class-cart.php
index e684f0171..3ecf79963 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)) {
diff --git a/inc/class-cron.php b/inc/class-cron.php
index 7262b2ed4..69dc70588 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);
+ }
+ }
}
}
diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php
index 13b618f39..662b66c15 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
@@ -509,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();
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 000000000..463c15345
--- /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}))";
+ }
+}
diff --git a/inc/debug/class-debug.php b/inc/debug/class-debug.php
index 0a20d443d..7af6a987e 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']);
}
/**
@@ -571,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');
}
);
@@ -945,6 +946,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 {
+
+ ?>
+
+ ⚙ Fill with random data
+
+
+ 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/functions/helper.php b/inc/functions/helper.php
index 409686d74..29bf21a92 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
diff --git a/inc/gateways/class-base-gateway.php b/inc/gateways/class-base-gateway.php
index cd6657ba2..35974ea1f 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 2ffc5625d..3d1739481 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.
@@ -2019,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, // $s_line_item->amount !== $s_line_item->taxes->amount_excluding_tax,
+ '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,
];
@@ -2034,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
@@ -2254,13 +2329,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 +2477,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 +2554,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 +3005,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 +3293,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 +3303,6 @@ public function maybe_create_plan($args) {
$product = $this->get_stripe_client()->products->create(
[
'name' => $args['name'] . ' - ' . $args['currency'],
- 'type' => 'service',
]
);
@@ -3292,7 +3380,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 3953ab018..8f2bc9d88 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/gateways/class-stripe-checkout-gateway.php b/inc/gateways/class-stripe-checkout-gateway.php
index ce857bf10..1c8c430af 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),
- ];
+ elseif ($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.
diff --git a/inc/helpers/validation-rules/class-exists.php b/inc/helpers/validation-rules/class-exists.php
index 788ee2ef8..737c9d3f1 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 explicit "no association" sentinels for optional foreign keys.
+ if ($value === null || $value === '' || $value === 0 || $value === '0') {
+ return true;
+ }
+
$column = $this->parameter('column');
$model = $this->parameter('model');
diff --git a/inc/installers/class-multisite-network-installer.php b/inc/installers/class-multisite-network-installer.php
index a5ea01b64..14c8fa79b 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(
diff --git a/inc/integrations/capabilities/interface-domain-selling-capability.php b/inc/integrations/capabilities/interface-domain-selling-capability.php
index 88f9fca5d..f30320a10 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 000000000..119e8269a
--- /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;
+}
diff --git a/inc/integrations/providers/cloudflare/class-cloudflare-integration.php b/inc/integrations/providers/cloudflare/class-cloudflare-integration.php
index 5304a0e8c..bf9bcafcf 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 f8342e241..4078f4728 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 dec90d5db..301734d51 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 5a37d5b61..11494275d 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 6dad481e2..750b93c68 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 2f3a7a072..801023364 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 1753c91c9..71e24a3fc 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/inc/list-tables/class-domain-list-table.php b/inc/list-tables/class-domain-list-table.php
index 199105ba5..da67c14b8 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 f31a3cd49..0fef8f100 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/inc/managers/class-email-manager.php b/inc/managers/class-email-manager.php
index d9599c1fc..852a8f75d 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 3d6d25dd0..295717f98 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 5b9ea4e41..34a6a60df 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 eb754c289..59279e3ea 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 84d5562c8..48af84f9b 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/sso/auth-functions.php b/inc/sso/auth-functions.php
index 4cf3c7157..94d23bf77 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 c0a4c9b3a..333c4ea6c 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;
}
diff --git a/inc/ui/class-payment-methods-element.php b/inc/ui/class-payment-methods-element.php
index 50e99d0e6..441301b06 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 dd066ad86..372fbbae3 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(
diff --git a/inc/ui/class-tours.php b/inc/ui/class-tours.php
index 7abf87e06..068e01be0 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');
}
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 000000000..20d1672bd
--- /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
diff --git a/readme.txt b/readme.txt
index 66a8aacd3..b320bea61 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1,15 +1,15 @@
=== 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
-Stable tag: 2.4.11
+Stable tag: 2.4.12
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,30 @@ Data collected includes:
No personal data, domains, IP addresses, or payment information are collected.
== Changelog ==
+Version [2.4.12] - Released on 2026-XX-XX
+- 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.
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 0d7a0d595..95d9f5568 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 000000000..3ed3d2127
--- /dev/null
+++ b/tests/WP_Ultimo/Update_Check_Test.php
@@ -0,0 +1,263 @@
+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."
+ );
+ }
+}
diff --git a/views/dashboard-widgets/domain-mapping.php b/views/dashboard-widgets/domain-mapping.php
index bceb56f97..dd9e7773a 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
);
diff --git a/views/dashboard-widgets/payment-methods.php b/views/dashboard-widgets/payment-methods.php
new file mode 100644
index 000000000..5db3c77c6
--- /dev/null
+++ b/views/dashboard-widgets/payment-methods.php
@@ -0,0 +1,90 @@
+
+
diff --git a/views/dashboard-widgets/thank-you.php b/views/dashboard-widgets/thank-you.php
index a7965d94d..1854ab593 100644
--- a/views/dashboard-widgets/thank-you.php
+++ b/views/dashboard-widgets/thank-you.php
@@ -250,11 +250,11 @@
-
+
diff --git a/views/emails/admin/membership-expired.php b/views/emails/admin/membership-expired.php
new file mode 100644
index 000000000..1f8769096
--- /dev/null
+++ b/views/emails/admin/membership-expired.php
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/emails/admin/payment-failed.php b/views/emails/admin/payment-failed.php
new file mode 100644
index 000000000..1e98de666
--- /dev/null
+++ b/views/emails/admin/payment-failed.php
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/emails/customer/membership-expired.php b/views/emails/customer/membership-expired.php
new file mode 100644
index 000000000..9286b6a04
--- /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 000000000..bdc535469
--- /dev/null
+++ b/views/emails/customer/payment-failed.php
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{membership_description}}
+
+
+
+
+ {{membership_date_expiration}}
+
+
+
+ {{payment_gateway}}
+
+
+
diff --git a/views/settings/fields/field-select2.php b/views/settings/fields/field-select2.php
index 24a6003ca..c3e4fe7d4 100644
--- a/views/settings/fields/field-select2.php
+++ b/views/settings/fields/field-select2.php
@@ -22,8 +22,18 @@
+
+
+
+
+
+
+
+
$option) : ?>
- value="">
+
+
+
diff --git a/views/wizards/host-integrations/configuration.php b/views/wizards/host-integrations/configuration.php
index 51728d590..ea2715d4f 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 @@