diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index 5cc27b698f..ed5781a39a 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -1496,18 +1496,42 @@ class BillingSession(ABC): current_plan.save(update_fields=["status", "next_invoice_date"]) return f"Fixed price {required_plan_tier_name} plan scheduled to start on {current_plan.end_date.date()}." + # TODO: Use normal 'pay by invoice' flow for fixed-price plan offers, + # which requires handling automated license management for these plan + # offers via that flow. if sent_invoice_id is not None: sent_invoice_id = sent_invoice_id.strip() - # Verify 'sent_invoice_id' before storing in database. + # Verify 'sent_invoice_id' and 'stripe_customer_id' before + # storing in database. try: invoice = stripe.Invoice.retrieve(sent_invoice_id) if invoice.status != "open": raise SupportRequestError( "Invoice status should be open. Please verify sent_invoice_id." ) + invoice_customer_id = invoice.customer + if not invoice_customer_id: # nocoverage + raise SupportRequestError( + "Invoice missing Stripe customer ID. Please review invoice." + ) + if customer.stripe_customer_id and customer.stripe_customer_id != str( + invoice_customer_id + ): # nocoverage + raise SupportRequestError( + "Invoice Stripe customer ID does not match. Please attach invoice to correct customer in Stripe." + ) except Exception as e: raise SupportRequestError(str(e)) + if customer.stripe_customer_id is None: + # Note this is an exception to our normal support panel actions, + # which do not set any stripe billing information. Since these + # invoices are manually created first in stripe, it's important + # for our billing page to have our Customer object correctly + # linked to the customer in stripe. + customer.stripe_customer_id = str(invoice_customer_id) + customer.save(update_fields=["stripe_customer_id"]) + fixed_price_plan_params["sent_invoice_id"] = sent_invoice_id Invoice.objects.create( customer=customer, diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index 548607cf77..f2e21710f6 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -7686,11 +7686,19 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase): ["Invoice status should be open. Please verify sent_invoice_id."], result ) + hamlet = self.example_user("hamlet") sent_invoice_id = "test_sent_invoice_id" + stripe_customer_id = "cus_123" mock_invoice = MagicMock() mock_invoice.status = "open" mock_invoice.sent_invoice_id = sent_invoice_id - with mock.patch("stripe.Invoice.retrieve", return_value=mock_invoice): + with ( + patch( + "stripe.Customer.retrieve", + return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email), + ), + patch("stripe.Invoice.retrieve", return_value=mock_invoice), + ): result = self.client_post( "/activity/remote/support", { @@ -7716,7 +7724,6 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase): self.logout() self.login("hamlet") - hamlet = self.example_user("hamlet") # Customer don't need to visit /upgrade to buy plan. # In case they visit, we inform them about the mail to which @@ -7726,7 +7733,15 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase): mock_invoice.hosted_invoice_url = "payments_page_url" with ( time_machine.travel(self.now, tick=False), - mock.patch("stripe.Invoice.retrieve", return_value=mock_invoice), + patch( + "corporate.lib.stripe.customer_has_credit_card_as_default_payment_method", + return_value=False, + ), + patch( + "stripe.Customer.retrieve", + return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email), + ), + patch("stripe.Invoice.retrieve", return_value=mock_invoice), ): result = self.client_get( f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}", @@ -7768,6 +7783,34 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase): self.assertEqual(invoice.status, Invoice.PAID) self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.PROCESSED) + # Visit /billing + self.execute_remote_billing_authentication_flow( + hamlet, expect_tos=False, first_time_login=False + ) + with ( + time_machine.travel(self.now + timedelta(days=1), tick=False), + patch( + "corporate.lib.stripe.customer_has_credit_card_as_default_payment_method", + return_value=False, + ), + patch( + "stripe.Customer.retrieve", + return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email), + ), + patch("stripe.Invoice.retrieve", return_value=mock_invoice), + ): + response = self.client_get( + f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting" + ) + for substring in [ + "Zulip Basic", + hamlet.delivery_email, + "Annual", + "This is a fixed-price plan", + "You will be contacted by Zulip Sales", + ]: + self.assert_in_response(substring, response) + @responses.activate @mock_stripe() def test_schedule_upgrade_to_fixed_price_annual_business_plan(self, *mocks: Mock) -> None: @@ -9505,12 +9548,20 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase): ) # Configure fixed-price plan with ID of manually sent invoice. + hamlet = self.example_user("hamlet") sent_invoice_id = "test_sent_invoice_id" + stripe_customer_id = "cus_123" annual_fixed_price = 1200 mock_invoice = MagicMock() mock_invoice.status = "open" mock_invoice.sent_invoice_id = sent_invoice_id - with mock.patch("stripe.Invoice.retrieve", return_value=mock_invoice): + with ( + patch( + "stripe.Customer.retrieve", + return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email), + ), + patch("stripe.Invoice.retrieve", return_value=mock_invoice), + ): result = self.client_post( "/activity/remote/support", { @@ -9536,7 +9587,6 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase): self.logout() self.login("hamlet") - hamlet = self.example_user("hamlet") # Customer don't need to visit /upgrade to buy plan. # In case they visit, we inform them about the mail to which @@ -9546,7 +9596,15 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase): mock_invoice.hosted_invoice_url = "payments_page_url" with ( time_machine.travel(self.now, tick=False), - mock.patch("stripe.Invoice.retrieve", return_value=mock_invoice), + patch( + "corporate.lib.stripe.customer_has_credit_card_as_default_payment_method", + return_value=False, + ), + patch( + "stripe.Customer.retrieve", + return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email), + ), + patch("stripe.Invoice.retrieve", return_value=mock_invoice), ): result = self.client_get( f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}", @@ -9588,6 +9646,34 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase): self.assertEqual(invoice.status, Invoice.PAID) self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.PROCESSED) + # Visit /billing + self.execute_remote_billing_authentication_flow( + hamlet.delivery_email, hamlet.full_name, expect_tos=False + ) + with ( + time_machine.travel(self.now + timedelta(days=1), tick=False), + patch( + "corporate.lib.stripe.customer_has_credit_card_as_default_payment_method", + return_value=False, + ), + patch( + "stripe.Customer.retrieve", + return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email), + ), + patch("stripe.Invoice.retrieve", return_value=mock_invoice), + ): + response = self.client_get( + f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting" + ) + for substring in [ + "Zulip Basic", + hamlet.delivery_email, + "Annual", + "This is a fixed-price plan", + "You will be contacted by Zulip Sales", + ]: + self.assert_in_response(substring, response) + @responses.activate @mock_stripe() def test_schedule_server_upgrade_to_fixed_price_business_plan(self, *mocks: Mock) -> None: