@@ -836,6 +836,104 @@ protected function build_from_membership($membership_id): bool {
836836 return true ;
837837 }
838838
839+ /*
840+ * Reactivation flow: detect cancelled/expired memberships.
841+ *
842+ * When a customer with a cancelled or expired membership submits a checkout,
843+ * this is a reactivation — not an upgrade. We set the cart_type accordingly
844+ * and ensure the product list includes the full plan + any addons from the
845+ * original membership.
846+ *
847+ * @since 2.5.0
848+ */
849+ $ inactive_statuses = [
850+ Membership_Status::CANCELLED ,
851+ Membership_Status::EXPIRED ,
852+ 'on-hold ' ,
853+ 'suspended ' ,
854+ ];
855+
856+ if (in_array ($ membership ->get_status (), $ inactive_statuses , true )) {
857+
858+ $ this ->cart_type = 'reactivation ' ;
859+
860+ /*
861+ * Mark the customer as having trialed in memory only.
862+ *
863+ * This prevents has_trial() from returning true during THIS
864+ * checkout session. The flag is NOT persisted to the database
865+ * here — it will be saved when the membership is renewed after
866+ * payment completes. This avoids permanently removing trial
867+ * eligibility if the user abandons checkout.
868+ *
869+ * @since 2.5.0
870+ */
871+ if ($ this ->customer && method_exists ($ this ->customer , 'set_has_trialed ' )) {
872+ $ this ->customer ->set_has_trialed (true );
873+ }
874+
875+ /*
876+ * Always rebuild products from the membership, ignoring any
877+ * user-supplied products in the request. This prevents a
878+ * malicious user from injecting arbitrary product IDs into
879+ * a reactivation cart.
880+ *
881+ * @since 2.5.0
882+ */
883+ $ plan_id = $ membership ->get_plan_id ();
884+
885+ // Set up country and currency before building products
886+ if (! $ this ->country && $ this ->customer ) {
887+ $ this ->country = $ this ->customer ->get_country ();
888+ }
889+
890+ $ this ->set_currency ($ membership ->get_currency ());
891+
892+ /*
893+ * Set duration from the plan BEFORE calling add_product() so that
894+ * line items are created with the correct billing period. Previously
895+ * this was set after add_product(), meaning products were added with
896+ * the default/empty duration and the correct values were never used.
897+ *
898+ * @since 2.5.0
899+ */
900+ $ plan_product = $ membership ->get_plan ();
901+
902+ if ($ plan_product && ! $ membership ->is_free ()) {
903+ $ this ->duration = $ plan_product ->get_duration ();
904+ $ this ->duration_unit = $ plan_product ->get_duration_unit ();
905+ }
906+
907+ if (! $ plan_id ) {
908+ $ this ->errors ->add ('no_plan ' , __ ('This membership has no plan to reactivate. ' , 'ultimate-multisite ' ));
909+
910+ return true ;
911+ }
912+
913+ /*
914+ * Rebuild the product list from the membership, preserving addon
915+ * quantities. get_addon_ids() discards quantities; get_addon_products()
916+ * returns [['product' => $obj, 'quantity' => N], ...] so each addon
917+ * is added with the correct quantity.
918+ *
919+ * @since 2.5.0
920+ */
921+ $ this ->add_product ($ plan_id );
922+
923+ $ addon_products = $ membership ->get_addon_products ();
924+
925+ foreach ($ addon_products as $ addon_item ) {
926+ $ addon_product = wu_get_isset ($ addon_item , 'product ' );
927+ $ addon_qty = (int ) wu_get_isset ($ addon_item , 'quantity ' , 1 );
928+
929+ if ($ addon_product && method_exists ($ addon_product , 'get_id ' )) {
930+ $ this ->add_product ($ addon_product ->get_id (), $ addon_qty );
931+ }
932+ }
933+
934+ return true ;
935+ }
936+
839937 /*
840938 * Adds the country to calculate taxes.
841939 */
@@ -2052,7 +2150,7 @@ public function add_product($product_id_or_slug, $quantity = 1): bool {
20522150 return true ;
20532151 }
20542152
2055- $ add_signup_fee = ' renewal ' !== $ this ->get_cart_type ();
2153+ $ add_signup_fee = ! in_array ( $ this ->get_cart_type (), [ ' renewal ' , ' reactivation ' ], true );
20562154
20572155 /**
20582156 * Filters whether or not the signup fee should be applied.
@@ -2144,6 +2242,18 @@ public function get_tax_breakthrough() {
21442242 */
21452243 public function has_trial () {
21462244
2245+ /*
2246+ * Reactivation carts never get a trial period.
2247+ *
2248+ * A customer reactivating a cancelled membership has already used
2249+ * their trial. Offering another trial would be a revenue loss.
2250+ *
2251+ * @since 2.5.0
2252+ */
2253+ if ('reactivation ' === $ this ->cart_type ) {
2254+ return false ;
2255+ }
2256+
21472257 $ products = $ this ->get_all_products ();
21482258
21492259 if (empty ($ products )) {
@@ -2576,6 +2686,18 @@ public function get_billing_start_date() {
25762686 return null ;
25772687 }
25782688
2689+ /*
2690+ * Reactivation carts have no trial period, so billing starts now.
2691+ *
2692+ * Return null so downstream consumers (Stripe subscription setup,
2693+ * date_trial_end, etc.) don't receive a trial-derived start date.
2694+ *
2695+ * @since 2.5.0
2696+ */
2697+ if ('reactivation ' === $ this ->cart_type ) {
2698+ return null ;
2699+ }
2700+
25792701 /*
25802702 * Set extremely high value at first to prevent any change of errors.
25812703 */
0 commit comments