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
1 change: 1 addition & 0 deletions server/polar/integrations/stripe/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"refund.created",
"refund.updated",
"refund.failed",
"payment_method.detached",
"identity.verification_session.verified",
"identity.verification_session.processing",
"identity.verification_session.requires_input",
Expand Down
21 changes: 21 additions & 0 deletions server/polar/integrations/stripe/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@

from polar.checkout.service import NotConfirmedCheckout
from polar.dispute.service import dispute as dispute_service
from polar.enums import PaymentProcessor
from polar.external_event.service import external_event as external_event_service
from polar.logging import Logger
from polar.payment.service import UnhandledPaymentIntent
from polar.payment.service import payment as payment_service
from polar.payment_method.repository import PaymentMethodRepository
from polar.payment_method.service import payment_method as payment_method_service
from polar.payout.service import payout as payout_service
from polar.payout_account.service import payout_account as payout_account_service
Expand Down Expand Up @@ -357,6 +359,25 @@ async def payout_failed(event_id: uuid.UUID) -> None:
await payout_service.update_from_stripe(session, payout)


@actor(actor_name="stripe.webhook.payment_method.detached", priority=TaskPriority.HIGH)
@stripe_api_connection_error_retry
async def payment_method_detached(event_id: uuid.UUID) -> None:
async with AsyncSessionMaker() as session:
async with external_event_service.handle_stripe(session, event_id) as event:
stripe_payment_method = cast(
stripe_lib.PaymentMethod, event.stripe_data.data.object
)
repository = PaymentMethodRepository.from_session(session)
payment_method = await repository.get_by_processor_id(
PaymentProcessor.stripe,
stripe_payment_method.id,
options=repository.get_eager_options(),
)
if payment_method is None:
return
await payment_method_service.delete(session, payment_method, force=True)


@actor(
actor_name="stripe.webhook.identity.verification_session.verified",
priority=TaskPriority.HIGH,
Expand Down
17 changes: 17 additions & 0 deletions server/polar/payment_method/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ async def get_by_customer_and_processor_id(
)
return await self.get_one_or_none(statement)

async def get_by_processor_id(
self,
processor: PaymentProcessor,
processor_id: str,
*,
options: Options = (),
) -> PaymentMethod | None:
statement = (
self.get_base_statement()
.where(
PaymentMethod.processor == processor,
PaymentMethod.processor_id == processor_id,
)
.options(*options)
)
return await self.get_one_or_none(statement)

async def list_by_customer(
self,
customer_id: UUID,
Expand Down
106 changes: 105 additions & 1 deletion server/tests/integrations/stripe/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,25 @@
from pytest_mock import MockerFixture
from sqlalchemy.orm import selectinload

from polar.customer.repository import CustomerRepository
from polar.enums import PaymentProcessor, SubscriptionRecurringInterval
from polar.integrations.stripe.tasks import payment_intent_succeeded
from polar.integrations.stripe.tasks import (
payment_intent_succeeded,
payment_method_detached,
)
from polar.models import (
Customer,
Organization,
PaymentMethod,
)
from polar.payment_method.repository import PaymentMethodRepository
from polar.postgres import AsyncSession
from polar.subscription.repository import SubscriptionRepository
from tests.fixtures.database import SaveFixture
from tests.fixtures.random_objects import (
create_active_subscription,
create_order,
create_payment_method,
create_product,
)

Expand Down Expand Up @@ -226,3 +232,101 @@ async def test_payment_intent_without_order_metadata_ignored(

# Then: Payment method service is not called (no retry metadata)
payment_method_service_mock.assert_not_called()


def build_stripe_detached_payment_method(
*, payment_method_id: str = "pm_test"
) -> stripe_lib.PaymentMethod:
return stripe_lib.PaymentMethod.construct_from(
{
"id": payment_method_id,
"object": "payment_method",
"type": "card",
"customer": None,
"card": {
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2030,
},
},
stripe_lib.api_key,
)


def patch_stripe_event(
mocker: MockerFixture, stripe_object: stripe_lib.StripeObject
) -> None:
event_mock = mocker.MagicMock()
event_mock.stripe_data.data.object = stripe_object

context_mock = mocker.patch(
"polar.integrations.stripe.tasks.external_event_service.handle_stripe"
)
context_mock.return_value.__aenter__ = AsyncMock(return_value=event_mock)
context_mock.return_value.__aexit__ = AsyncMock(return_value=None)


@pytest.mark.asyncio
class TestPaymentMethodDetached:
async def test_soft_deletes_matching_payment_method(
self,
mocker: MockerFixture,
session: AsyncSession,
save_fixture: SaveFixture,
customer: Customer,
) -> None:
# Given: Customer with a stored payment method set as default
payment_method = await create_payment_method(
save_fixture,
customer,
processor_id="pm_detach_test",
method_metadata={"brand": "visa", "last4": "4242"},
)
customer.default_payment_method = payment_method
await save_fixture(customer)

patch_stripe_event(
mocker,
build_stripe_detached_payment_method(payment_method_id="pm_detach_test"),
)
# Stripe already detached on their side; our delete_payment_method call
# would fail — swallow it.
mocker.patch(
"polar.payment_method.service.stripe_service.delete_payment_method",
side_effect=stripe_lib.InvalidRequestError("already detached", param=None),
)

# When: Process webhook
await payment_method_detached(uuid.uuid4())

# Then: Payment method is soft-deleted and unlinked from the customer
pm_repo = PaymentMethodRepository.from_session(session)
stored = await pm_repo.get_by_processor_id(
PaymentProcessor.stripe, "pm_detach_test"
)
assert stored is None

customer_repo = CustomerRepository.from_session(session)
refreshed_customer = await customer_repo.get_by_id(customer.id)
assert refreshed_customer is not None
assert refreshed_customer.default_payment_method_id is None

async def test_unknown_payment_method_is_noop(
self,
mocker: MockerFixture,
) -> None:
# Given: Webhook for a payment method we don't have on file
patch_stripe_event(
mocker,
build_stripe_detached_payment_method(payment_method_id="pm_unknown"),
)
delete_mock = mocker.patch(
"polar.integrations.stripe.tasks.payment_method_service.delete"
)

# When: Process webhook
await payment_method_detached(uuid.uuid4())

# Then: The service is not invoked
delete_mock.assert_not_called()
Loading