diff --git a/server/polar/refund/service.py b/server/polar/refund/service.py index 88d936f22f..4bb49b3c16 100644 --- a/server/polar/refund/service.py +++ b/server/polar/refund/service.py @@ -182,19 +182,28 @@ async def create( raise RefundUnknownPayment(order.id, payment_type="order") refund_total = refund_amount + refund_tax_amount - stripe_refund = await stripe_service.create_refund( - charge_id=payment.charge_id, - amount=refund_total, - reason=RefundReason.to_stripe(create_schema.reason), - metadata=dict( - order_id=str(order.id), - charge_id=str(payment.charge_id), - amount=str(create_schema.amount), - refund_amount=str(refund_amount), - refund_tax_amount=str(refund_tax_amount), - revoke_benefits="1" if create_schema.revoke_benefits else "0", - ), + stripe_metadata = dict( + order_id=str(order.id), + charge_id=str(payment.charge_id), + amount=str(create_schema.amount), + refund_amount=str(refund_amount), + refund_tax_amount=str(refund_tax_amount), + revoke_benefits="1" if create_schema.revoke_benefits else "0", ) + + try: + stripe_refund = await stripe_service.create_refund( + charge_id=payment.charge_id, + amount=refund_total, + reason=RefundReason.to_stripe(create_schema.reason), + metadata=stripe_metadata, + ) + except stripe_lib.InvalidRequestError as e: + if e.code == "charge_already_refunded": + raise RefundedAlready(order) + else: + raise e + internal_create_schema = self.build_create_schema_from_stripe( stripe_refund, order=order, diff --git a/server/tests/refunds/test_service.py b/server/tests/refunds/test_service.py index 773ea29cd6..2599a0faf3 100644 --- a/server/tests/refunds/test_service.py +++ b/server/tests/refunds/test_service.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock import pytest +import stripe as stripe_lib from httpx import AsyncClient, Response from pytest_mock import MockerFixture @@ -25,6 +26,7 @@ from polar.pledge.service import pledge as pledge_service from polar.postgres import AsyncSession from polar.refund.schemas import RefundCreate +from polar.refund.service import RefundedAlready from polar.refund.service import refund as refund_service from polar.transaction.service.refund import ( refund_transaction as refund_transaction_service, @@ -221,6 +223,57 @@ async def assert_transaction_amounts_from_refund( return refund_transaction +@pytest.mark.asyncio +class TestCreateAbuse(StripeRefund): + async def test_create_repeatedly( + self, + session: AsyncSession, + mocker: MockerFixture, + stripe_service_mock: MagicMock, + save_fixture: SaveFixture, + product: Product, + refund_hooks: Hooks, + customer: Customer, + ) -> None: + # Complex Swedish order. $99.9 with 25% VAT = $24.75 + order, payment = await create_order_and_payment( + save_fixture, + product=product, + customer=customer, + amount=1000, + tax_amount=250, + ) + + assert order.refunded_amount == 0 + assert order.refunded_tax_amount == 0 + + stripe_error = stripe_lib.InvalidRequestError( + "Charge py_XX has already been refunded.", + param=None, + code="charge_already_refunded", + ) + stripe_service_mock.create_refund.side_effect = stripe_error + + # Raised by us or Stripe, e.g attempting a POST request in a quick loop + with pytest.raises(RefundedAlready): + await refund_service.create( + session, + order, + RefundCreate( + order_id=order.id, + reason=RefundReason.service_disruption, + amount=1000, + comment=None, + revoke_benefits=False, + ), + ) + + order = await order_service.get(session, order.id) # type: ignore + assert order + assert order.refunded_amount == 0 + assert order.refunded_tax_amount == 0 + + @pytest.mark.asyncio class TestCreatedWebhooks(StripeRefund): async def test_valid_pledge_refund(