diff --git a/includes/incoming-events/class-woocommerce-membership-updated.php b/includes/incoming-events/class-woocommerce-membership-updated.php index c19d3f5..ea6d65a 100644 --- a/includes/incoming-events/class-woocommerce-membership-updated.php +++ b/includes/incoming-events/class-woocommerce-membership-updated.php @@ -181,6 +181,24 @@ private function transfer_membership( $new_user, $local_plan_id, $previous_email return; } + // Skip team-owned memberships: their ownership lives on the team post's `_member_id`, + // the linked subscription's `_customer_user`, and team_member user meta – rewriting only + // `post_author` here would silently desync all of those and break renewal dispatch. + // Team ownership transfers must be handled at the team level, not the user_membership level. + $team_id = get_post_meta( $existing_membership_id, '_team_id', true ); + if ( ! empty( $team_id ) ) { + Debugger::log( + sprintf( + 'Skipping transfer of team-owned membership #%d (team #%s) from %s to %s. Team memberships cannot be transferred via post_author alone.', + $existing_membership_id, + $team_id, + $previous_email, + $new_user->user_email + ) + ); + return; + } + // Reassign the membership to the new owner. $updated_post_id = wp_update_post( [ diff --git a/tests/unit-tests/test-membership-transfer.php b/tests/unit-tests/test-membership-transfer.php index 17621d6..f279c55 100644 --- a/tests/unit-tests/test-membership-transfer.php +++ b/tests/unit-tests/test-membership-transfer.php @@ -212,4 +212,59 @@ public function test_update_membership_routes_to_transfer() { // Verify the membership was transferred. $this->assertEquals( $new_user_id, (int) get_post( $membership_id )->post_author ); } + + /** + * Team-owned memberships (those with a `_team_id` meta) must not be transferred via + * post_author rewrite. Their ownership lives on the team's `_member_id`, the linked + * subscription's `_customer_user`, and team_member user meta. See NPPM-2741. + */ + public function test_transfer_skips_team_owned_membership() { + $old_user_id = $this->factory->user->create( [ 'user_email' => 'teamold@example.com' ] ); + $new_user_id = $this->factory->user->create( [ 'user_email' => 'teamnew@example.com' ] ); + + $plan_id = $this->factory->post->create( + [ + 'post_type' => Memberships_Admin::MEMBERSHIP_PLANS_CPT, + 'post_status' => 'publish', + ] + ); + update_post_meta( $plan_id, Memberships_Admin::NETWORK_ID_META_KEY, 'test-team-plan' ); + + $membership_id = $this->factory->post->create( + [ + 'post_type' => 'wc_user_membership', + 'post_status' => 'wcm-active', + 'post_author' => $old_user_id, + 'post_parent' => $plan_id, + ] + ); + update_post_meta( $membership_id, Memberships_Admin::NETWORK_MANAGED_META_KEY, true ); + update_post_meta( $membership_id, Memberships_Admin::REMOTE_ID_META_KEY, 555 ); + update_post_meta( $membership_id, Memberships_Admin::SITE_URL_META_KEY, 'https://hub.example.com' ); + // Mark as team-owned. + update_post_meta( $membership_id, '_team_id', 42 ); + + $transfer_event = new Woocommerce_Membership_Updated( + 'https://hub.example.com', + [ + 'email' => 'teamnew@example.com', + 'user_id' => $new_user_id, + 'plan_network_id' => 'test-team-plan', + 'membership_id' => 555, + 'new_status' => 'active', + 'end_date' => null, + 'previous_email' => 'teamold@example.com', + ], + time() + ); + + $transfer_method = new ReflectionMethod( Woocommerce_Membership_Updated::class, 'transfer_membership' ); + $transfer_method->setAccessible( true ); + + $new_user = get_user_by( 'id', $new_user_id ); + $transfer_method->invoke( $transfer_event, $new_user, $plan_id, 'teamold@example.com' ); + + // post_author must remain unchanged for team-owned memberships. + $this->assertEquals( $old_user_id, (int) get_post( $membership_id )->post_author ); + } }