fix(member): emit member.updated webhook for demoted owner on ownership transfer - #14211
Merged
psincraian merged 1 commit intoSep 8, 2026
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
OpenAPI ChangesNo changes detected in the OpenAPI schema. |
psincraian
approved these changes
Sep 8, 2026
psincraian
deleted the
detail/bug-fix/fix-member-emit-member-updated-webhook-for-demoted-1018b8
branch
September 8, 2026 15:03
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Detail bug report: View on Detail
Summary
Related Issue: polarsource/feedback#478
Ownership transfer promoted a new owner but never notified subscribers that the former owner was demoted to
billing_manager. This emits a secondmember.updatedevent for the demoted owner, fired before the promoted member's event.What
server/polar/member/service.py(MemberService.update): when ownership is transferred (transferred == True), emit amember.updatedwebhook for the demotedcurrent_ownerin addition to the promoted member. Send the demoted owner's event first (demote-then-promote ordering), then the promoted member's — mirroringMemberRepository.transfer_ownership's own demote-first UPDATE ordering.server/tests/member/test_service.py(TestUpdate): four new tests mockingpolar.member.service.webhook_service.send:test_update_ownership_transfer_admin_emits_webhook_for_demoted_owner— admin path (allow_ownership_transfer=True); asserts[owner.id, member.id]ordering and exactly 2 events.test_update_ownership_transfer_customer_portal_emits_webhook_for_demoted_owner— portal path (caller_member=owner); same assertions.test_update_role_change_emits_single_webhook— non-transfer role change emits exactly one event for the changed member.test_update_no_changes_emits_no_webhook— no-op update emits no webhook.Why
MemberService.updatepersisted the demotion insiderepository.transfer_ownership(which refreshescurrent_ownersorole == billing_managerin memory) but the single trailingwebhook_service.send(..., WebhookEventType.member_updated, updated_member)only referencedupdated_member— the promoted member. The demoted owner's role changed with no event, so subscribers relying onmember.updatedto mirror member roles desynced from Polar's DB for that member. Themember.updatedpayload contract documents role changes as a trigger and carriesMemberSchema.role, so a role change with no event is a contract violation.This bug was introduced when
member.updatedwas added (PR #9378) — the transfer path was treated as an internal sub-step of "the update" rather than a second member whose role changed. The transfer path is reachable from four callers (adminPATCH /v1/members/{id}, admin nested-customerPATCH /v1/customers/{id}/members/{id}, organization self-customer owner change, customer portal owner self-demote), so the defect affected every transfer.How
Initialize
current_owner: Member | None = Noneat the top ofupdateso it stays in scope through the webhook section. In the transfer block,current_owneris already assigned (eithercaller_memberfor the portal path, or the existing owner frommembers). Aftertransfer_ownershiprefreshes it,current_owner.role == billing_manager.In the webhook block, when
transferred and current_owner is not None, send the demoted owner'smember.updatedfirst, then the promoted member's. This ordering preserves a "demote then promote" sequence for subscribers (so a single-owner-enforcing consumer never briefly observes two owners), matchingtransfer_ownership's own demote-first pattern that avoids tripping the partial unique index on(customer_id) WHERE role='owner'.Non-transfer role changes are unchanged:
transferredisFalse, so only the single existingwebhook_service.send(..., updated_member)fires. No-op updates still short-circuit before the webhook block (if not update_dict and not transferred: return member).Checklist
uv run task lint && uv run task lint_types)Automatic Fixes PRs can be configured here.