Skip to content

fix(member): emit member.updated webhook for demoted owner on ownership transfer - #14211

Merged
psincraian merged 1 commit into
mainfrom
detail/bug-fix/fix-member-emit-member-updated-webhook-for-demoted-1018b8
Sep 8, 2026
Merged

psincraian merged 1 commit into
mainfrom
detail/bug-fix/fix-member-emit-member-updated-webhook-for-demoted-1018b8

Conversation

@detail-app

@detail-app detail-app Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

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 second member.updated event 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 a member.updated webhook for the demoted current_owner in addition to the promoted member. Send the demoted owner's event first (demote-then-promote ordering), then the promoted member's — mirroring MemberRepository.transfer_ownership's own demote-first UPDATE ordering.
  • server/tests/member/test_service.py (TestUpdate): four new tests mocking polar.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.update persisted the demotion inside repository.transfer_ownership (which refreshes current_owner so role == billing_manager in memory) but the single trailing webhook_service.send(..., WebhookEventType.member_updated, updated_member) only referenced updated_member — the promoted member. The demoted owner's role changed with no event, so subscribers relying on member.updated to mirror member roles desynced from Polar's DB for that member. The member.updated payload contract documents role changes as a trigger and carries MemberSchema.role, so a role change with no event is a contract violation.

This bug was introduced when member.updated was 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 (admin PATCH /v1/members/{id}, admin nested-customer PATCH /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 = None at the top of update so it stays in scope through the webhook section. In the transfer block, current_owner is already assigned (either caller_member for the portal path, or the existing owner from members). After transfer_ownership refreshes it, current_owner.role == billing_manager.

In the webhook block, when transferred and current_owner is not None, send the demoted owner's member.updated first, 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), matching transfer_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: transferred is False, so only the single existing webhook_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

  • This PR addresses a single concern (one bug fix, one feature, one refactor)
  • The diff is reasonably sized and easy to review
  • New functionality is covered by tests
  • Linting and type checking pass (uv run task lint && uv run task lint_types)
  • No unrelated changes or drive-by fixes are included

Automatic Fixes PRs can be configured here.

Review in cubic

@detail-app
detail-app Bot requested a review from psincraian September 7, 2026 01:32
@vercel

vercel Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
orbit Ready Ready Preview Sep 7, 2026 1:36am UTC
polar-test Ready Ready Preview Sep 7, 2026 1:36am UTC

Request Review

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

OpenAPI Changes

No changes detected in the OpenAPI schema.

@psincraian
psincraian added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 2194626 Sep 8, 2026
30 of 44 checks passed
@psincraian
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

2 active deployments
Preview – polar-test — 0a6fbe0d Deployed Sep 7, 2026 by vercel[bot]
Preview – orbit — 0a6fbe0d Deployed Sep 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant