diff --git a/conftest.py b/conftest.py index 01394ae2..a4a9346f 100644 --- a/conftest.py +++ b/conftest.py @@ -86,6 +86,18 @@ def invoice(configured_user): ) +def get_mock_setup_intent(**params): + defaults = { + "object": "setup_intent", + "id": "mock-intent-id", + "status": "succeeded", + "currency": "gbp", + "client_secret": "secret", + } + options = {**defaults, **params} + return Mock(**options) + + def get_mock_payment_intent(webhook_event_type=None, **params): defaults = { "object": "payment_intent", diff --git a/stripe_payments/templates/stripe_payments/email/payment_error.txt b/stripe_payments/templates/stripe_payments/email/payment_error.txt index e78e5af6..6c7c7fe6 100644 --- a/stripe_payments/templates/stripe_payments/email/payment_error.txt +++ b/stripe_payments/templates/stripe_payments/email/payment_error.txt @@ -2,7 +2,7 @@ ACTION REQUIRED: CHECK STATUS OF ERROR TRANSACTION/EVENT {% if payment_intent %} Payment Error ------------- -Stripe payment intent id: {{ payment_intent.id }} +Stripe payment/setup intent id: {{ payment_intent.id }} payment status: {{ payment_intent.status }} {% endif %} {% if event_type %} diff --git a/stripe_payments/templates/stripe_payments/valid_subscription_setup.html b/stripe_payments/templates/stripe_payments/valid_subscription_setup.html index d22b6f34..e845500b 100644 --- a/stripe_payments/templates/stripe_payments/valid_subscription_setup.html +++ b/stripe_payments/templates/stripe_payments/valid_subscription_setup.html @@ -16,7 +16,7 @@

Membership setup succeeded

{% if payment %}

{% if updating %} - Thank you for updating up your membership. Your transaction for your next month's membership has been completed and you'll receive confirmation by email shortly. + Thank you for updating your membership. Your transaction for your next month's membership has been completed and you'll receive confirmation by email shortly. {% else %} Thank you for setting up your membership. Your transaction for your first month's membership has been completed and you'll receive confirmation by email shortly. {% endif %} @@ -24,7 +24,7 @@

Membership setup succeeded

{% elif setup %}

{% if updating %} - Thank you for updating up your membership. Your payment for your next month's membership will be taken on the 25th of the month. + Thank you for updating your membership. Your payment for your next month's membership will be taken on the 25th of the month. {% else %} Thank you for setting up your membership. You'll receive confirmation by email shortly. Your first payment will be taken on the 25th of the month. {% endif %} diff --git a/stripe_payments/tests/mock_connector.py b/stripe_payments/tests/mock_connector.py index 6066a1f0..1dbbf527 100644 --- a/stripe_payments/tests/mock_connector.py +++ b/stripe_payments/tests/mock_connector.py @@ -145,4 +145,4 @@ def customer_portal_configuration(self): def customer_portal_url(self, customer_id): self._record(self.customer_portal_url, [customer_id]) - raise NotImplementedError + return f"https://example.com/portal/{customer_id}/" diff --git a/stripe_payments/tests/test_stripe_views.py b/stripe_payments/tests/test_stripe_views.py index 0491f2c1..a90041f8 100644 --- a/stripe_payments/tests/test_stripe_views.py +++ b/stripe_payments/tests/test_stripe_views.py @@ -8,7 +8,7 @@ import stripe from model_bakery import baker -from conftest import get_mock_payment_intent +from conftest import get_mock_payment_intent, get_mock_setup_intent from booking.models import Block, Booking, TicketBooking, Ticket from ..models import Invoice, StripePaymentIntent from .mock_connector import MockConnector @@ -388,3 +388,109 @@ def test_return_with_processing_payment_intent(mock_payment_intent, client, conf assert "Your payment is processing" in resp.content.decode("utf-8") assert len(mail.outbox) == 1 assert mail.outbox[0].to == [settings.SUPPORT_EMAIL] + + +# stripe portal +@pytest.mark.usefixtures("seller") +@patch("stripe_payments.views.views.StripeConnector", MockConnector) +def test_stripe_portal_view(client): + resp = client.get(reverse("stripe_payments:stripe_portal", args=("customer-id-123",))) + assert resp.status_code == 302 + assert resp.url == f"https://example.com/portal/customer-id-123/" + + +# stripe subscribe view (stripe return url for subscriptions) + +subscribe_complete_url = reverse("stripe_payments:stripe_subscribe_complete") + +@pytest.mark.parametrize( + "payment_intent_status,updating,success,template_match", + [ + ("succeeded", False, True, "Thank you for setting up your membership"), + ("succeeded", True, True, "Thank you for updating your membership"), + ("processing", True, False, "Your payment is processing"), + ("unknown", False, False, "Error Processing Payment"), + ] +) +@pytest.mark.usefixtures("seller") +@patch("stripe_payments.utils.stripe.PaymentIntent") +def test_stripe_subscribe_complete_with_payment_intent(mock_payment_intent, client, payment_intent_status, updating, success, template_match): + mock_payment_intent.retrieve.return_value = get_mock_payment_intent(status=payment_intent_status) + url = f"{subscribe_complete_url}?payment_intent=pi_123" + if updating: + url += "&updating=true" + resp = client.get(url) + assert resp.status_code == 200 + + assert template_match in resp.content.decode() + if success: + assert resp.context["updating"] == updating + assert resp.context["payment"] + assert len(mail.outbox) == 0 + else: + assert len(mail.outbox) == 1 + assert "Something went wrong processing a stripe event" in mail.outbox[0].subject + + +@pytest.mark.usefixtures("seller") +@patch("stripe_payments.utils.stripe.PaymentIntent") +def test_stripe_subscribe_complete_with_payment_intent_error(mock_payment_intent, client): + mock_payment_intent.retrieve.side_effect = stripe.InvalidRequestError("err", None) + url = f"{subscribe_complete_url}?payment_intent=pi_123" + resp = client.get(url) + assert resp.status_code == 200 + + assert "Error Processing Payment" in resp.content.decode() + assert len(mail.outbox) == 1 + assert "Something went wrong processing a stripe event" in mail.outbox[0].subject + + +@pytest.mark.parametrize( + "setup_intent_status,updating,success,template_match", + [ + ("succeeded", False, True, "Thank you for setting up your membership"), + ("succeeded", True, True, "Thank you for updating your membership"), + ("processing", True, False, "Your payment is processing"), + ("unknown", False, False, "Error Processing Payment"), + ] +) +@pytest.mark.usefixtures("seller") +@patch("stripe_payments.utils.stripe.SetupIntent") +def test_stripe_subscribe_complete_with_setup_intent(mock_setup_intent, client, setup_intent_status, updating, success, template_match): + mock_setup_intent.retrieve.return_value = get_mock_setup_intent(status=setup_intent_status) + url = f"{subscribe_complete_url}?setup_intent=su_123" + if updating: + url += "&updating=true" + resp = client.get(url) + assert resp.status_code == 200 + assert template_match in resp.content.decode() + if success: + assert resp.context["updating"] == updating + assert resp.context["setup"] + assert len(mail.outbox) == 0 + else: + assert len(mail.outbox) == 1 + assert "Something went wrong processing a stripe event" in mail.outbox[0].subject + + +@pytest.mark.usefixtures("seller") +@patch("stripe_payments.utils.stripe.SetupIntent") +def test_stripe_subscribe_complete_with_setup_intent_error(mock_setup_intent, client): + mock_setup_intent.retrieve.side_effect = stripe.InvalidRequestError("err", None) + url = f"{subscribe_complete_url}?setup_intent=su_123" + resp = client.get(url) + assert resp.status_code == 200 + + assert "Error Processing Payment" in resp.content.decode() + assert len(mail.outbox) == 1 + assert "Something went wrong processing a stripe event" in mail.outbox[0].subject + + +@pytest.mark.usefixtures("seller") +def test_stripe_subscribe_complete_with_unknown_payment_type(client): + resp = client.get(subscribe_complete_url) + assert resp.status_code == 200 + + assert "Error Processing Payment" in resp.content.decode() + assert len(mail.outbox) == 1 + assert "Something went wrong processing a stripe event" in mail.outbox[0].subject \ No newline at end of file diff --git a/stripe_payments/tests/test_stripe_webhook_views.py b/stripe_payments/tests/test_stripe_webhook_views.py index 9ef06bcd..1228e302 100644 --- a/stripe_payments/tests/test_stripe_webhook_views.py +++ b/stripe_payments/tests/test_stripe_webhook_views.py @@ -239,13 +239,14 @@ def test_webhook_authorized_account_no_seller( webhook_event_type="account.application.authorized" ) mock_account.list.return_value = Mock(data=[Mock(id="stripe-account-1")]) - + Seller.objects.all().delete() resp = client.post(webhook_url, data={}, HTTP_STRIPE_SIGNATURE="foo") - assert resp.status_code == 400 + assert resp.status_code == 200 + assert "Stripe account has no associated seller on this site" in resp.content.decode() @patch("stripe_payments.views.webhook.stripe.Webhook") -def test_webhook_authorized_account_mismatched_seller( +def test_webhook_payment_intent_succeeded_mismatched_seller( mock_webhook, get_mock_webhook_event, client, invoice ): baker.make(Block, paid=True, block_type__cost=10, invoice=invoice) @@ -264,7 +265,7 @@ def test_webhook_authorized_account_mismatched_seller( @patch("stripe_payments.views.webhook.stripe.Webhook") -def test_webhook_authorized_account_no_seller( +def test_webhook_payment_intent_succeeded_no_seller( mock_webhook, get_mock_webhook_event, client, invoice ): baker.make(Block, paid=True, block_type__cost=10, invoice=invoice) @@ -321,6 +322,30 @@ def test_webhook_subscription_created( assert membership.user_memberships.first().start_date.date() == datetime(2024, 7, 1).date() +@patch("booking.models.membership_models.StripeConnector", MockConnector) +@patch("stripe_payments.views.webhook.stripe.Webhook") +def test_webhook_subscription_created_setup_pending( + mock_webhook, get_mock_webhook_event, client, configured_stripe_user +): + membership = baker.make(Membership, name="membership1") + assert not membership.user_memberships.exists() + mock_webhook.construct_event.return_value = get_mock_webhook_event( + webhook_event_type="customer.subscription.created", + start_date = datetime(2024, 6, 25).timestamp(), + pending_setup_intent="su_123", + status="active", + ) + resp = client.post(webhook_url, data={}, HTTP_STRIPE_SIGNATURE="foo") + assert resp.status_code == 200 + # no emails sent for initial creation with no default payment method (i.e. setup but not paid/confirmed yet) + assert len(mail.outbox) == 0 + # membership created, with start date as first of next month + assert membership.user_memberships.count() == 1 + user_membership = membership.user_memberships.first() + assert user_membership.start_date.date() == datetime(2024, 7, 1).date() + assert user_membership.subscription_status == "setup_pending" + + @patch("booking.models.membership_models.StripeConnector", MockConnector) @patch("stripe_payments.views.webhook.stripe.Webhook") def test_webhook_setup_intent_succeeded_for_subscription_with_user_membership( @@ -508,6 +533,34 @@ def test_webhook_subscription_updated_status_changed_to_active_from_incomplete( assert paid_booking.membership is None + +@pytest.mark.freeze_time("2024-02-26") +@patch("booking.models.membership_models.StripeConnector", MockConnector) +@patch("stripe_payments.views.webhook.stripe.Webhook") +def test_webhook_subscription_updated_status_changed_to_incomplete_expired( + mock_webhook, get_mock_webhook_event, client, configured_stripe_user +): + mock_webhook.construct_event.return_value = get_mock_webhook_event( + webhook_event_type="customer.subscription.updated" + ) + # status incomplete, changed to incomplete_expired deletes UserMembership + membership = baker.make(Membership, name="membership1") + baker.make( + UserMembership, membership=membership, user=configured_stripe_user, subscription_id="id", + start_date=datetime(2024, 1, 25, tzinfo=datetime_tz.utc), subscription_status="incomplete" + ) + assert membership.user_memberships.count() == 1 + mock_webhook.construct_event.return_value = get_mock_webhook_event( + webhook_event_type="customer.subscription.updated", + status="incomplete_expired", + canceled_at=None, + ) + resp = client.post(webhook_url, data={}, HTTP_STRIPE_SIGNATURE="foo") + assert resp.status_code == 200, resp.content + + assert not membership.user_memberships.exists() + + @patch("booking.models.membership_models.StripeConnector", MockConnector) @patch("stripe_payments.views.webhook.stripe.Webhook") def test_webhook_subscription_updated_status_scheduled_to_cancel( @@ -753,3 +806,16 @@ def test_webhook_refund_updated_for_subscription( assert resp.status_code == 200, resp.content assert len(mail.outbox) == 1 assert mail.outbox[0].to == [settings.SUPPORT_EMAIL] + + +@patch("stripe_payments.views.webhook.get_invoice_from_event_metadata") +@patch("stripe_payments.views.webhook.stripe.Webhook") +def test_webhook_unexpected_exception( + mock_webhook, mock_get_invoice, get_mock_webhook_event, client +): + mock_webhook.construct_event.return_value = get_mock_webhook_event() + mock_get_invoice.side_effect = Exception("err") + resp = client.post(webhook_url, data={}, HTTP_STRIPE_SIGNATURE="foo") + assert resp.status_code == 400, resp.content + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [settings.SUPPORT_EMAIL] diff --git a/stripe_payments/utils.py b/stripe_payments/utils.py index 46fdd7c0..aab978be 100644 --- a/stripe_payments/utils.py +++ b/stripe_payments/utils.py @@ -221,9 +221,9 @@ def update_stripe_customer(self, customer_id, **kwargs): **kwargs ) - def get_payment_intent(self, setup_intent_id): + def get_payment_intent(self, payment_intent_id): return stripe.PaymentIntent.retrieve( - id=setup_intent_id, stripe_account=self.connected_account_id + id=payment_intent_id, stripe_account=self.connected_account_id ) def get_setup_intent(self, setup_intent_id): diff --git a/stripe_payments/views/views.py b/stripe_payments/views/views.py index 734361f5..b1b764da 100644 --- a/stripe_payments/views/views.py +++ b/stripe_payments/views/views.py @@ -75,17 +75,22 @@ def stripe_payment_complete(request): def stripe_subscribe_complete(request): subscribe_type = None - intent_id = request.GET.get("payment_intent") - updating = request.GET.get("updating", False) - if intent_id: + updating = "updating" in request.GET + + if "payment_intent" in request.GET: + intent_id = request.GET.get("payment_intent") subscribe_type = "payment" - else: + elif "setup_intent" in request.GET: intent_id = request.GET.get("setup_intent") subscribe_type = "setup" if subscribe_type is None: error = f"Could not identify payment or setup intent for subscription" logger.error(error) + send_failed_payment_emails( + payment_intent=None, + error=error + ) return render(request, 'stripe_payments/non_valid_payment.html') client = StripeConnector(request) @@ -94,7 +99,7 @@ def stripe_subscribe_complete(request): # All confirmation emails are handled in the webhook if subscribe_type == "payment": try: - intent = stripe.PaymentIntent.retrieve(intent_id, stripe_account=client.connected_account_id) + intent = client.get_payment_intent(intent_id) except stripe.error.InvalidRequestError as e: error = f"Error retrieving Stripe payment intent: {e}" logger.error(e) @@ -106,7 +111,7 @@ def stripe_subscribe_complete(request): else: assert subscribe_type == "setup" try: - intent = stripe.SetupIntent.retrieve(intent_id, stripe_account=client.connected_account_id) + intent = client.get_setup_intent(intent_id) except stripe.error.InvalidRequestError as e: error = f"Error retrieving Stripe setup intent: {e}" logger.error(e) @@ -132,7 +137,6 @@ def stripe_subscribe_complete(request): assert subscribe_type == "setup" if intent.status == "succeeded": - # _process_completed_stripe_subscription(intent, client.connected_account, subscribe_type=subscribe_type, request=request) return render(request, 'stripe_payments/valid_subscription_setup.html', {"setup": True, "updating": updating}) elif intent.status == "processing": error = f"Setup intent {intent.id} still processing."