From c85710a463adbfdc736d8dd9a5d35b33c0ed5ed5 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Tue, 14 Apr 2026 20:27:20 +0200 Subject: [PATCH 1/2] fix(memberships): skip team-owned memberships in transfer_membership `Woocommerce_Membership_Updated::transfer_membership()` rewrites `post_author` on the user_membership to reassign ownership. For team-owned memberships (those with a `_team_id` meta), that's insufficient: true ownership lives on the team post's `_member_id`, the linked subscription's `_customer_user`, and team_member user meta. Rewriting only `post_author` silently desyncs those and can trigger duplicate-team creation on the next renewal dispatch. Skip the transfer (with a log note) when the membership carries `_team_id`. Team ownership transfers must be handled at the team level, not via the user_membership post. Refs NPPM-2741. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../class-woocommerce-membership-updated.php | 19 +++++++ tests/unit-tests/test-membership-transfer.php | 55 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/includes/incoming-events/class-woocommerce-membership-updated.php b/includes/incoming-events/class-woocommerce-membership-updated.php index c19d3f5..ddc0b65 100644 --- a/includes/incoming-events/class-woocommerce-membership-updated.php +++ b/includes/incoming-events/class-woocommerce-membership-updated.php @@ -181,6 +181,25 @@ 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. + // See https://linear.app/a8c/issue/NPPM-2741. + $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 ); + } } From 32b9990e9e085f9478fd61aac9044486f9fa78ce Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Tue, 14 Apr 2026 20:47:30 +0200 Subject: [PATCH 2/2] chore: drop Linear ref from inline comment --- .../incoming-events/class-woocommerce-membership-updated.php | 1 - 1 file changed, 1 deletion(-) diff --git a/includes/incoming-events/class-woocommerce-membership-updated.php b/includes/incoming-events/class-woocommerce-membership-updated.php index ddc0b65..ea6d65a 100644 --- a/includes/incoming-events/class-woocommerce-membership-updated.php +++ b/includes/incoming-events/class-woocommerce-membership-updated.php @@ -185,7 +185,6 @@ private function transfer_membership( $new_user, $local_plan_id, $previous_email // 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. - // See https://linear.app/a8c/issue/NPPM-2741. $team_id = get_post_meta( $existing_membership_id, '_team_id', true ); if ( ! empty( $team_id ) ) { Debugger::log(