diff --git a/server/polar/member/service.py b/server/polar/member/service.py index c08eb35d6a2..40d2cb075ec 100644 --- a/server/polar/member/service.py +++ b/server/polar/member/service.py @@ -818,6 +818,7 @@ async def update( """ repository = MemberRepository.from_session(session) transferred = False + current_owner: Member | None = None if role is not None and member.role != role: members = await repository.list_by_customer(member.customer_id) @@ -913,6 +914,18 @@ async def update( organization_repository = OrganizationRepository.from_session(session) organization = await organization_repository.get_by_id(member.organization_id) if organization: + # Ownership transfer changes two members' roles: the new owner is + # promoted and the previous owner is demoted to billing_manager. + # Emit the demoted owner's event first, mirroring transfer_ownership's + # own demote-then-promote ordering, so subscribers never briefly see + # two owners and stay in sync with the DB for the former owner. + if transferred and current_owner is not None: + await webhook_service.send( + session, + organization, + WebhookEventType.member_updated, + current_owner, + ) await webhook_service.send( session, organization, diff --git a/server/tests/member/test_service.py b/server/tests/member/test_service.py index f790c8be6fb..bd8721809fd 100644 --- a/server/tests/member/test_service.py +++ b/server/tests/member/test_service.py @@ -23,6 +23,7 @@ from polar.models.customer import CustomerType from polar.models.customer_seat import SeatStatus from polar.models.member import MemberRole +from polar.models.webhook_endpoint import WebhookEventType from polar.postgres import AsyncSession from tests.fixtures.auth import AuthSubjectFixture from tests.fixtures.database import SaveFixture @@ -848,6 +849,154 @@ async def test_update_ownership_transfer_admin( assert owner.role == MemberRole.billing_manager assert member.role == MemberRole.owner + @pytest.mark.auth + async def test_update_ownership_transfer_admin_emits_webhook_for_demoted_owner( + self, + mocker: MockerFixture, + save_fixture: SaveFixture, + session: AsyncSession, + organization: Organization, + ) -> None: + """Ownership transfer sends ``member.updated`` for the demoted former owner too. + + Regression: ``update`` previously sent a single ``member.updated`` for the + promoted member, so subscribers mirroring member roles desynced from the + DB for the demoted owner (owner -> billing_manager). The demoted owner's + event must fire before the promoted member's so a single-owner-enforcing + consumer never briefly observes two owners. + """ + webhook_send_mock = mocker.patch("polar.member.service.webhook_service.send") + + customer = await create_customer( + save_fixture, organization=organization, email="customer@example.com" + ) + owner = await create_member( + save_fixture, + customer=customer, + organization=organization, + role=MemberRole.owner, + email="owner@example.com", + ) + member = await create_member( + save_fixture, + customer=customer, + organization=organization, + role=MemberRole.member, + email="member@example.com", + ) + + await member_service.update( + session, member, role=MemberRole.owner, allow_ownership_transfer=True + ) + + member_updated_members = [ + call.args[3] + for call in webhook_send_mock.call_args_list + if call.args[2] == WebhookEventType.member_updated + ] + assert [m.id for m in member_updated_members] == [owner.id, member.id] + assert webhook_send_mock.call_count == 2 + + @pytest.mark.auth + async def test_update_ownership_transfer_customer_portal_emits_webhook_for_demoted_owner( + self, + mocker: MockerFixture, + save_fixture: SaveFixture, + session: AsyncSession, + organization: Organization, + ) -> None: + """The customer-portal transfer path (``current_owner = caller_member``) + also notifies subscribers about the demoted former owner.""" + webhook_send_mock = mocker.patch("polar.member.service.webhook_service.send") + + customer = await create_customer( + save_fixture, organization=organization, email="customer@example.com" + ) + owner = await create_member( + save_fixture, + customer=customer, + organization=organization, + role=MemberRole.owner, + email="owner@example.com", + ) + member = await create_member( + save_fixture, + customer=customer, + organization=organization, + role=MemberRole.member, + email="member@example.com", + ) + + await member_service.update( + session, member, role=MemberRole.owner, caller_member=owner + ) + + member_updated_members = [ + call.args[3] + for call in webhook_send_mock.call_args_list + if call.args[2] == WebhookEventType.member_updated + ] + assert [m.id for m in member_updated_members] == [owner.id, member.id] + assert webhook_send_mock.call_count == 2 + + @pytest.mark.auth + async def test_update_role_change_emits_single_webhook( + self, + mocker: MockerFixture, + save_fixture: SaveFixture, + session: AsyncSession, + organization: Organization, + ) -> None: + """A non-transfer role change still emits exactly one ``member.updated`` + for the changed member (no regression from the transfer fix).""" + webhook_send_mock = mocker.patch("polar.member.service.webhook_service.send") + + customer = await create_customer( + save_fixture, organization=organization, email="customer@example.com" + ) + member = await create_member( + save_fixture, + customer=customer, + organization=organization, + role=MemberRole.member, + email="member@example.com", + ) + + updated = await member_service.update( + session, member, role=MemberRole.billing_manager + ) + + assert webhook_send_mock.call_count == 1 + call = webhook_send_mock.call_args + assert call.args[2] == WebhookEventType.member_updated + assert call.args[3].id == updated.id + + @pytest.mark.auth + async def test_update_no_changes_emits_no_webhook( + self, + mocker: MockerFixture, + save_fixture: SaveFixture, + session: AsyncSession, + organization: Organization, + ) -> None: + """An update with no effective changes short-circuits and sends no webhook.""" + webhook_send_mock = mocker.patch("polar.member.service.webhook_service.send") + + customer = await create_customer( + save_fixture, organization=organization, email="customer@example.com" + ) + member = await create_member( + save_fixture, + customer=customer, + organization=organization, + role=MemberRole.member, + email="member@example.com", + ) + + await member_service.update(session, member) + + webhook_send_mock.assert_not_called() + @pytest.mark.auth async def test_update_no_changes( self,