Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down
55 changes: 55 additions & 0 deletions tests/unit-tests/test-membership-transfer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
}
Loading