mirror of https://github.com/zulip/zulip.git
billing: Split views into upgrade and billing page views.
This is a prep commit for the Stripe checkout migration. The Stripe migration commit adds a lot of new view functions. Keeping all of the views in one view file makes it super hard for readbability. So creating a new views folder and splitting the existing view file into two so that we minimize the changes in the big migration commit.
This commit is contained in:
parent
199d3859fb
commit
55a9a019a0
|
@ -653,7 +653,7 @@ class StripeTest(StripeTestCase):
|
||||||
self.assertEqual("/billing/", response.url)
|
self.assertEqual("/billing/", response.url)
|
||||||
|
|
||||||
# Check /billing has the correct information
|
# Check /billing has the correct information
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_get("/billing/")
|
response = self.client_get("/billing/")
|
||||||
self.assert_not_in_success_response(["Pay annually"], response)
|
self.assert_not_in_success_response(["Pay annually"], response)
|
||||||
for substring in [
|
for substring in [
|
||||||
|
@ -792,7 +792,7 @@ class StripeTest(StripeTestCase):
|
||||||
self.assertEqual("/billing/", response.url)
|
self.assertEqual("/billing/", response.url)
|
||||||
|
|
||||||
# Check /billing has the correct information
|
# Check /billing has the correct information
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_get("/billing/")
|
response = self.client_get("/billing/")
|
||||||
self.assert_not_in_success_response(["Pay annually", "Update card"], response)
|
self.assert_not_in_success_response(["Pay annually", "Update card"], response)
|
||||||
for substring in [
|
for substring in [
|
||||||
|
@ -900,7 +900,7 @@ class StripeTest(StripeTestCase):
|
||||||
self.assertEqual(realm.plan_type, Realm.STANDARD)
|
self.assertEqual(realm.plan_type, Realm.STANDARD)
|
||||||
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_get("/billing/")
|
response = self.client_get("/billing/")
|
||||||
self.assert_not_in_success_response(["Pay annually"], response)
|
self.assert_not_in_success_response(["Pay annually"], response)
|
||||||
for substring in [
|
for substring in [
|
||||||
|
@ -919,7 +919,7 @@ class StripeTest(StripeTestCase):
|
||||||
self.assert_in_response(substring, response)
|
self.assert_in_response(substring, response)
|
||||||
self.assert_not_in_success_response(["Go to your Zulip organization"], response)
|
self.assert_not_in_success_response(["Go to your Zulip organization"], response)
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_get("/billing/", {"onboarding": "true"})
|
response = self.client_get("/billing/", {"onboarding": "true"})
|
||||||
self.assert_in_success_response(["Go to your Zulip organization"], response)
|
self.assert_in_success_response(["Go to your Zulip organization"], response)
|
||||||
|
|
||||||
|
@ -1101,7 +1101,7 @@ class StripeTest(StripeTestCase):
|
||||||
self.assertEqual(realm.plan_type, Realm.STANDARD)
|
self.assertEqual(realm.plan_type, Realm.STANDARD)
|
||||||
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_get("/billing/")
|
response = self.client_get("/billing/")
|
||||||
self.assert_not_in_success_response(["Pay annually"], response)
|
self.assert_not_in_success_response(["Pay annually"], response)
|
||||||
for substring in [
|
for substring in [
|
||||||
|
@ -1270,7 +1270,7 @@ class StripeTest(StripeTestCase):
|
||||||
|
|
||||||
# Try again, with a valid card, after they added a few users
|
# Try again, with a valid card, after they added a few users
|
||||||
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=23):
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=23):
|
||||||
with patch("corporate.views.get_latest_seat_count", return_value=23):
|
with patch("corporate.views.upgrade.get_latest_seat_count", return_value=23):
|
||||||
self.upgrade()
|
self.upgrade()
|
||||||
customer = Customer.objects.get(realm=get_realm("zulip"))
|
customer = Customer.objects.get(realm=get_realm("zulip"))
|
||||||
# It's impossible to create two Customers, but check that we didn't
|
# It's impossible to create two Customers, but check that we didn't
|
||||||
|
@ -1397,7 +1397,7 @@ class StripeTest(StripeTestCase):
|
||||||
else:
|
else:
|
||||||
del_args = []
|
del_args = []
|
||||||
upgrade_params["licenses"] = licenses
|
upgrade_params["licenses"] = licenses
|
||||||
with patch("corporate.views.process_initial_upgrade"):
|
with patch("corporate.views.upgrade.process_initial_upgrade"):
|
||||||
response = self.upgrade(
|
response = self.upgrade(
|
||||||
invoice=invoice, talk_to_stripe=False, del_args=del_args, **upgrade_params
|
invoice=invoice, talk_to_stripe=False, del_args=del_args, **upgrade_params
|
||||||
)
|
)
|
||||||
|
@ -1442,7 +1442,7 @@ class StripeTest(StripeTestCase):
|
||||||
hamlet = self.example_user("hamlet")
|
hamlet = self.example_user("hamlet")
|
||||||
self.login_user(hamlet)
|
self.login_user(hamlet)
|
||||||
with patch(
|
with patch(
|
||||||
"corporate.views.process_initial_upgrade", side_effect=Exception
|
"corporate.views.upgrade.process_initial_upgrade", side_effect=Exception
|
||||||
), self.assertLogs("corporate.stripe", "WARNING") as m:
|
), self.assertLogs("corporate.stripe", "WARNING") as m:
|
||||||
response = self.upgrade(talk_to_stripe=False)
|
response = self.upgrade(talk_to_stripe=False)
|
||||||
self.assertIn("ERROR:corporate.stripe:Uncaught exception in billing", m.output[0])
|
self.assertIn("ERROR:corporate.stripe:Uncaught exception in billing", m.output[0])
|
||||||
|
@ -1844,7 +1844,7 @@ class StripeTest(StripeTestCase):
|
||||||
self.assertEqual(plan.licenses(), self.seat_count)
|
self.assertEqual(plan.licenses(), self.seat_count)
|
||||||
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
|
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
|
||||||
with self.assertLogs("corporate.stripe", "INFO") as m:
|
with self.assertLogs("corporate.stripe", "INFO") as m:
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_patch(
|
response = self.client_patch(
|
||||||
"/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}
|
"/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}
|
||||||
)
|
)
|
||||||
|
@ -1858,9 +1858,11 @@ class StripeTest(StripeTestCase):
|
||||||
self.assertEqual(plan.licenses(), self.seat_count)
|
self.assertEqual(plan.licenses(), self.seat_count)
|
||||||
self.assertEqual(plan.licenses_at_next_renewal(), None)
|
self.assertEqual(plan.licenses_at_next_renewal(), None)
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
mock_customer = Mock(email=user.delivery_email, default_source=None)
|
mock_customer = Mock(email=user.delivery_email, default_source=None)
|
||||||
with patch("corporate.views.stripe_get_customer", return_value=mock_customer):
|
with patch(
|
||||||
|
"corporate.views.billing_page.stripe_get_customer", return_value=mock_customer
|
||||||
|
):
|
||||||
response = self.client_get("/billing/")
|
response = self.client_get("/billing/")
|
||||||
self.assert_in_success_response(
|
self.assert_in_success_response(
|
||||||
[
|
[
|
||||||
|
@ -1951,7 +1953,7 @@ class StripeTest(StripeTestCase):
|
||||||
assert new_plan is not None
|
assert new_plan is not None
|
||||||
|
|
||||||
with self.assertLogs("corporate.stripe", "INFO") as m:
|
with self.assertLogs("corporate.stripe", "INFO") as m:
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_patch(
|
response = self.client_patch(
|
||||||
"/json/billing/plan",
|
"/json/billing/plan",
|
||||||
{"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE},
|
{"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE},
|
||||||
|
@ -1961,7 +1963,7 @@ class StripeTest(StripeTestCase):
|
||||||
self.assert_json_success(response)
|
self.assert_json_success(response)
|
||||||
monthly_plan.refresh_from_db()
|
monthly_plan.refresh_from_db()
|
||||||
self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE)
|
self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE)
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_get("/billing/")
|
response = self.client_get("/billing/")
|
||||||
self.assert_in_success_response(
|
self.assert_in_success_response(
|
||||||
["be switched from monthly to annual billing on <strong>February 2, 2012"], response
|
["be switched from monthly to annual billing on <strong>February 2, 2012"], response
|
||||||
|
@ -2137,7 +2139,7 @@ class StripeTest(StripeTestCase):
|
||||||
new_plan = get_current_plan_by_realm(user.realm)
|
new_plan = get_current_plan_by_realm(user.realm)
|
||||||
assert new_plan is not None
|
assert new_plan is not None
|
||||||
with self.assertLogs("corporate.stripe", "INFO") as m:
|
with self.assertLogs("corporate.stripe", "INFO") as m:
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_patch(
|
response = self.client_patch(
|
||||||
"/json/billing/plan",
|
"/json/billing/plan",
|
||||||
{"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE},
|
{"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE},
|
||||||
|
@ -2149,7 +2151,7 @@ class StripeTest(StripeTestCase):
|
||||||
self.assert_json_success(response)
|
self.assert_json_success(response)
|
||||||
monthly_plan.refresh_from_db()
|
monthly_plan.refresh_from_db()
|
||||||
self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE)
|
self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE)
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_get("/billing/")
|
response = self.client_get("/billing/")
|
||||||
self.assert_in_success_response(
|
self.assert_in_success_response(
|
||||||
["be switched from monthly to annual billing on <strong>February 2, 2012"], response
|
["be switched from monthly to annual billing on <strong>February 2, 2012"], response
|
||||||
|
@ -2238,7 +2240,7 @@ class StripeTest(StripeTestCase):
|
||||||
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
|
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
|
||||||
self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, "token")
|
self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, "token")
|
||||||
with self.assertLogs("corporate.stripe", "INFO") as m:
|
with self.assertLogs("corporate.stripe", "INFO") as m:
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_patch(
|
response = self.client_patch(
|
||||||
"/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}
|
"/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}
|
||||||
)
|
)
|
||||||
|
@ -2252,7 +2254,7 @@ class StripeTest(StripeTestCase):
|
||||||
CustomerPlan.objects.first().status, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
|
CustomerPlan.objects.first().status, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
|
||||||
)
|
)
|
||||||
with self.assertLogs("corporate.stripe", "INFO") as m:
|
with self.assertLogs("corporate.stripe", "INFO") as m:
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_patch("/json/billing/plan", {"status": CustomerPlan.ACTIVE})
|
response = self.client_patch("/json/billing/plan", {"status": CustomerPlan.ACTIVE})
|
||||||
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.ACTIVE}"
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.ACTIVE}"
|
||||||
self.assertEqual(m.output[0], expected_log)
|
self.assertEqual(m.output[0], expected_log)
|
||||||
|
@ -2276,7 +2278,7 @@ class StripeTest(StripeTestCase):
|
||||||
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
||||||
new_plan = get_current_plan_by_realm(user.realm)
|
new_plan = get_current_plan_by_realm(user.realm)
|
||||||
assert new_plan is not None
|
assert new_plan is not None
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
self.client_patch(
|
self.client_patch(
|
||||||
"/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}
|
"/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}
|
||||||
)
|
)
|
||||||
|
@ -2314,7 +2316,7 @@ class StripeTest(StripeTestCase):
|
||||||
|
|
||||||
self.login_user(user)
|
self.login_user(user)
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
self.client_patch("/json/billing/plan", {"status": CustomerPlan.ENDED})
|
self.client_patch("/json/billing/plan", {"status": CustomerPlan.ENDED})
|
||||||
|
|
||||||
plan.refresh_from_db()
|
plan.refresh_from_db()
|
||||||
|
@ -2347,7 +2349,7 @@ class StripeTest(StripeTestCase):
|
||||||
|
|
||||||
self.login_user(user)
|
self.login_user(user)
|
||||||
with self.assertLogs("corporate.stripe", "INFO") as m:
|
with self.assertLogs("corporate.stripe", "INFO") as m:
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
self.client_patch(
|
self.client_patch(
|
||||||
"/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}
|
"/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}
|
||||||
)
|
)
|
||||||
|
@ -2399,35 +2401,35 @@ class StripeTest(StripeTestCase):
|
||||||
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
|
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
|
||||||
self.upgrade(invoice=True, licenses=100)
|
self.upgrade(invoice=True, licenses=100)
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
result = self.client_patch("/json/billing/plan", {"licenses": 100})
|
result = self.client_patch("/json/billing/plan", {"licenses": 100})
|
||||||
self.assert_json_error_contains(
|
self.assert_json_error_contains(
|
||||||
result, "Your plan is already on 100 licenses in the current billing period."
|
result, "Your plan is already on 100 licenses in the current billing period."
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 100})
|
result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 100})
|
||||||
self.assert_json_error_contains(
|
self.assert_json_error_contains(
|
||||||
result, "Your plan is already scheduled to renew with 100 licenses."
|
result, "Your plan is already scheduled to renew with 100 licenses."
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
result = self.client_patch("/json/billing/plan", {"licenses": 50})
|
result = self.client_patch("/json/billing/plan", {"licenses": 50})
|
||||||
self.assert_json_error_contains(
|
self.assert_json_error_contains(
|
||||||
result, "You cannot decrease the licenses in the current billing period."
|
result, "You cannot decrease the licenses in the current billing period."
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 25})
|
result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 25})
|
||||||
self.assert_json_error_contains(result, "You must invoice for at least 30 users.")
|
self.assert_json_error_contains(result, "You must invoice for at least 30 users.")
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
result = self.client_patch("/json/billing/plan", {"licenses": 2000})
|
result = self.client_patch("/json/billing/plan", {"licenses": 2000})
|
||||||
self.assert_json_error_contains(
|
self.assert_json_error_contains(
|
||||||
result, "Invoices with more than 1000 licenses can't be processed from this page."
|
result, "Invoices with more than 1000 licenses can't be processed from this page."
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
result = self.client_patch("/json/billing/plan", {"licenses": 150})
|
result = self.client_patch("/json/billing/plan", {"licenses": 150})
|
||||||
self.assert_json_success(result)
|
self.assert_json_success(result)
|
||||||
invoice_plans_as_needed(self.next_year)
|
invoice_plans_as_needed(self.next_year)
|
||||||
|
@ -2477,7 +2479,7 @@ class StripeTest(StripeTestCase):
|
||||||
for key, value in line_item_params.items():
|
for key, value in line_item_params.items():
|
||||||
self.assertEqual(extra_license_item.get(key), value)
|
self.assertEqual(extra_license_item.get(key), value)
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.next_year):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.next_year):
|
||||||
result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 120})
|
result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 120})
|
||||||
self.assert_json_success(result)
|
self.assert_json_success(result)
|
||||||
invoice_plans_as_needed(self.next_year + timedelta(days=365))
|
invoice_plans_as_needed(self.next_year + timedelta(days=365))
|
||||||
|
@ -2520,11 +2522,11 @@ class StripeTest(StripeTestCase):
|
||||||
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
|
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
|
||||||
self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, "token")
|
self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, "token")
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
result = self.client_patch("/json/billing/plan", {"licenses": 100})
|
result = self.client_patch("/json/billing/plan", {"licenses": 100})
|
||||||
self.assert_json_error_contains(result, "Your plan is on automatic license management.")
|
self.assert_json_error_contains(result, "Your plan is on automatic license management.")
|
||||||
|
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 100})
|
result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 100})
|
||||||
self.assert_json_error_contains(result, "Your plan is on automatic license management.")
|
self.assert_json_error_contains(result, "Your plan is on automatic license management.")
|
||||||
|
|
||||||
|
@ -2544,7 +2546,7 @@ class StripeTest(StripeTestCase):
|
||||||
self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, "token")
|
self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, "token")
|
||||||
|
|
||||||
self.login_user(self.example_user("hamlet"))
|
self.login_user(self.example_user("hamlet"))
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
response = self.client_patch("/json/billing/plan", {})
|
response = self.client_patch("/json/billing/plan", {})
|
||||||
self.assert_json_error_contains(response, "Nothing to change")
|
self.assert_json_error_contains(response, "Nothing to change")
|
||||||
|
|
||||||
|
@ -2554,7 +2556,7 @@ class StripeTest(StripeTestCase):
|
||||||
|
|
||||||
self.login_user(self.example_user("hamlet"))
|
self.login_user(self.example_user("hamlet"))
|
||||||
with self.assertLogs("corporate.stripe", "INFO") as m:
|
with self.assertLogs("corporate.stripe", "INFO") as m:
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
result = self.client_patch(
|
result = self.client_patch(
|
||||||
"/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}
|
"/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}
|
||||||
)
|
)
|
||||||
|
@ -2576,7 +2578,7 @@ class StripeTest(StripeTestCase):
|
||||||
|
|
||||||
self.login_user(self.example_user("hamlet"))
|
self.login_user(self.example_user("hamlet"))
|
||||||
with self.assertLogs("corporate.stripe", "INFO") as m:
|
with self.assertLogs("corporate.stripe", "INFO") as m:
|
||||||
with patch("corporate.views.timezone_now", return_value=self.now):
|
with patch("corporate.views.billing_page.timezone_now", return_value=self.now):
|
||||||
result = self.client_patch(
|
result = self.client_patch(
|
||||||
"/json/billing/plan", {"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}
|
"/json/billing/plan", {"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}
|
||||||
)
|
)
|
||||||
|
@ -2921,14 +2923,14 @@ class RequiresBillingAccessTest(ZulipTestCase):
|
||||||
def test_who_can_access_json_endpoints(self) -> None:
|
def test_who_can_access_json_endpoints(self) -> None:
|
||||||
# Billing admins have access
|
# Billing admins have access
|
||||||
self.login_user(self.example_user("hamlet"))
|
self.login_user(self.example_user("hamlet"))
|
||||||
with patch("corporate.views.do_replace_payment_source") as mocked1:
|
with patch("corporate.views.billing_page.do_replace_payment_source") as mocked1:
|
||||||
response = self.client_post("/json/billing/sources/change", {"stripe_token": "token"})
|
response = self.client_post("/json/billing/sources/change", {"stripe_token": "token"})
|
||||||
self.assert_json_success(response)
|
self.assert_json_success(response)
|
||||||
mocked1.assert_called_once()
|
mocked1.assert_called_once()
|
||||||
|
|
||||||
# Realm owners have access, even if they are not billing admins
|
# Realm owners have access, even if they are not billing admins
|
||||||
self.login_user(self.example_user("desdemona"))
|
self.login_user(self.example_user("desdemona"))
|
||||||
with patch("corporate.views.do_replace_payment_source") as mocked2:
|
with patch("corporate.views.billing_page.do_replace_payment_source") as mocked2:
|
||||||
response = self.client_post("/json/billing/sources/change", {"stripe_token": "token"})
|
response = self.client_post("/json/billing/sources/change", {"stripe_token": "token"})
|
||||||
self.assert_json_success(response)
|
self.assert_json_success(response)
|
||||||
mocked2.assert_called_once()
|
mocked2.assert_called_once()
|
||||||
|
|
|
@ -4,14 +4,8 @@ from django.conf.urls import include
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from corporate.views import (
|
from corporate.views.billing_page import billing_home, replace_payment_source, update_plan
|
||||||
billing_home,
|
from corporate.views.upgrade import initial_upgrade, sponsorship, upgrade
|
||||||
initial_upgrade,
|
|
||||||
replace_payment_source,
|
|
||||||
sponsorship,
|
|
||||||
update_plan,
|
|
||||||
upgrade,
|
|
||||||
)
|
|
||||||
from zerver.lib.rest import rest_path
|
from zerver.lib.rest import rest_path
|
||||||
|
|
||||||
i18n_urlpatterns: Any = [
|
i18n_urlpatterns: Any = [
|
||||||
|
|
|
@ -1,13 +1,8 @@
|
||||||
import logging
|
import logging
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any, Dict, Optional, Union
|
from typing import Any, Dict, Optional, Union
|
||||||
from urllib.parse import urlencode, urljoin, urlunsplit
|
|
||||||
|
|
||||||
import stripe
|
import stripe
|
||||||
from django import forms
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
|
||||||
from django.db import transaction
|
|
||||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
@ -15,85 +10,35 @@ from django.utils.timezone import now as timezone_now
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from corporate.lib.stripe import (
|
from corporate.lib.stripe import (
|
||||||
DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
|
||||||
MIN_INVOICED_LICENSES,
|
|
||||||
STRIPE_PUBLISHABLE_KEY,
|
STRIPE_PUBLISHABLE_KEY,
|
||||||
BillingError,
|
|
||||||
cents_to_dollar_string,
|
cents_to_dollar_string,
|
||||||
do_change_plan_status,
|
do_change_plan_status,
|
||||||
do_replace_payment_source,
|
do_replace_payment_source,
|
||||||
downgrade_at_the_end_of_billing_cycle,
|
downgrade_at_the_end_of_billing_cycle,
|
||||||
downgrade_now_without_creating_additional_invoices,
|
downgrade_now_without_creating_additional_invoices,
|
||||||
get_latest_seat_count,
|
get_latest_seat_count,
|
||||||
is_sponsored_realm,
|
|
||||||
make_end_of_cycle_updates_if_needed,
|
make_end_of_cycle_updates_if_needed,
|
||||||
process_initial_upgrade,
|
|
||||||
renewal_amount,
|
renewal_amount,
|
||||||
sign_string,
|
|
||||||
start_of_next_billing_cycle,
|
start_of_next_billing_cycle,
|
||||||
stripe_get_customer,
|
stripe_get_customer,
|
||||||
unsign_string,
|
|
||||||
update_license_ledger_for_manual_plan,
|
update_license_ledger_for_manual_plan,
|
||||||
update_sponsorship_status,
|
|
||||||
validate_licenses,
|
validate_licenses,
|
||||||
)
|
)
|
||||||
from corporate.models import (
|
from corporate.models import (
|
||||||
CustomerPlan,
|
CustomerPlan,
|
||||||
ZulipSponsorshipRequest,
|
|
||||||
get_current_plan_by_customer,
|
get_current_plan_by_customer,
|
||||||
get_current_plan_by_realm,
|
get_current_plan_by_realm,
|
||||||
get_customer_by_realm,
|
get_customer_by_realm,
|
||||||
)
|
)
|
||||||
from zerver.decorator import (
|
from zerver.decorator import require_billing_access, zulip_login_required
|
||||||
require_billing_access,
|
|
||||||
require_organization_member,
|
|
||||||
zulip_login_required,
|
|
||||||
)
|
|
||||||
from zerver.lib.actions import do_make_user_billing_admin
|
|
||||||
from zerver.lib.exceptions import JsonableError
|
from zerver.lib.exceptions import JsonableError
|
||||||
from zerver.lib.request import REQ, has_request_variables
|
from zerver.lib.request import REQ, has_request_variables
|
||||||
from zerver.lib.response import json_success
|
from zerver.lib.response import json_success
|
||||||
from zerver.lib.send_email import FromAddress, send_email
|
from zerver.lib.validator import check_int, check_int_in
|
||||||
from zerver.lib.validator import check_int, check_int_in, check_string_in
|
from zerver.models import UserProfile
|
||||||
from zerver.models import Realm, UserProfile, get_org_type_display_name, get_realm
|
|
||||||
|
|
||||||
billing_logger = logging.getLogger("corporate.stripe")
|
billing_logger = logging.getLogger("corporate.stripe")
|
||||||
|
|
||||||
VALID_BILLING_MODALITY_VALUES = ["send_invoice", "charge_automatically"]
|
|
||||||
VALID_BILLING_SCHEDULE_VALUES = ["annual", "monthly"]
|
|
||||||
VALID_LICENSE_MANAGEMENT_VALUES = ["automatic", "manual"]
|
|
||||||
|
|
||||||
|
|
||||||
def unsign_seat_count(signed_seat_count: str, salt: str) -> int:
|
|
||||||
try:
|
|
||||||
return int(unsign_string(signed_seat_count, salt))
|
|
||||||
except signing.BadSignature:
|
|
||||||
raise BillingError("tampered seat count")
|
|
||||||
|
|
||||||
|
|
||||||
def check_upgrade_parameters(
|
|
||||||
billing_modality: str,
|
|
||||||
schedule: str,
|
|
||||||
license_management: Optional[str],
|
|
||||||
licenses: Optional[int],
|
|
||||||
has_stripe_token: bool,
|
|
||||||
seat_count: int,
|
|
||||||
) -> None:
|
|
||||||
if billing_modality not in VALID_BILLING_MODALITY_VALUES: # nocoverage
|
|
||||||
raise BillingError("unknown billing_modality")
|
|
||||||
if schedule not in VALID_BILLING_SCHEDULE_VALUES: # nocoverage
|
|
||||||
raise BillingError("unknown schedule")
|
|
||||||
if license_management not in VALID_LICENSE_MANAGEMENT_VALUES: # nocoverage
|
|
||||||
raise BillingError("unknown license_management")
|
|
||||||
|
|
||||||
charge_automatically = False
|
|
||||||
if billing_modality == "charge_automatically":
|
|
||||||
charge_automatically = True
|
|
||||||
if not has_stripe_token:
|
|
||||||
raise BillingError("autopay with no card")
|
|
||||||
|
|
||||||
validate_licenses(charge_automatically, licenses, seat_count)
|
|
||||||
|
|
||||||
|
|
||||||
# Should only be called if the customer is being charged automatically
|
# Should only be called if the customer is being charged automatically
|
||||||
def payment_method_string(stripe_customer: stripe.Customer) -> str:
|
def payment_method_string(stripe_customer: stripe.Customer) -> str:
|
||||||
|
@ -116,202 +61,6 @@ def payment_method_string(stripe_customer: stripe.Customer) -> str:
|
||||||
) # nocoverage
|
) # nocoverage
|
||||||
|
|
||||||
|
|
||||||
@require_organization_member
|
|
||||||
@has_request_variables
|
|
||||||
def upgrade(
|
|
||||||
request: HttpRequest,
|
|
||||||
user: UserProfile,
|
|
||||||
billing_modality: str = REQ(str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES)),
|
|
||||||
schedule: str = REQ(str_validator=check_string_in(VALID_BILLING_SCHEDULE_VALUES)),
|
|
||||||
signed_seat_count: str = REQ(),
|
|
||||||
salt: str = REQ(),
|
|
||||||
license_management: Optional[str] = REQ(
|
|
||||||
default=None, str_validator=check_string_in(VALID_LICENSE_MANAGEMENT_VALUES)
|
|
||||||
),
|
|
||||||
licenses: Optional[int] = REQ(json_validator=check_int, default=None),
|
|
||||||
stripe_token: Optional[str] = REQ(default=None),
|
|
||||||
) -> HttpResponse:
|
|
||||||
|
|
||||||
try:
|
|
||||||
seat_count = unsign_seat_count(signed_seat_count, salt)
|
|
||||||
if billing_modality == "charge_automatically" and license_management == "automatic":
|
|
||||||
licenses = seat_count
|
|
||||||
if billing_modality == "send_invoice":
|
|
||||||
schedule = "annual"
|
|
||||||
license_management = "manual"
|
|
||||||
check_upgrade_parameters(
|
|
||||||
billing_modality,
|
|
||||||
schedule,
|
|
||||||
license_management,
|
|
||||||
licenses,
|
|
||||||
stripe_token is not None,
|
|
||||||
seat_count,
|
|
||||||
)
|
|
||||||
assert licenses is not None
|
|
||||||
automanage_licenses = license_management == "automatic"
|
|
||||||
|
|
||||||
billing_schedule = {"annual": CustomerPlan.ANNUAL, "monthly": CustomerPlan.MONTHLY}[
|
|
||||||
schedule
|
|
||||||
]
|
|
||||||
process_initial_upgrade(user, licenses, automanage_licenses, billing_schedule, stripe_token)
|
|
||||||
except BillingError as e:
|
|
||||||
if not settings.TEST_SUITE: # nocoverage
|
|
||||||
billing_logger.warning(
|
|
||||||
"BillingError during upgrade: %s. user=%s, realm=%s (%s), billing_modality=%s, "
|
|
||||||
"schedule=%s, license_management=%s, licenses=%s, has stripe_token: %s",
|
|
||||||
e.error_description,
|
|
||||||
user.id,
|
|
||||||
user.realm.id,
|
|
||||||
user.realm.string_id,
|
|
||||||
billing_modality,
|
|
||||||
schedule,
|
|
||||||
license_management,
|
|
||||||
licenses,
|
|
||||||
stripe_token is not None,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
billing_logger.exception("Uncaught exception in billing:", stack_info=True)
|
|
||||||
error_message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR)
|
|
||||||
error_description = "uncaught exception during upgrade"
|
|
||||||
raise BillingError(error_description, error_message)
|
|
||||||
else:
|
|
||||||
return json_success()
|
|
||||||
|
|
||||||
|
|
||||||
@zulip_login_required
|
|
||||||
def initial_upgrade(request: HttpRequest) -> HttpResponse:
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
if not settings.BILLING_ENABLED or user.is_guest:
|
|
||||||
return render(request, "404.html", status=404)
|
|
||||||
|
|
||||||
billing_page_url = reverse(billing_home)
|
|
||||||
|
|
||||||
customer = get_customer_by_realm(user.realm)
|
|
||||||
if customer is not None and (
|
|
||||||
get_current_plan_by_customer(customer) is not None or customer.sponsorship_pending
|
|
||||||
):
|
|
||||||
if request.GET.get("onboarding") is not None:
|
|
||||||
billing_page_url = f"{billing_page_url}?onboarding=true"
|
|
||||||
return HttpResponseRedirect(billing_page_url)
|
|
||||||
|
|
||||||
if is_sponsored_realm(user.realm):
|
|
||||||
return HttpResponseRedirect(billing_page_url)
|
|
||||||
|
|
||||||
percent_off = Decimal(0)
|
|
||||||
if customer is not None and customer.default_discount is not None:
|
|
||||||
percent_off = customer.default_discount
|
|
||||||
|
|
||||||
seat_count = get_latest_seat_count(user.realm)
|
|
||||||
signed_seat_count, salt = sign_string(str(seat_count))
|
|
||||||
context: Dict[str, Any] = {
|
|
||||||
"realm": user.realm,
|
|
||||||
"publishable_key": STRIPE_PUBLISHABLE_KEY,
|
|
||||||
"email": user.delivery_email,
|
|
||||||
"seat_count": seat_count,
|
|
||||||
"signed_seat_count": signed_seat_count,
|
|
||||||
"salt": salt,
|
|
||||||
"min_invoiced_licenses": max(seat_count, MIN_INVOICED_LICENSES),
|
|
||||||
"default_invoice_days_until_due": DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
|
||||||
"plan": "Zulip Standard",
|
|
||||||
"free_trial_days": settings.FREE_TRIAL_DAYS,
|
|
||||||
"onboarding": request.GET.get("onboarding") is not None,
|
|
||||||
"page_params": {
|
|
||||||
"seat_count": seat_count,
|
|
||||||
"annual_price": 8000,
|
|
||||||
"monthly_price": 800,
|
|
||||||
"percent_off": float(percent_off),
|
|
||||||
},
|
|
||||||
"realm_org_type": user.realm.org_type,
|
|
||||||
"sorted_org_types": sorted(
|
|
||||||
[
|
|
||||||
[org_type_name, org_type]
|
|
||||||
for (org_type_name, org_type) in Realm.ORG_TYPES.items()
|
|
||||||
if not org_type.get("hidden")
|
|
||||||
],
|
|
||||||
key=lambda d: d[1]["display_order"],
|
|
||||||
),
|
|
||||||
}
|
|
||||||
response = render(request, "corporate/upgrade.html", context=context)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
class SponsorshipRequestForm(forms.Form):
|
|
||||||
website = forms.URLField(max_length=ZulipSponsorshipRequest.MAX_ORG_URL_LENGTH)
|
|
||||||
organization_type = forms.IntegerField()
|
|
||||||
description = forms.CharField(widget=forms.Textarea)
|
|
||||||
|
|
||||||
|
|
||||||
@require_organization_member
|
|
||||||
@has_request_variables
|
|
||||||
def sponsorship(
|
|
||||||
request: HttpRequest,
|
|
||||||
user: UserProfile,
|
|
||||||
organization_type: str = REQ("organization-type"),
|
|
||||||
website: str = REQ(),
|
|
||||||
description: str = REQ(),
|
|
||||||
) -> HttpResponse:
|
|
||||||
realm = user.realm
|
|
||||||
|
|
||||||
requested_by = user.full_name
|
|
||||||
user_role = user.get_role_name()
|
|
||||||
|
|
||||||
support_realm_uri = get_realm(settings.STAFF_SUBDOMAIN).uri
|
|
||||||
support_url = urljoin(
|
|
||||||
support_realm_uri,
|
|
||||||
urlunsplit(("", "", reverse("support"), urlencode({"q": realm.string_id}), "")),
|
|
||||||
)
|
|
||||||
|
|
||||||
post_data = request.POST.copy()
|
|
||||||
# We need to do this because the field name in the template
|
|
||||||
# for organization type contains a hyphen and the form expects
|
|
||||||
# an underscore.
|
|
||||||
post_data.update(organization_type=organization_type)
|
|
||||||
form = SponsorshipRequestForm(post_data)
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
if form.is_valid():
|
|
||||||
sponsorship_request = ZulipSponsorshipRequest(
|
|
||||||
realm=realm,
|
|
||||||
requested_by=user,
|
|
||||||
org_website=form.cleaned_data["website"],
|
|
||||||
org_description=form.cleaned_data["description"],
|
|
||||||
org_type=form.cleaned_data["organization_type"],
|
|
||||||
)
|
|
||||||
sponsorship_request.save()
|
|
||||||
|
|
||||||
org_type = form.cleaned_data["organization_type"]
|
|
||||||
if realm.org_type != org_type:
|
|
||||||
realm.org_type = org_type
|
|
||||||
realm.save(update_fields=["org_type"])
|
|
||||||
|
|
||||||
update_sponsorship_status(realm, True, acting_user=user)
|
|
||||||
do_make_user_billing_admin(user)
|
|
||||||
|
|
||||||
org_type_display_name = get_org_type_display_name(org_type)
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"requested_by": requested_by,
|
|
||||||
"user_role": user_role,
|
|
||||||
"string_id": realm.string_id,
|
|
||||||
"support_url": support_url,
|
|
||||||
"organization_type": org_type_display_name,
|
|
||||||
"website": website,
|
|
||||||
"description": description,
|
|
||||||
}
|
|
||||||
send_email(
|
|
||||||
"zerver/emails/sponsorship_request",
|
|
||||||
to_emails=[FromAddress.SUPPORT],
|
|
||||||
from_name="Zulip sponsorship",
|
|
||||||
from_address=FromAddress.tokenized_no_reply_address(),
|
|
||||||
reply_to_email=user.delivery_email,
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
|
|
||||||
return json_success()
|
|
||||||
|
|
||||||
|
|
||||||
@zulip_login_required
|
@zulip_login_required
|
||||||
def billing_home(request: HttpRequest) -> HttpResponse:
|
def billing_home(request: HttpRequest) -> HttpResponse:
|
||||||
user = request.user
|
user = request.user
|
||||||
|
@ -326,6 +75,8 @@ def billing_home(request: HttpRequest) -> HttpResponse:
|
||||||
return render(request, "corporate/billing.html", context=context)
|
return render(request, "corporate/billing.html", context=context)
|
||||||
|
|
||||||
if customer is None:
|
if customer is None:
|
||||||
|
from corporate.views.upgrade import initial_upgrade
|
||||||
|
|
||||||
return HttpResponseRedirect(reverse(initial_upgrade))
|
return HttpResponseRedirect(reverse(initial_upgrade))
|
||||||
|
|
||||||
if customer.sponsorship_pending:
|
if customer.sponsorship_pending:
|
||||||
|
@ -333,6 +84,8 @@ def billing_home(request: HttpRequest) -> HttpResponse:
|
||||||
return render(request, "corporate/billing.html", context=context)
|
return render(request, "corporate/billing.html", context=context)
|
||||||
|
|
||||||
if not CustomerPlan.objects.filter(customer=customer).exists():
|
if not CustomerPlan.objects.filter(customer=customer).exists():
|
||||||
|
from corporate.views.upgrade import initial_upgrade
|
||||||
|
|
||||||
return HttpResponseRedirect(reverse(initial_upgrade))
|
return HttpResponseRedirect(reverse(initial_upgrade))
|
||||||
|
|
||||||
if not user.has_billing_access:
|
if not user.has_billing_access:
|
|
@ -0,0 +1,273 @@
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import urlencode, urljoin, urlunsplit
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core import signing
|
||||||
|
from django.db import transaction
|
||||||
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from corporate.lib.stripe import (
|
||||||
|
DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
||||||
|
MIN_INVOICED_LICENSES,
|
||||||
|
STRIPE_PUBLISHABLE_KEY,
|
||||||
|
BillingError,
|
||||||
|
get_latest_seat_count,
|
||||||
|
is_sponsored_realm,
|
||||||
|
process_initial_upgrade,
|
||||||
|
sign_string,
|
||||||
|
unsign_string,
|
||||||
|
update_sponsorship_status,
|
||||||
|
validate_licenses,
|
||||||
|
)
|
||||||
|
from corporate.models import (
|
||||||
|
CustomerPlan,
|
||||||
|
ZulipSponsorshipRequest,
|
||||||
|
get_current_plan_by_customer,
|
||||||
|
get_customer_by_realm,
|
||||||
|
)
|
||||||
|
from corporate.views.billing_page import billing_home
|
||||||
|
from zerver.decorator import require_organization_member, zulip_login_required
|
||||||
|
from zerver.lib.actions import do_make_user_billing_admin
|
||||||
|
from zerver.lib.request import REQ, has_request_variables
|
||||||
|
from zerver.lib.response import json_success
|
||||||
|
from zerver.lib.send_email import FromAddress, send_email
|
||||||
|
from zerver.lib.validator import check_int, check_string_in
|
||||||
|
from zerver.models import Realm, UserProfile, get_org_type_display_name, get_realm
|
||||||
|
|
||||||
|
billing_logger = logging.getLogger("corporate.stripe")
|
||||||
|
|
||||||
|
VALID_BILLING_MODALITY_VALUES = ["send_invoice", "charge_automatically"]
|
||||||
|
VALID_BILLING_SCHEDULE_VALUES = ["annual", "monthly"]
|
||||||
|
VALID_LICENSE_MANAGEMENT_VALUES = ["automatic", "manual"]
|
||||||
|
|
||||||
|
|
||||||
|
def unsign_seat_count(signed_seat_count: str, salt: str) -> int:
|
||||||
|
try:
|
||||||
|
return int(unsign_string(signed_seat_count, salt))
|
||||||
|
except signing.BadSignature:
|
||||||
|
raise BillingError("tampered seat count")
|
||||||
|
|
||||||
|
|
||||||
|
def check_upgrade_parameters(
|
||||||
|
billing_modality: str,
|
||||||
|
schedule: str,
|
||||||
|
license_management: Optional[str],
|
||||||
|
licenses: Optional[int],
|
||||||
|
has_stripe_token: bool,
|
||||||
|
seat_count: int,
|
||||||
|
) -> None:
|
||||||
|
if billing_modality not in VALID_BILLING_MODALITY_VALUES: # nocoverage
|
||||||
|
raise BillingError("unknown billing_modality")
|
||||||
|
if schedule not in VALID_BILLING_SCHEDULE_VALUES: # nocoverage
|
||||||
|
raise BillingError("unknown schedule")
|
||||||
|
if license_management not in VALID_LICENSE_MANAGEMENT_VALUES: # nocoverage
|
||||||
|
raise BillingError("unknown license_management")
|
||||||
|
|
||||||
|
charge_automatically = False
|
||||||
|
if billing_modality == "charge_automatically":
|
||||||
|
charge_automatically = True
|
||||||
|
if not has_stripe_token:
|
||||||
|
raise BillingError("autopay with no card")
|
||||||
|
|
||||||
|
validate_licenses(charge_automatically, licenses, seat_count)
|
||||||
|
|
||||||
|
|
||||||
|
@require_organization_member
|
||||||
|
@has_request_variables
|
||||||
|
def upgrade(
|
||||||
|
request: HttpRequest,
|
||||||
|
user: UserProfile,
|
||||||
|
billing_modality: str = REQ(str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES)),
|
||||||
|
schedule: str = REQ(str_validator=check_string_in(VALID_BILLING_SCHEDULE_VALUES)),
|
||||||
|
signed_seat_count: str = REQ(),
|
||||||
|
salt: str = REQ(),
|
||||||
|
license_management: Optional[str] = REQ(
|
||||||
|
default=None, str_validator=check_string_in(VALID_LICENSE_MANAGEMENT_VALUES)
|
||||||
|
),
|
||||||
|
licenses: Optional[int] = REQ(json_validator=check_int, default=None),
|
||||||
|
stripe_token: Optional[str] = REQ(default=None),
|
||||||
|
) -> HttpResponse:
|
||||||
|
|
||||||
|
try:
|
||||||
|
seat_count = unsign_seat_count(signed_seat_count, salt)
|
||||||
|
if billing_modality == "charge_automatically" and license_management == "automatic":
|
||||||
|
licenses = seat_count
|
||||||
|
if billing_modality == "send_invoice":
|
||||||
|
schedule = "annual"
|
||||||
|
license_management = "manual"
|
||||||
|
check_upgrade_parameters(
|
||||||
|
billing_modality,
|
||||||
|
schedule,
|
||||||
|
license_management,
|
||||||
|
licenses,
|
||||||
|
stripe_token is not None,
|
||||||
|
seat_count,
|
||||||
|
)
|
||||||
|
assert licenses is not None
|
||||||
|
automanage_licenses = license_management == "automatic"
|
||||||
|
|
||||||
|
billing_schedule = {"annual": CustomerPlan.ANNUAL, "monthly": CustomerPlan.MONTHLY}[
|
||||||
|
schedule
|
||||||
|
]
|
||||||
|
process_initial_upgrade(user, licenses, automanage_licenses, billing_schedule, stripe_token)
|
||||||
|
except BillingError as e:
|
||||||
|
if not settings.TEST_SUITE: # nocoverage
|
||||||
|
billing_logger.warning(
|
||||||
|
"BillingError during upgrade: %s. user=%s, realm=%s (%s), billing_modality=%s, "
|
||||||
|
"schedule=%s, license_management=%s, licenses=%s, has stripe_token: %s",
|
||||||
|
e.error_description,
|
||||||
|
user.id,
|
||||||
|
user.realm.id,
|
||||||
|
user.realm.string_id,
|
||||||
|
billing_modality,
|
||||||
|
schedule,
|
||||||
|
license_management,
|
||||||
|
licenses,
|
||||||
|
stripe_token is not None,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
billing_logger.exception("Uncaught exception in billing:", stack_info=True)
|
||||||
|
error_message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR)
|
||||||
|
error_description = "uncaught exception during upgrade"
|
||||||
|
raise BillingError(error_description, error_message)
|
||||||
|
else:
|
||||||
|
return json_success()
|
||||||
|
|
||||||
|
|
||||||
|
@zulip_login_required
|
||||||
|
def initial_upgrade(request: HttpRequest) -> HttpResponse:
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not settings.BILLING_ENABLED or user.is_guest:
|
||||||
|
return render(request, "404.html", status=404)
|
||||||
|
|
||||||
|
billing_page_url = reverse(billing_home)
|
||||||
|
|
||||||
|
customer = get_customer_by_realm(user.realm)
|
||||||
|
if customer is not None and (
|
||||||
|
get_current_plan_by_customer(customer) is not None or customer.sponsorship_pending
|
||||||
|
):
|
||||||
|
if request.GET.get("onboarding") is not None:
|
||||||
|
billing_page_url = f"{billing_page_url}?onboarding=true"
|
||||||
|
return HttpResponseRedirect(billing_page_url)
|
||||||
|
|
||||||
|
if is_sponsored_realm(user.realm):
|
||||||
|
return HttpResponseRedirect(billing_page_url)
|
||||||
|
|
||||||
|
percent_off = Decimal(0)
|
||||||
|
if customer is not None and customer.default_discount is not None:
|
||||||
|
percent_off = customer.default_discount
|
||||||
|
|
||||||
|
seat_count = get_latest_seat_count(user.realm)
|
||||||
|
signed_seat_count, salt = sign_string(str(seat_count))
|
||||||
|
context: Dict[str, Any] = {
|
||||||
|
"realm": user.realm,
|
||||||
|
"publishable_key": STRIPE_PUBLISHABLE_KEY,
|
||||||
|
"email": user.delivery_email,
|
||||||
|
"seat_count": seat_count,
|
||||||
|
"signed_seat_count": signed_seat_count,
|
||||||
|
"salt": salt,
|
||||||
|
"min_invoiced_licenses": max(seat_count, MIN_INVOICED_LICENSES),
|
||||||
|
"default_invoice_days_until_due": DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
||||||
|
"plan": "Zulip Standard",
|
||||||
|
"free_trial_days": settings.FREE_TRIAL_DAYS,
|
||||||
|
"onboarding": request.GET.get("onboarding") is not None,
|
||||||
|
"page_params": {
|
||||||
|
"seat_count": seat_count,
|
||||||
|
"annual_price": 8000,
|
||||||
|
"monthly_price": 800,
|
||||||
|
"percent_off": float(percent_off),
|
||||||
|
},
|
||||||
|
"realm_org_type": user.realm.org_type,
|
||||||
|
"sorted_org_types": sorted(
|
||||||
|
[
|
||||||
|
[org_type_name, org_type]
|
||||||
|
for (org_type_name, org_type) in Realm.ORG_TYPES.items()
|
||||||
|
if not org_type.get("hidden")
|
||||||
|
],
|
||||||
|
key=lambda d: d[1]["display_order"],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
response = render(request, "corporate/upgrade.html", context=context)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorshipRequestForm(forms.Form):
|
||||||
|
website = forms.URLField(max_length=ZulipSponsorshipRequest.MAX_ORG_URL_LENGTH)
|
||||||
|
organization_type = forms.IntegerField()
|
||||||
|
description = forms.CharField(widget=forms.Textarea)
|
||||||
|
|
||||||
|
|
||||||
|
@require_organization_member
|
||||||
|
@has_request_variables
|
||||||
|
def sponsorship(
|
||||||
|
request: HttpRequest,
|
||||||
|
user: UserProfile,
|
||||||
|
organization_type: str = REQ("organization-type"),
|
||||||
|
website: str = REQ(),
|
||||||
|
description: str = REQ(),
|
||||||
|
) -> HttpResponse:
|
||||||
|
realm = user.realm
|
||||||
|
|
||||||
|
requested_by = user.full_name
|
||||||
|
user_role = user.get_role_name()
|
||||||
|
|
||||||
|
support_realm_uri = get_realm(settings.STAFF_SUBDOMAIN).uri
|
||||||
|
support_url = urljoin(
|
||||||
|
support_realm_uri,
|
||||||
|
urlunsplit(("", "", reverse("support"), urlencode({"q": realm.string_id}), "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
post_data = request.POST.copy()
|
||||||
|
# We need to do this because the field name in the template
|
||||||
|
# for organization type contains a hyphen and the form expects
|
||||||
|
# an underscore.
|
||||||
|
post_data.update(organization_type=organization_type)
|
||||||
|
form = SponsorshipRequestForm(post_data)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
if form.is_valid():
|
||||||
|
sponsorship_request = ZulipSponsorshipRequest(
|
||||||
|
realm=realm,
|
||||||
|
requested_by=user,
|
||||||
|
org_website=form.cleaned_data["website"],
|
||||||
|
org_description=form.cleaned_data["description"],
|
||||||
|
org_type=form.cleaned_data["organization_type"],
|
||||||
|
)
|
||||||
|
sponsorship_request.save()
|
||||||
|
|
||||||
|
org_type = form.cleaned_data["organization_type"]
|
||||||
|
if realm.org_type != org_type:
|
||||||
|
realm.org_type = org_type
|
||||||
|
realm.save(update_fields=["org_type"])
|
||||||
|
|
||||||
|
update_sponsorship_status(realm, True, acting_user=user)
|
||||||
|
do_make_user_billing_admin(user)
|
||||||
|
|
||||||
|
org_type_display_name = get_org_type_display_name(org_type)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"requested_by": requested_by,
|
||||||
|
"user_role": user_role,
|
||||||
|
"string_id": realm.string_id,
|
||||||
|
"support_url": support_url,
|
||||||
|
"organization_type": org_type_display_name,
|
||||||
|
"website": website,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
send_email(
|
||||||
|
"zerver/emails/sponsorship_request",
|
||||||
|
to_emails=[FromAddress.SUPPORT],
|
||||||
|
from_name="Zulip sponsorship",
|
||||||
|
from_address=FromAddress.tokenized_no_reply_address(),
|
||||||
|
reply_to_email=user.delivery_email,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
return json_success()
|
Loading…
Reference in New Issue