import uuid from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import timedelta from decimal import Decimal from typing import Any from unittest.mock import Mock, patch import orjson import stripe import time_machine from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core import signing from django.urls.resolvers import get_resolver from django.utils.timezone import now as timezone_now from typing_extensions import override from corporate.lib.stripe import ( DEFAULT_INVOICE_DAYS_UNTIL_DUE, MAX_INVOICED_LICENSES, MIN_INVOICED_LICENSES, STRIPE_API_VERSION, BillingError, InitialUpgradeRequest, RealmBillingSession, StripeCardError, UpdatePlanRequest, add_months, catch_stripe_errors, customer_has_credit_card_as_default_payment_method, customer_has_last_n_invoices_open, downgrade_small_realms_behind_on_payments_as_needed, get_latest_seat_count, get_price_per_license, invoice_plans_as_needed, sign_string, stripe_customer_has_credit_card_as_default_payment_method, stripe_get_customer, unsign_string, ) from corporate.lib.test_stripe_class import StripeTestCase, mock_stripe from corporate.models.customers import Customer, get_customer_by_realm from corporate.models.licenses import LicenseLedger from corporate.models.plans import ( CustomerPlan, get_current_plan_by_customer, get_current_plan_by_realm, ) from corporate.models.stripe_state import Event, Invoice from zerver.actions.create_realm import do_create_realm from zerver.actions.realm_settings import do_deactivate_realm, do_reactivate_realm from zerver.actions.users import do_deactivate_user from zerver.lib.exceptions import JsonableError from zerver.lib.test_classes import ZulipTestCase from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime from zerver.lib.utils import assert_is_not_none from zerver.models import Realm, RealmAuditLog, UserProfile from zerver.models.realm_audit_logs import AuditLogEventType from zerver.models.realms import get_realm from zilencer.models import RemoteZulipServer class StripeTest(StripeTestCase): def check_initial_ledger_entry( self, plan: CustomerPlan, licenses_purchased: int ) -> LicenseLedger: return LicenseLedger.objects.get( plan=plan, is_renewal=True, event_time=self.now, licenses=licenses_purchased, licenses_at_next_renewal=licenses_purchased, ) def test_catch_stripe_errors(self) -> None: @catch_stripe_errors def raise_invalid_request_error() -> None: raise stripe.InvalidRequestError("message", "param", "code", json_body={}) with self.assertLogs("corporate.stripe", "ERROR") as error_log: with self.assertRaises(BillingError) as billing_context: raise_invalid_request_error() self.assertEqual("other stripe error", billing_context.exception.error_description) self.assertEqual( error_log.output, ["ERROR:corporate.stripe:Stripe error: None None None None"] ) @catch_stripe_errors def raise_card_error() -> None: error_message = "The card number is not a valid credit card number." json_body = {"error": {"message": error_message}} raise stripe.CardError(error_message, "number", "invalid_number", json_body=json_body) with self.assertLogs("corporate.stripe", "INFO") as info_log: with self.assertRaises(StripeCardError) as card_context: raise_card_error() self.assertIn("not a valid credit card", str(card_context.exception)) self.assertEqual("card error", card_context.exception.error_description) self.assertEqual( info_log.output, ["INFO:corporate.stripe:Stripe card error: None None None None"] ) def test_billing_not_enabled(self) -> None: iago = self.example_user("iago") with self.settings(BILLING_ENABLED=False): self.login_user(iago) response = self.client_get("/upgrade/", follow=True) self.assertEqual(response.status_code, 404) @mock_stripe() def test_stripe_billing_portal_urls(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) self.add_card_to_customer_for_upgrade() response = self.client_get(f"/customer_portal/?tier={CustomerPlan.TIER_CLOUD_STANDARD}") self.assertEqual(response.status_code, 302) self.assertTrue(response["Location"].startswith("https://billing.stripe.com/")) self.upgrade(invoice=True) response = self.client_get("/customer_portal/?return_to_billing_page=true") self.assertEqual(response.status_code, 302) self.assertTrue(response["Location"].startswith("https://billing.stripe.com/")) response = self.client_get("/invoices/") self.assertEqual(response.status_code, 302) self.assertTrue(response["Location"].startswith("https://billing.stripe.com/")) @mock_stripe() def test_upgrade_by_card_to_plus_plan(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) response = self.client_get("/upgrade/?tier=2") self.assert_in_success_response( ["Your subscription will renew automatically", "Zulip Cloud Plus"], response ) self.assertEqual(user.realm.plan_type, Realm.PLAN_TYPE_SELF_HOSTED) # This also means there is no card set as default payment method set for the user. self.assertFalse(Customer.objects.filter(realm=user.realm).exists()) stripe_customer = self.add_card_and_upgrade(user, tier=CustomerPlan.TIER_CLOUD_PLUS) self.assertEqual(stripe_customer.description, "zulip (Zulip Dev)") self.assertEqual(stripe_customer.discount, None) self.assertEqual(stripe_customer.email, user.delivery_email) assert stripe_customer.metadata is not None self.assertEqual(stripe_customer.metadata["realm_str"], "zulip") try: int(stripe_customer.metadata["realm_id"]) except ValueError: # nocoverage raise AssertionError("realm_id is not a number") # Check Charges in Stripe [charge] = iter(stripe.Charge.list(customer=stripe_customer.id)) licenses_purchased = self.billing_session.min_licenses_for_plan( CustomerPlan.TIER_CLOUD_PLUS ) self.assertEqual(charge.amount, 12000 * licenses_purchased) self.assertEqual(charge.description, "Payment for Invoice") self.assertEqual(charge.receipt_email, user.delivery_email) self.assertEqual(charge.statement_descriptor, "Zulip Cloud Plus") # Check Invoices in Stripe [invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id)) self.assertIsNotNone(invoice.status_transitions.finalized_at) self.assertEqual(invoice.amount_due, 120000) self.assertEqual(invoice.amount_paid, 120000) # auto_advance is False because the invoice has been paid self.assertFalse(invoice.auto_advance) self.assertEqual(invoice.collection_method, "charge_automatically") self.assertEqual(invoice.status, "paid") # Check Line Items on Stripe Invoice [item0] = iter(invoice.lines) self.assertEqual(item0.amount, 12000 * licenses_purchased) self.assertEqual(item0.currency, "usd") self.assertEqual(item0.description, "Zulip Cloud Plus") self.assertFalse(item0.discountable) assert item0.pricing is not None self.assertEqual(item0.pricing.unit_amount_decimal, Decimal(12000)) self.assertEqual(item0.quantity, licenses_purchased) self.assertEqual(item0.period.start, datetime_to_timestamp(self.now)) self.assertEqual(item0.period.end, datetime_to_timestamp(add_months(self.now, 12))) # Check that we correctly populated Customer, CustomerPlan, and LicenseLedger in Zulip customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm) plan = CustomerPlan.objects.get( customer=customer, automanage_licenses=True, price_per_license=12000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, invoiced_through=LicenseLedger.objects.first(), next_invoice_date=self.next_month, tier=CustomerPlan.TIER_CLOUD_PLUS, status=CustomerPlan.ACTIVE, ) self.check_initial_ledger_entry(plan, licenses_purchased) # Check RealmAuditLog audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") .order_by("id") ) self.assertEqual( audit_log_entries[:3], [ ( AuditLogEventType.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), ), (AuditLogEventType.STRIPE_CARD_CHANGED, self.now), (AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now), ], ) self.assertEqual(audit_log_entries[3][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED) first_audit_log_entry = ( RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED) .values_list("extra_data", flat=True) .first() ) assert first_audit_log_entry is not None self.assertTrue(first_audit_log_entry["automanage_licenses"]) # Check that we correctly updated Realm realm = get_realm("zulip") self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_PLUS) self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) # Check that we can no longer access /upgrade response = self.client_get("/upgrade/") self.assertEqual(response.status_code, 302) self.assertEqual("http://zulip.testserver/billing", response["Location"]) # Check /billing/ has the correct information with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually"], response) for substring in [ "Zulip Cloud Plus", str(licenses_purchased), "Number of licenses", f"{licenses_purchased}", "Your plan will automatically renew on", "January 2, 2013", "$1,200.00", "Visa ending in 4242", "Update card", ]: self.assert_in_response(substring, response) self.assert_not_in_success_response( [ "Number of licenses for current billing period", "You will receive an invoice for", ], response, ) @mock_stripe() def test_upgrade_by_invoice_to_plus_plan(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) # Click "Make payment" in Stripe Checkout with time_machine.travel(self.now, tick=False): self.upgrade(invoice=True, tier=CustomerPlan.TIER_CLOUD_PLUS) # Check that we correctly created a Customer in Stripe stripe_customer = stripe_get_customer( assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id) ) self.assertFalse(stripe_customer_has_credit_card_as_default_payment_method(stripe_customer)) # Check Charges in Stripe # There is no charge created for out of band payments which is used # to test this method. self.assertFalse(stripe.Charge.list(customer=stripe_customer.id)) # Check Invoices in Stripe [invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id)) self.assertIsNotNone(invoice.due_date) self.assertIsNotNone(invoice.status_transitions.finalized_at) self.assertEqual(invoice.amount_due, 12000 * 123) self.assertEqual(invoice.amount_paid, 12000 * 123) self.assertEqual(invoice.attempt_count, 0) self.assertFalse(invoice.auto_advance) self.assertEqual(invoice.collection_method, "send_invoice") self.assertEqual(invoice.statement_descriptor, "Zulip Cloud Plus") self.assertEqual(invoice.status, "paid") # Check Line Items on Stripe Invoice [item] = iter(invoice.lines) self.assertEqual(item.amount, 12000 * 123) self.assertEqual(item.currency, "usd") self.assertEqual(item.description, "Zulip Cloud Plus") self.assertFalse(item.discountable) assert item.pricing is not None self.assertEqual(item.pricing.unit_amount_decimal, Decimal(12000)) self.assertEqual(item.quantity, 123) self.assertEqual(item.period.start, datetime_to_timestamp(self.now)) self.assertEqual(item.period.end, datetime_to_timestamp(add_months(self.now, 12))) # Check that we correctly populated Customer, CustomerPlan and LicenseLedger in Zulip customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm) plan = CustomerPlan.objects.get( customer=customer, automanage_licenses=False, charge_automatically=False, price_per_license=12000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, invoiced_through=LicenseLedger.objects.first(), next_invoice_date=self.next_month, tier=CustomerPlan.TIER_CLOUD_PLUS, status=CustomerPlan.ACTIVE, ) self.check_initial_ledger_entry(plan, 123) # Check RealmAuditLog audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") .order_by("id") ) self.assertEqual( audit_log_entries[:3], [ ( AuditLogEventType.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), ), (AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now), (AuditLogEventType.REALM_PLAN_TYPE_CHANGED, self.now), ], ) self.assertEqual(audit_log_entries[2][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED) first_audit_log_entry = ( RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED) .values_list("extra_data", flat=True) .first() ) assert first_audit_log_entry is not None self.assertFalse(first_audit_log_entry["automanage_licenses"]) # Check that we correctly updated Realm realm = get_realm("zulip") self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_PLUS) self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) # Check that we can no longer access /upgrade response = self.client_get("/upgrade/") self.assertEqual(response.status_code, 302) self.assertEqual("http://zulip.testserver/billing", response["Location"]) # Check /billing/ has the correct information with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually", "Update card"], response) for substring in [ "Zulip Cloud Plus", str(123), "Number of licenses for current billing period", f"licenses ({self.seat_count} in use)", "You will receive an invoice for", "January 2, 2013", "$14,760.00", # 14760 = 120 * 123 ]: self.assert_in_response(substring, response) @mock_stripe() def test_upgrade_by_card(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) response = self.client_get("/upgrade/") self.assert_in_success_response(["Your subscription will renew automatically"], response) self.assertNotEqual(user.realm.plan_type, Realm.PLAN_TYPE_STANDARD) # This also means there is no card set as default payment method set for the user. self.assertFalse(Customer.objects.filter(realm=user.realm).exists()) # Click "Purchase Zulip Cloud Standard" without adding a card. with self.assertLogs("corporate.stripe", "WARNING"): response = self.upgrade() self.assert_json_error(response, "Please add a credit card before upgrading.") stripe_customer = self.add_card_and_upgrade(user) self.assertEqual(stripe_customer.description, "zulip (Zulip Dev)") self.assertEqual(stripe_customer.discount, None) self.assertEqual(stripe_customer.email, user.delivery_email) assert stripe_customer.metadata is not None self.assertEqual(stripe_customer.metadata["realm_str"], "zulip") try: int(stripe_customer.metadata["realm_id"]) except ValueError: # nocoverage raise AssertionError("realm_id is not a number") # Check Charges in Stripe [charge] = iter(stripe.Charge.list(customer=stripe_customer.id)) self.assertEqual(charge.amount, 8000 * self.seat_count) self.assertEqual(charge.description, "Payment for Invoice") self.assertEqual(charge.receipt_email, user.delivery_email) self.assertEqual(charge.statement_descriptor, "Zulip Cloud Standard") # Check Invoices in Stripe [invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id)) self.assertIsNotNone(invoice.status_transitions.finalized_at) self.assertEqual(invoice.amount_due, 48000) self.assertEqual(invoice.amount_paid, 48000) # auto_advance is False because the invoice has been paid self.assertFalse(invoice.auto_advance) self.assertEqual(invoice.collection_method, "charge_automatically") self.assertEqual(invoice.statement_descriptor, "Zulip Cloud Standard") # Check Line Items on Stripe Invoice [item0] = iter(invoice.lines) self.assertEqual(item0.amount, 8000 * self.seat_count) self.assertEqual(item0.currency, "usd") self.assertEqual(item0.description, "Zulip Cloud Standard") self.assertFalse(item0.discountable) assert item0.pricing is not None self.assertEqual(item0.pricing.unit_amount_decimal, Decimal(8000)) self.assertEqual(item0.quantity, self.seat_count) self.assertEqual(item0.period.start, datetime_to_timestamp(self.now)) self.assertEqual(item0.period.end, datetime_to_timestamp(add_months(self.now, 12))) # Check that we correctly populated Customer, CustomerPlan, and LicenseLedger in Zulip customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm) plan = CustomerPlan.objects.get( customer=customer, automanage_licenses=True, price_per_license=8000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, invoiced_through=LicenseLedger.objects.first(), next_invoice_date=self.next_month, tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.ACTIVE, ) self.check_initial_ledger_entry(plan, self.seat_count) # Check RealmAuditLog audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") .order_by("id") ) self.assertEqual( audit_log_entries[:3], [ ( AuditLogEventType.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), ), (AuditLogEventType.STRIPE_CARD_CHANGED, self.now), (AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now), ], ) self.assertEqual(audit_log_entries[3][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED) first_audit_log_entry = ( RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED) .values_list("extra_data", flat=True) .first() ) assert first_audit_log_entry is not None self.assertTrue(first_audit_log_entry["automanage_licenses"]) # Check that we correctly updated Realm realm = get_realm("zulip") self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) # Check that we can no longer access /upgrade response = self.client_get("/upgrade/") self.assertEqual(response.status_code, 302) self.assertEqual("http://zulip.testserver/billing", response["Location"]) # Check /billing/ has the correct information with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually"], response) for substring in [ "Zulip Cloud Standard", str(self.seat_count), "Number of licenses", f"{self.seat_count}", "Your plan will automatically renew on", "January 2, 2013", f"${80 * self.seat_count}.00", "Visa ending in 4242", "Update card", ]: self.assert_in_response(substring, response) self.assert_not_in_success_response( [ "Number of licenses for current billing period", "You will receive an invoice for", ], response, ) @mock_stripe() def test_card_attached_to_customer_but_payment_fails(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) self.add_card_to_customer_for_upgrade(charge_succeeds=False) with self.assertLogs("corporate.stripe", "WARNING"): response = self.upgrade() self.assert_json_error_contains(response, "Your card was declined.") # Customer added a card which always requires authentication, we cannot # use these cards for automatic payments. # TODO: Add a test case for it here. @mock_stripe() def test_upgrade_by_invoice(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) # Click "Make payment" in Stripe Checkout with time_machine.travel(self.now, tick=False): self.upgrade(invoice=True) # Check that we correctly created a Customer in Stripe stripe_customer = stripe_get_customer( assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id) ) self.assertFalse(stripe_customer_has_credit_card_as_default_payment_method(stripe_customer)) # Check Charges in Stripe self.assertFalse(stripe.Charge.list(customer=stripe_customer.id)) # Check Invoices in Stripe [invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id)) self.assertIsNotNone(invoice.due_date) self.assertIsNotNone(invoice.status_transitions.finalized_at) self.assertEqual(invoice.amount_due, 8000 * 123) self.assertEqual(invoice.amount_paid, 8000 * 123) self.assertEqual(invoice.attempt_count, 0) self.assertFalse(invoice.auto_advance) self.assertEqual(invoice.collection_method, "send_invoice") self.assertEqual(invoice.statement_descriptor, "Zulip Cloud Standard") self.assertEqual(invoice.status, "paid") # Check Line Items on Stripe Invoice [item] = iter(invoice.lines) self.assertEqual(item.currency, "usd") self.assertEqual(item.amount, 8000 * 123) self.assertEqual(item.description, "Zulip Cloud Standard") self.assertFalse(item.discountable) self.assertEqual(item.quantity, 123) assert item.pricing is not None self.assertEqual(item.pricing.unit_amount_decimal, Decimal(8000)) self.assertEqual(item.period.start, datetime_to_timestamp(self.now)) self.assertEqual(item.period.end, datetime_to_timestamp(add_months(self.now, 12))) # Check that we correctly populated Customer, CustomerPlan and LicenseLedger in Zulip customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm) plan = CustomerPlan.objects.get( customer=customer, automanage_licenses=False, charge_automatically=False, price_per_license=8000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, invoiced_through=LicenseLedger.objects.first(), next_invoice_date=self.next_month, tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.ACTIVE, ) self.check_initial_ledger_entry(plan, 123) # Check RealmAuditLog audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") .order_by("id") ) self.assertEqual( audit_log_entries[:3], [ ( AuditLogEventType.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), ), (AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now), (AuditLogEventType.REALM_PLAN_TYPE_CHANGED, self.now), ], ) self.assertEqual(audit_log_entries[2][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED) first_audit_log_entry = ( RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED) .values_list("extra_data", flat=True) .first() ) assert first_audit_log_entry is not None self.assertFalse(first_audit_log_entry["automanage_licenses"]) # Check that we correctly updated Realm realm = get_realm("zulip") self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) # Check that we can no longer access /upgrade response = self.client_get("/upgrade/") self.assertEqual(response.status_code, 302) self.assertEqual("http://zulip.testserver/billing", response["Location"]) # Check /billing/ has the correct information with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually", "Update card"], response) for substring in [ "Zulip Cloud Standard", str(123), "Number of licenses for current billing period", f"licenses ({self.seat_count} in use)", "You will receive an invoice for", "January 2, 2013", "$9,840.00", # 9840 = 80 * 123 ]: self.assert_in_response(substring, response) @mock_stripe() def test_free_trial_upgrade_by_card(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) with self.settings(CLOUD_FREE_TRIAL_DAYS=60): response = self.client_get("/upgrade/") free_trial_end_date = self.now + timedelta(days=60) self.assert_in_success_response( ["Your card will not be charged", "free trial", "60-day"], response ) self.assertNotEqual(user.realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertFalse(Customer.objects.filter(realm=user.realm).exists()) # Require free trial users to add a credit card. with ( time_machine.travel(self.now, tick=False), self.assertLogs("corporate.stripe", "WARNING"), ): response = self.upgrade() self.assert_json_error( response, "Please add a credit card before starting your free trial." ) stripe_customer = self.add_card_and_upgrade(user) self.assertEqual(Invoice.objects.count(), 0) self.assertEqual(stripe_customer.description, "zulip (Zulip Dev)") self.assertEqual(stripe_customer.discount, None) self.assertEqual(stripe_customer.email, user.delivery_email) assert stripe_customer.metadata is not None self.assertEqual(stripe_customer.metadata["realm_str"], "zulip") try: int(stripe_customer.metadata["realm_id"]) except ValueError: # nocoverage raise AssertionError("realm_id is not a number") self.assertFalse(stripe.Charge.list(customer=stripe_customer.id)) self.assertFalse(stripe.Invoice.list(customer=stripe_customer.id)) customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm) plan = CustomerPlan.objects.get( customer=customer, automanage_licenses=True, price_per_license=8000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, invoiced_through=LicenseLedger.objects.first(), next_invoice_date=free_trial_end_date, tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.FREE_TRIAL, # For payment through card. charge_automatically=True, ) self.check_initial_ledger_entry(plan, self.seat_count) audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") .order_by("id") ) self.assertEqual( audit_log_entries[:4], [ ( AuditLogEventType.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), ), ( AuditLogEventType.STRIPE_CARD_CHANGED, self.now, ), (AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now), (AuditLogEventType.REALM_PLAN_TYPE_CHANGED, self.now), ], ) self.assertEqual(audit_log_entries[3][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED) first_audit_log_entry = ( RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED) .values_list("extra_data", flat=True) .first() ) assert first_audit_log_entry is not None self.assertTrue(first_audit_log_entry["automanage_licenses"]) realm = get_realm("zulip") self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually"], response) for substring in [ "Zulip Cloud Standard (free trial)", str(self.seat_count), "Number of licenses", f"{self.seat_count}", "Your plan will automatically renew on", "March 2, 2012", f"${80 * self.seat_count}.00", "Visa ending in 4242", "Update card", ]: self.assert_in_response(substring, response) self.assert_not_in_success_response(["Go to your Zulip organization"], response) billing_session = RealmBillingSession(user=user, realm=realm) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=12): billing_session.update_license_ledger_if_needed(self.now) self.check_last_ledger_entry_license_counts(plan, 12, 12) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=15): billing_session.update_license_ledger_if_needed(self.next_month) self.check_last_ledger_entry_license_counts(plan, 15, 15) invoice_plans_as_needed(self.next_month) self.assertFalse(stripe.Invoice.list(customer=stripe_customer.id)) customer_plan = CustomerPlan.objects.get(customer=customer) self.assertEqual(customer_plan.status, CustomerPlan.FREE_TRIAL) self.assertEqual(customer_plan.next_invoice_date, free_trial_end_date) invoice_plans_as_needed(free_trial_end_date) customer_plan.refresh_from_db() realm.refresh_from_db() self.assertEqual(customer_plan.status, CustomerPlan.ACTIVE) self.assertEqual(customer_plan.next_invoice_date, add_months(free_trial_end_date, 1)) self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) [invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id)) self.assertEqual(invoice.amount_due, 15 * 80 * 100) self.assertEqual(invoice.amount_paid, 0) self.assertEqual(invoice.amount_remaining, 15 * 80 * 100) self.assertTrue(invoice.auto_advance) self.assertEqual(invoice.collection_method, "charge_automatically") self.assertEqual(invoice.customer_email, self.example_email("hamlet")) self.assertEqual(invoice.status, "open") [invoice_item] = iter(invoice.lines) self.assertEqual(invoice_item.amount, 15 * 80 * 100) self.assertEqual(invoice_item.description, "Zulip Cloud Standard - renewal") self.assertEqual(invoice_item.quantity, 15) self.assertFalse(invoice_item.discountable) assert invoice_item.pricing is not None self.assertEqual(invoice_item.pricing.unit_amount_decimal, Decimal(8000)) self.assertEqual(invoice_item.period.start, datetime_to_timestamp(free_trial_end_date)) self.assertEqual( invoice_item.period.end, datetime_to_timestamp(add_months(free_trial_end_date, 12)) ) invoice_plans_as_needed(add_months(free_trial_end_date, 1)) [invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id)) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=19): billing_session.update_license_ledger_if_needed(add_months(free_trial_end_date, 10)) self.check_last_ledger_entry_license_counts(plan, 19, 19) # Fast forward next_invoice_date to 10 months from the free_trial_end_date plan.next_invoice_date = add_months(free_trial_end_date, 10) plan.save(update_fields=["next_invoice_date"]) invoice_plans_as_needed(add_months(free_trial_end_date, 10)) [invoice0, _invoice1] = iter(stripe.Invoice.list(customer=stripe_customer.id)) self.assertEqual(invoice0.amount_due, 5172) self.assertEqual(invoice0.auto_advance, True) self.assertEqual(invoice0.collection_method, "charge_automatically") self.assertEqual(invoice0.customer_email, "hamlet@zulip.com") [invoice_item] = iter(invoice0.lines) self.assertEqual(invoice_item.amount, 5172) self.assertEqual(invoice_item.description, "Additional Zulip Cloud Standard license") self.assertFalse(invoice_item.discountable) self.assertEqual(invoice_item.quantity, 4) self.assertEqual( invoice_item.period.start, datetime_to_timestamp(add_months(free_trial_end_date, 10)), ) self.assertEqual( invoice_item.period.end, datetime_to_timestamp(add_months(free_trial_end_date, 12)) ) # Fast forward next_invoice_date to one year from the free_trial_end_date plan.next_invoice_date = add_months(free_trial_end_date, 12) plan.save(update_fields=["next_invoice_date"]) invoice_plans_as_needed(add_months(free_trial_end_date, 12)) [invoice0, _invoice1, _invoice2] = iter( stripe.Invoice.list(customer=stripe_customer.id) ) # Check /billing/ has correct information for fixed price customers. plan.fixed_price = 127 plan.price_per_license = None plan.save(update_fields=["fixed_price", "price_per_license"]) with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_in_success_response(["$1.27"], response) # Don't show price breakdown self.assert_not_in_success_response(["{self.seat_count} x"], response) @mock_stripe() def test_free_trial_upgrade_by_invoice(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) free_trial_end_date = self.now + timedelta(days=60) with self.settings(CLOUD_FREE_TRIAL_DAYS=60): response = self.client_get("/upgrade/") self.assert_in_success_response( ["Your card will not be charged", "free trial", "60-day"], response ) self.assertNotEqual(user.realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertFalse(Customer.objects.filter(realm=user.realm).exists()) with time_machine.travel(self.now, tick=False): self.upgrade(invoice=True) stripe_customer = stripe_get_customer( assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id) ) self.assertEqual(stripe_customer.discount, None) self.assertEqual(stripe_customer.email, user.delivery_email) assert stripe_customer.metadata is not None self.assertEqual(stripe_customer.metadata["realm_str"], "zulip") try: int(stripe_customer.metadata["realm_id"]) except ValueError: # nocoverage raise AssertionError("realm_id is not a number") [invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id)) self.assertEqual(invoice.amount_due, 123 * 80 * 100) self.assertEqual(invoice.amount_paid, 0) self.assertEqual(invoice.amount_remaining, 123 * 80 * 100) self.assertTrue(invoice.auto_advance) self.assertEqual(invoice.collection_method, "send_invoice") self.assertEqual(invoice.customer_email, self.example_email("hamlet")) self.assertEqual(invoice.status, "open") customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm) plan = CustomerPlan.objects.get( customer=customer, automanage_licenses=False, price_per_license=8000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, invoiced_through=LicenseLedger.objects.first(), next_invoice_date=free_trial_end_date, tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.FREE_TRIAL, # For invoice billing. charge_automatically=False, ) self.check_initial_ledger_entry(plan, 123) audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") .order_by("id") ) self.assertEqual( audit_log_entries[:3], [ ( AuditLogEventType.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), ), (AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now), (AuditLogEventType.REALM_PLAN_TYPE_CHANGED, self.now), ], ) self.assertEqual(audit_log_entries[2][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED) first_audit_log_entry = ( RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED) .values_list("extra_data", flat=True) .first() ) assert first_audit_log_entry is not None self.assertFalse(first_audit_log_entry["automanage_licenses"]) realm = get_realm("zulip") self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually"], response) for substring in [ "Zulip Cloud Standard (free trial)", str(self.seat_count), "Number of licenses for next billing period", f"{self.seat_count} in use", "To ensure continuous access", "please pay", "before the end of your trial", "March 2, 2012", "Invoice", ]: self.assert_in_response(substring, response) [invoice_item] = iter(invoice.lines) self.assertEqual(invoice_item.amount, 123 * 80 * 100) self.assertEqual(invoice_item.description, "Zulip Cloud Standard") self.assertEqual(invoice_item.quantity, 123) self.assertFalse(invoice_item.discountable) self.assertEqual(invoice_item.period.start, datetime_to_timestamp(free_trial_end_date)) self.assertEqual( invoice_item.period.end, datetime_to_timestamp(add_months(free_trial_end_date, 12)) ) with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked: invoice_plans_as_needed(self.next_month) mocked.assert_not_called() mocked.reset_mock() customer_plan = CustomerPlan.objects.get(customer=customer) self.assertEqual(customer_plan.status, CustomerPlan.FREE_TRIAL) self.assertEqual(customer_plan.next_invoice_date, free_trial_end_date) cursor = self.pin_event_cursor() last_renewal_ledger = ( LicenseLedger.objects.filter(plan=plan, is_renewal=True).order_by("-id").first() ) assert last_renewal_ledger is not None self.assertEqual( last_renewal_ledger.event_time, self.now, ) # Customer pays the invoice assert invoice.id is not None stripe.Invoice.pay(invoice.id, paid_out_of_band=True) self.send_stripe_webhook_events(cursor, "invoice.paid") with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_in_success_response(["You have no outstanding invoices."], response) self.assert_in_success_response( [f"An invoice will be sent to {user.delivery_email} on the same day."], response, ) invoice_plans_as_needed(free_trial_end_date) last_renewal_ledger.refresh_from_db() customer_plan.refresh_from_db() realm.refresh_from_db() plan.refresh_from_db() self.assertEqual(customer_plan.status, CustomerPlan.ACTIVE) self.assertEqual(customer_plan.next_invoice_date, add_months(free_trial_end_date, 1)) self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(last_renewal_ledger.event_time, free_trial_end_date) self.assertEqual(customer_plan.billing_cycle_anchor, free_trial_end_date) before_ledger_count = LicenseLedger.objects.filter(plan=plan).count() self.billing_session.make_end_of_cycle_updates_if_needed(plan, free_trial_end_date) after_ledger_count = LicenseLedger.objects.filter(plan=plan).count() # No additional ledger entries are created. self.assertEqual(before_ledger_count, after_ledger_count) @mock_stripe() def test_free_trial_upgrade_by_invoice_voided(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) with self.settings(CLOUD_FREE_TRIAL_DAYS=60), time_machine.travel(self.now, tick=False): self.upgrade(invoice=True) customer = Customer.objects.get(realm=user.realm) stripe_customer_id = assert_is_not_none(customer.stripe_customer_id) [stripe_invoice] = iter(stripe.Invoice.list(customer=stripe_customer_id)) assert stripe_invoice.id is not None self.assertEqual(stripe_invoice.status, "open") invoice = Invoice.objects.get(customer=customer, stripe_invoice_id=stripe_invoice.id) self.assertEqual(invoice.status, Invoice.SENT) self.assertIsNone(invoice.get_last_associated_event()) # Support voids the unpaid invoice in Stripe, delivering an invoice.voided webhook. cursor = self.pin_event_cursor() stripe.Invoice.void_invoice(stripe_invoice.id) self.send_stripe_webhook_events(cursor, "invoice.voided") invoice.refresh_from_db() self.assertEqual(invoice.status, Invoice.VOID) event = invoice.get_last_associated_event() assert event is not None self.assertEqual(event.type, "invoice.voided") self.assertEqual(event.status, Event.EVENT_HANDLER_SUCCEEDED) def test_free_trial_billing_page_after_invoice_voided(self) -> None: # An invoice-billed free trial whose invoice is later voided should # show the pending downgrade instead of a stale prompt to pay it. user = self.example_user("hamlet") self.login_user(user) with self.settings(CLOUD_FREE_TRIAL_DAYS=60), time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, False, True, ) customer = get_customer_by_realm(user.realm) assert customer is not None plan = get_current_plan_by_customer(customer) assert plan is not None self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL) invoice = Invoice.objects.get(plan=plan) self.assertTrue(invoice.is_created_for_free_trial_upgrade) self.assertEqual(invoice.status, Invoice.SENT) mock_customer = Mock(email=user.delivery_email) with ( time_machine.travel(self.now, tick=False), patch("corporate.lib.stripe.stripe_get_customer", return_value=mock_customer), ): response = self.client_get("/billing/") self.assert_in_success_response(["To ensure continuous access", "please pay"], response) # Mark the invoice void, as the invoice.voided webhook does; see # test_free_trial_upgrade_by_invoice_voided for that half of the flow. invoice.status = Invoice.VOID invoice.save(update_fields=["status"]) response = self.client_get("/billing/") self.assert_in_success_response( [ ( "will be downgraded to Zulip Cloud Free " "at the end of the free trial" ) ], response, ) self.assert_not_in_success_response( ["To ensure continuous access", "please pay"], response ) def test_make_end_of_cycle_updates_errors_without_free_trial_invoice(self) -> None: realm = get_realm("zulip") customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_123") free_trial_end_date = self.now + timedelta(days=30) plan = CustomerPlan.objects.create( customer=customer, tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.FREE_TRIAL, # Billed by invoice rather than charged automatically. charge_automatically=False, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, price_per_license=8000, next_invoice_date=free_trial_end_date, ) LicenseLedger.objects.create( plan=plan, is_renewal=True, event_time=self.now, licenses=10, licenses_at_next_renewal=10, ) # No Invoice object exists for the plan. billing_session = RealmBillingSession(realm=realm) with self.assertRaises(BillingError) as billing_context: billing_session.make_end_of_cycle_updates_if_needed(plan, free_trial_end_date) self.assertEqual( f"Invoice-billed free trial has no invoice: {plan}.", billing_context.exception.error_description, ) @mock_stripe() def test_free_trial_upgrade_by_invoice_with_additional_users_after_payment( self, *mocks: Mock ) -> None: user = self.example_user("hamlet") self.login_user(user) free_trial_end_date = self.now + timedelta(days=60) with self.settings(CLOUD_FREE_TRIAL_DAYS=60): response = self.client_get("/upgrade/") self.assert_in_success_response( ["Your card will not be charged", "free trial", "60-day"], response ) self.assertNotEqual(user.realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertFalse(Customer.objects.filter(realm=user.realm).exists()) with time_machine.travel(self.now, tick=False): self.upgrade(invoice=True) stripe_customer = stripe_get_customer( assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id) ) self.assertEqual(stripe_customer.discount, None) self.assertEqual(stripe_customer.email, user.delivery_email) assert stripe_customer.metadata is not None self.assertEqual(stripe_customer.metadata["realm_str"], "zulip") try: int(stripe_customer.metadata["realm_id"]) except ValueError: # nocoverage raise AssertionError("realm_id is not a number") [invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id)) self.assertEqual(invoice.amount_due, 123 * 80 * 100) self.assertEqual(invoice.amount_paid, 0) self.assertEqual(invoice.amount_remaining, 123 * 80 * 100) self.assertTrue(invoice.auto_advance) self.assertEqual(invoice.collection_method, "send_invoice") self.assertEqual(invoice.customer_email, self.example_email("hamlet")) self.assertEqual(invoice.status, "open") customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm) plan = CustomerPlan.objects.get( customer=customer, automanage_licenses=False, price_per_license=8000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, invoiced_through=LicenseLedger.objects.first(), next_invoice_date=free_trial_end_date, tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.FREE_TRIAL, # For invoice billing. charge_automatically=False, ) self.check_initial_ledger_entry(plan, 123) audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") .order_by("id") ) self.assertEqual( audit_log_entries[:3], [ ( AuditLogEventType.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), ), (AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now), (AuditLogEventType.REALM_PLAN_TYPE_CHANGED, self.now), ], ) self.assertEqual(audit_log_entries[2][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED) first_audit_log_entry = ( RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED) .values_list("extra_data", flat=True) .first() ) assert first_audit_log_entry is not None self.assertFalse(first_audit_log_entry["automanage_licenses"]) realm = get_realm("zulip") self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually"], response) for substring in [ "Zulip Cloud Standard (free trial)", str(self.seat_count), "Number of licenses for next billing period", f"{self.seat_count} in use", "To ensure continuous access", "please pay", "before the end of your trial", "March 2, 2012", "Invoice", ]: self.assert_in_response(substring, response) [invoice_item] = iter(invoice.lines) self.assertEqual(invoice_item.amount, 123 * 80 * 100) self.assertEqual(invoice_item.description, "Zulip Cloud Standard") self.assertEqual(invoice_item.quantity, 123) self.assertFalse(invoice_item.discountable) self.assertEqual(invoice_item.period.start, datetime_to_timestamp(free_trial_end_date)) self.assertEqual( invoice_item.period.end, datetime_to_timestamp(add_months(free_trial_end_date, 12)) ) with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked: invoice_plans_as_needed(self.next_month) mocked.assert_not_called() mocked.reset_mock() customer_plan = CustomerPlan.objects.get(customer=customer) self.assertEqual(customer_plan.status, CustomerPlan.FREE_TRIAL) self.assertEqual(customer_plan.next_invoice_date, free_trial_end_date) cursor = self.pin_event_cursor() # Customer pays the invoice assert invoice.id is not None stripe.Invoice.pay(invoice.id, paid_out_of_band=True) self.send_stripe_webhook_events(cursor, "invoice.paid") with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_in_success_response(["You have no outstanding invoices."], response) update_plan_request = UpdatePlanRequest( status=None, licenses=125, licenses_at_next_renewal=None, schedule=None, toggle_license_management=False, ) # Customer cannot update licenses while in free trial. with time_machine.travel(self.now, tick=False), self.assertRaises(JsonableError) as exc: self.billing_session.do_update_plan(update_plan_request) self.assertEqual( str(exc.exception), "Cannot update licenses in the current billing period for free trial plan.", ) # Customer paid the invoice and then added 2 new users for the next renewal period. update_plan_request = UpdatePlanRequest( status=None, licenses=None, licenses_at_next_renewal=125, schedule=None, toggle_license_management=False, ) self.check_last_ledger_entry_license_counts(plan, 123, 123) with time_machine.travel(self.now, tick=False): self.billing_session.do_update_plan(update_plan_request) self.check_last_ledger_entry_license_counts(plan, 123, 125) invoice_plans_as_needed(free_trial_end_date) customer_plan.refresh_from_db() realm.refresh_from_db() plan.refresh_from_db() self.assertEqual(customer_plan.status, CustomerPlan.ACTIVE) self.assertEqual(customer_plan.next_invoice_date, add_months(free_trial_end_date, 1)) self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(customer_plan.billing_cycle_anchor, free_trial_end_date) [additional_license_invoice, renewal_invoice] = iter( stripe.Invoice.list(customer=stripe_customer.id) ) self.assertEqual(renewal_invoice.id, invoice.id) self.assertEqual(additional_license_invoice.amount_due, 2 * 80 * 100) self.assertEqual(additional_license_invoice.amount_paid, 0) self.assertEqual(additional_license_invoice.amount_remaining, 2 * 80 * 100) self.assertTrue(additional_license_invoice.auto_advance) self.assertEqual(additional_license_invoice.collection_method, "send_invoice") self.assertEqual( additional_license_invoice.customer_email, self.example_email("hamlet") ) self.assertEqual(additional_license_invoice.status, "open") @mock_stripe() def test_free_trial_upgrade_by_invoice_customer_fails_to_pay(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) free_trial_end_date = self.now + timedelta(days=60) with self.settings(CLOUD_FREE_TRIAL_DAYS=60): response = self.client_get("/upgrade/") self.assert_in_success_response( ["Your card will not be charged", "free trial", "60-day"], response ) self.assertNotEqual(user.realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertFalse(Customer.objects.filter(realm=user.realm).exists()) with time_machine.travel(self.now, tick=False): self.upgrade(invoice=True) stripe_customer = stripe_get_customer( assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id) ) self.assertEqual(stripe_customer.discount, None) self.assertEqual(stripe_customer.email, user.delivery_email) assert stripe_customer.metadata is not None self.assertEqual(stripe_customer.metadata["realm_str"], "zulip") try: int(stripe_customer.metadata["realm_id"]) except ValueError: # nocoverage raise AssertionError("realm_id is not a number") [invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id)) self.assertEqual(invoice.amount_due, 123 * 80 * 100) self.assertEqual(invoice.amount_paid, 0) self.assertEqual(invoice.amount_remaining, 123 * 80 * 100) self.assertTrue(invoice.auto_advance) self.assertEqual(invoice.collection_method, "send_invoice") self.assertEqual(invoice.customer_email, self.example_email("hamlet")) self.assertEqual(invoice.status, "open") customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm) plan = CustomerPlan.objects.get( customer=customer, automanage_licenses=False, price_per_license=8000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, invoiced_through=LicenseLedger.objects.first(), next_invoice_date=free_trial_end_date, tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.FREE_TRIAL, # For invoice billing. charge_automatically=False, ) self.check_initial_ledger_entry(plan, 123) audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") .order_by("id") ) self.assertEqual( audit_log_entries[:3], [ ( AuditLogEventType.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), ), (AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now), (AuditLogEventType.REALM_PLAN_TYPE_CHANGED, self.now), ], ) self.assertEqual(audit_log_entries[2][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED) first_audit_log_entry = ( RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED) .values_list("extra_data", flat=True) .first() ) assert first_audit_log_entry is not None self.assertFalse(first_audit_log_entry["automanage_licenses"]) realm = get_realm("zulip") self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually"], response) for substring in [ "Zulip Cloud Standard (free trial)", str(self.seat_count), "Number of licenses for next billing period", f"{self.seat_count} in use", "To ensure continuous access", "please pay", "before the end of your trial", "March 2, 2012", "Invoice", ]: self.assert_in_response(substring, response) [invoice_item] = iter(invoice.lines) self.assertEqual(invoice_item.amount, 123 * 80 * 100) self.assertEqual(invoice_item.description, "Zulip Cloud Standard") self.assertEqual(invoice_item.quantity, 123) self.assertFalse(invoice_item.discountable) self.assertEqual(invoice_item.period.start, datetime_to_timestamp(free_trial_end_date)) self.assertEqual( invoice_item.period.end, datetime_to_timestamp(add_months(free_trial_end_date, 12)) ) # We reached free trial end but customer didn't pay the invoice. invoice_plans_as_needed(free_trial_end_date) customer_plan = CustomerPlan.objects.get(customer=customer) self.assertEqual(customer_plan.status, CustomerPlan.ENDED) realm.refresh_from_db() self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_LIMITED) response = self.client_get("/upgrade/") self.assert_in_success_response( ["Your free trial", "has expired", "To reactivate", "please pay"], response ) # Customer decides to pay later cursor = self.pin_event_cursor() assert invoice.id is not None stripe.Invoice.pay(invoice.id, paid_out_of_band=True) self.send_stripe_webhook_events(cursor, "invoice.paid") invoice_plans_as_needed(free_trial_end_date) CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE) realm.refresh_from_db() self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) @mock_stripe() def test_upgrade_by_card_with_outdated_seat_count(self, *mocks: Mock) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) # Higher than original seat count new_seat_count = 23 initial_upgrade_request = InitialUpgradeRequest( manual_license_management=False, tier=CustomerPlan.TIER_CLOUD_STANDARD, billing_modality="charge_automatically", ) billing_session = RealmBillingSession(hamlet) _, context_when_upgrade_page_is_rendered = billing_session.get_initial_upgrade_context( initial_upgrade_request ) # Change the seat count in upgrade flow: after do_upgrade, during process_initial_upgrade with ( patch( "corporate.lib.stripe.BillingSession.stale_license_count_check", return_value=self.seat_count, ), patch("corporate.lib.stripe.get_latest_seat_count", return_value=new_seat_count), patch( "corporate.lib.stripe.RealmBillingSession.get_initial_upgrade_context", return_value=(_, context_when_upgrade_page_is_rendered), ), ): self.add_card_and_upgrade(hamlet) customer = Customer.objects.first() assert customer is not None stripe_customer_id: str = assert_is_not_none(customer.stripe_customer_id) # Check that the Charge used the old quantity, not new_seat_count [charge] = iter(stripe.Charge.list(customer=stripe_customer_id)) self.assertEqual(8000 * self.seat_count, charge.amount) # Check that the invoice has a credit for the old amount and a charge for the new one [additional_license_invoice, upgrade_invoice] = iter( stripe.Invoice.list(customer=stripe_customer_id) ) self.assertEqual( [8000 * self.seat_count], [item.amount for item in upgrade_invoice.lines], ) self.assertEqual( [8000 * (new_seat_count - self.seat_count)], [item.amount for item in additional_license_invoice.lines], ) # Check LicenseLedger has the new amount plan = get_current_plan_by_customer(customer) assert plan is not None self.check_last_ledger_entry_license_counts(plan, new_seat_count, new_seat_count) @mock_stripe() def test_upgrade_by_card_with_outdated_lower_seat_count(self, *mocks: Mock) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) new_seat_count = self.seat_count - 1 initial_upgrade_request = InitialUpgradeRequest( manual_license_management=False, tier=CustomerPlan.TIER_CLOUD_STANDARD, billing_modality="charge_automatically", ) billing_session = RealmBillingSession(hamlet) _, context_when_upgrade_page_is_rendered = billing_session.get_initial_upgrade_context( initial_upgrade_request ) # Change the seat count in upgrade flow: after do_upgrade, during process_initial_upgrade with ( patch( "corporate.lib.stripe.BillingSession.stale_license_count_check", return_value=self.seat_count, ), patch("corporate.lib.stripe.get_latest_seat_count", return_value=new_seat_count), patch( "corporate.lib.stripe.RealmBillingSession.get_initial_upgrade_context", return_value=(_, context_when_upgrade_page_is_rendered), ), ): self.add_card_and_upgrade(hamlet) customer = Customer.objects.first() assert customer is not None stripe_customer_id: str = assert_is_not_none(customer.stripe_customer_id) # Check that the Charge used the old quantity, not new_seat_count [charge] = iter(stripe.Charge.list(customer=stripe_customer_id)) self.assertEqual(8000 * self.seat_count, charge.amount) [upgrade_invoice] = iter(stripe.Invoice.list(customer=stripe_customer_id)) self.assertEqual( [8000 * self.seat_count], [item.amount for item in upgrade_invoice.lines], ) # Check LicenseLedger has the reduced license count at renewal plan = get_current_plan_by_customer(customer) assert plan is not None self.check_last_ledger_entry_license_counts(plan, self.seat_count, new_seat_count) # Check that we informed the support team about the potential billing error. from django.core.mail import outbox self.assert_length(outbox, 1) for message in outbox: self.assert_length(message.to, 1) self.assertEqual(message.to[0], "sales@zulip.com") self.assertEqual( message.subject, f"Check initial licenses invoiced for {billing_session.billing_entity_display_name}", ) self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS) @mock_stripe() def test_upgrade_by_card_with_outdated_seat_count_and_minimum_for_plan_tier( self, *mocks: Mock ) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) # New seat count is under the minimum for the plan tier minimum_for_plan_tier = self.seat_count - 1 new_seat_count = self.seat_count - 2 initial_upgrade_request = InitialUpgradeRequest( manual_license_management=False, tier=CustomerPlan.TIER_CLOUD_STANDARD, billing_modality="charge_automatically", ) billing_session = RealmBillingSession(hamlet) _, context_when_upgrade_page_is_rendered = billing_session.get_initial_upgrade_context( initial_upgrade_request ) assert context_when_upgrade_page_is_rendered is not None assert context_when_upgrade_page_is_rendered.get("seat_count") == self.seat_count # Change the current and minimum license counts in do_upgrade with ( patch( "corporate.lib.stripe.BillingSession.min_licenses_for_plan", return_value=minimum_for_plan_tier, ), patch("corporate.lib.stripe.get_latest_seat_count", return_value=new_seat_count), patch( "corporate.lib.stripe.RealmBillingSession.get_initial_upgrade_context", return_value=(_, context_when_upgrade_page_is_rendered), ), ): self.add_card_and_upgrade(hamlet) customer = Customer.objects.first() assert customer is not None stripe_customer_id: str = assert_is_not_none(customer.stripe_customer_id) # Check that the Charge used the minimum seat count [charge] = iter(stripe.Charge.list(customer=stripe_customer_id)) self.assertEqual(8000 * minimum_for_plan_tier, charge.amount) [upgrade_invoice] = iter(stripe.Invoice.list(customer=stripe_customer_id)) self.assertEqual( [8000 * minimum_for_plan_tier], [item.amount for item in upgrade_invoice.lines], ) # Check LicenseLedger has the minimum license count plan = get_current_plan_by_customer(customer) assert plan is not None self.check_last_ledger_entry_license_counts( plan, minimum_for_plan_tier, minimum_for_plan_tier ) @mock_stripe() def test_customer_minimum_licenses_for_plan(self, *mocks: Mock) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) # We set a 1 license minimum for the initial upgrade. minimum_for_plan_tier = 1 with ( patch( "corporate.lib.stripe.BillingSession.min_licenses_for_plan", return_value=minimum_for_plan_tier, ), ): self.add_card_and_upgrade(hamlet, tier=CustomerPlan.TIER_CLOUD_PLUS) customer = Customer.objects.first() assert customer is not None assert customer.stripe_customer_id is not None # Check LicenseLedger has the current seat count. plan = get_current_plan_by_customer(customer) assert plan is not None self.check_last_ledger_entry_license_counts(plan, self.seat_count, self.seat_count) # We manually set customer.minimum_licenses to the current seat count, # which is below the general Plus plan minimum licenses. customer.minimum_licenses = self.seat_count customer.save() # Next year, they are still invoiced for the current seat count. invoice_plans_as_needed(self.next_year) # Check both invoices (initial and renewal) [invoice0, invoice1] = iter(stripe.Invoice.list(customer=customer.stripe_customer_id)) self.assertEqual( [12000 * self.seat_count], [item.amount for item in invoice0.lines], ) self.assertEqual( [12000 * self.seat_count], [item.amount for item in invoice1.lines], ) # Without the minimum_licenses set on the customer, a BillingError is raised when # invoicing plans. customer.minimum_licenses = None customer.save() with self.assertLogs("corporate.stripe", level="ERROR") as m: invoice_plans_as_needed(self.next_year + timedelta(days=366)) self.assertIn( f"ERROR:corporate.stripe:Invoicing failed: Customer.id: {customer.id}", m.output[0], ) self.assertIn( "Renewal licenses (6) less than minimum licenses (10) required for plan Zulip Cloud Plus.", m.output[0], ) def test_upgrade_with_tampered_seat_count(self) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) with self.assertLogs("corporate.stripe", "WARNING"): response = self.upgrade(talk_to_stripe=False, salt="badsalt") self.assert_json_error_contains(response, "Something went wrong. Please contact") self.assertEqual(orjson.loads(response.content)["error_description"], "tampered seat count") @mock_stripe() def test_upgrade_race_condition_during_card_upgrade(self, *mocks: Mock) -> None: hamlet = self.example_user("hamlet") iago = self.example_user("iago") self.login_user(iago) iago_upgrade_page_response = self.client_get("/upgrade/") self.login_user(hamlet) self.add_card_to_customer_for_upgrade() cursor_before_upgrade = self.pin_event_cursor() hamlet_upgrade_page_response = self.client_get("/upgrade/") self.client_billing_post( "/billing/upgrade", { "billing_modality": "charge_automatically", "schedule": "annual", "signed_seat_count": self.get_signed_seat_count_from_response( hamlet_upgrade_page_response ), "salt": self.get_salt_from_response(hamlet_upgrade_page_response), "license_management": "automatic", }, ) # Get the last generated invoice for Hamlet customer = get_customer_by_realm(get_realm("zulip")) assert customer is not None assert customer.stripe_customer_id is not None [hamlet_invoice] = iter(stripe.Invoice.list(customer=customer.stripe_customer_id)) self.login_user(iago) with self.settings(CLOUD_FREE_TRIAL_DAYS=60): # Iago completed the upgrade while we were waiting on success payment event for Hamlet. # NOTE: Used free trial to avoid creating any stripe invoice events. self.client_billing_post( "/billing/upgrade", { "billing_modality": "charge_automatically", "schedule": "annual", "signed_seat_count": self.get_signed_seat_count_from_response( iago_upgrade_page_response ), "salt": self.get_salt_from_response(iago_upgrade_page_response), "license_management": "automatic", }, ) with self.assertLogs("corporate.stripe", "WARNING"): self.send_stripe_webhook_events(cursor_before_upgrade) assert hamlet_invoice.id is not None self.assert_details_of_valid_invoice_payment_from_event_status_endpoint( hamlet_invoice.id, { "status": "paid", "event_handler": { "status": "failed", "error": { "message": "The organization is already subscribed to a plan. Please reload the billing page.", "description": "subscribing with existing subscription", }, }, }, ) # Check that we informed the support team about the failure. from django.core.mail import outbox self.assert_length(outbox, 1) for message in outbox: self.assert_length(message.to, 1) self.assertEqual(message.to[0], "sales@zulip.com") self.assertEqual(message.subject, "Error processing paid customer invoice") self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS) def test_upgrade_race_condition_during_invoice_upgrade(self) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) self.local_upgrade(self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False) with ( self.assertLogs("corporate.stripe", "WARNING") as m, self.assertRaises(BillingError) as context, ): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) self.assertEqual( "subscribing with existing subscription", context.exception.error_description ) self.assertEqual( m.output[0], "WARNING:corporate.stripe:Upgrade of (stripe: cus_123) failed because of existing active plan.", ) self.assert_length(m.output, 1) @mock_stripe() def test_check_upgrade_parameters(self, *mocks: Mock) -> None: def check_error( error_message: str, error_description: str, upgrade_params: Mapping[str, Any], del_args: Sequence[str] = [], ) -> None: if error_description: with self.assertLogs("corporate.stripe", "WARNING"): response = self.upgrade( talk_to_stripe=False, del_args=del_args, **upgrade_params ) self.assertEqual( orjson.loads(response.content)["error_description"], error_description ) else: response = self.upgrade(talk_to_stripe=False, del_args=del_args, **upgrade_params) self.assert_json_error_contains(response, error_message) hamlet = self.example_user("hamlet") self.login_user(hamlet) self.add_card_to_customer_for_upgrade() check_error("Invalid billing_modality", "", {"billing_modality": "invalid"}) check_error("Invalid schedule", "", {"schedule": "invalid"}) check_error("Invalid license_management", "", {"license_management": "invalid"}) check_error( "Something went wrong. Please contact", "unknown license_management", {}, del_args=["license_management"], ) check_error( "You must purchase licenses for all active users in your organization (minimum 30).", "not enough licenses", {"billing_modality": "send_invoice", "licenses": -1}, ) check_error( "You must purchase licenses for all active users in your organization (minimum 30).", "not enough licenses", {"billing_modality": "send_invoice"}, ) check_error( "You must purchase licenses for all active users in your organization (minimum 30).", "not enough licenses", {"billing_modality": "send_invoice", "licenses": 25}, ) check_error( "Invoices with more than 1000 licenses can't be processed from this page", "too many licenses", {"billing_modality": "send_invoice", "licenses": 10000}, ) check_error( "You must purchase licenses for all active users in your organization (minimum 6).", "not enough licenses", {"billing_modality": "charge_automatically", "license_management": "manual"}, ) check_error( "You must purchase licenses for all active users in your organization (minimum 6).", "not enough licenses", { "billing_modality": "charge_automatically", "license_management": "manual", "licenses": 3, }, ) @mock_stripe() def test_upgrade_license_counts(self, *mocks: Mock) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) self.add_card_to_customer_for_upgrade() def check_min_licenses_error( invoice: bool, licenses: int | None, min_licenses_in_response: int, upgrade_params: Mapping[str, Any] = {}, ) -> None: upgrade_params = dict(upgrade_params) if licenses is None: del_args = ["licenses"] else: del_args = [] upgrade_params["licenses"] = licenses with self.assertLogs("corporate.stripe", "WARNING"): response = self.upgrade( invoice=invoice, talk_to_stripe=False, del_args=del_args, **upgrade_params ) self.assert_json_error_contains(response, f"minimum {min_licenses_in_response}") self.assertEqual( orjson.loads(response.content)["error_description"], "not enough licenses" ) def check_max_licenses_error(licenses: int) -> None: with self.assertLogs("corporate.stripe", "WARNING"): response = self.upgrade(invoice=True, talk_to_stripe=False, licenses=licenses) self.assert_json_error_contains( response, f"with more than {MAX_INVOICED_LICENSES} licenses" ) self.assertEqual( orjson.loads(response.content)["error_description"], "too many licenses" ) def check_success( invoice: bool, licenses: int | None, upgrade_params: Mapping[str, Any] = {} ) -> None: upgrade_params = dict(upgrade_params) if licenses is None: del_args = ["licenses"] else: del_args = [] upgrade_params["licenses"] = licenses with ( patch("corporate.lib.stripe.BillingSession.process_initial_upgrade"), patch( "corporate.lib.stripe.BillingSession.create_stripe_invoice_and_charge", return_value="fake_stripe_invoice_id", ), ): response = self.upgrade( invoice=invoice, talk_to_stripe=False, del_args=del_args, **upgrade_params ) self.assert_json_success(response) # Autopay with licenses < seat count check_min_licenses_error( False, self.seat_count - 1, self.seat_count, {"license_management": "manual"} ) # Autopay with not setting licenses check_min_licenses_error(False, None, self.seat_count, {"license_management": "manual"}) # Invoice with licenses < MIN_INVOICED_LICENSES check_min_licenses_error(True, MIN_INVOICED_LICENSES - 1, MIN_INVOICED_LICENSES) # Invoice with licenses < seat count with patch("corporate.lib.stripe.MIN_INVOICED_LICENSES", 3): check_min_licenses_error(True, 4, self.seat_count) # Invoice with not setting licenses check_min_licenses_error(True, None, MIN_INVOICED_LICENSES) # Invoice exceeding max licenses check_max_licenses_error(MAX_INVOICED_LICENSES + 1) with patch( "corporate.lib.stripe.get_latest_seat_count", return_value=MAX_INVOICED_LICENSES + 5 ): check_max_licenses_error(MAX_INVOICED_LICENSES + 5) # Autopay with automatic license_management check_success(False, None) # Autopay with automatic license_management, should just ignore the licenses entry check_success(False, self.seat_count) # Autopay check_success(False, self.seat_count, {"license_management": "manual"}) # Autopay has no limit on max licenses check_success(False, MAX_INVOICED_LICENSES + 1, {"license_management": "manual"}) # Invoice check_success(True, self.seat_count + MIN_INVOICED_LICENSES) # Invoice check_success(True, MAX_INVOICED_LICENSES) # By default, an organization on a "Pay by card" plan with Manual license # management cannot purchase less licenses than the current seat count. # If exempt_from_license_number_check is enabled, they should be able to though. customer = Customer.objects.get_or_create(realm=hamlet.realm)[0] customer.exempt_from_license_number_check = True customer.save() check_success(False, self.seat_count - 1, {"license_management": "manual"}) @mock_stripe() def test_upgrade_with_uncaught_exception(self, *mock_args: Any) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) self.add_card_to_customer_for_upgrade() with ( patch( "corporate.lib.stripe.BillingSession.create_stripe_invoice_and_charge", side_effect=Exception, ), self.assertLogs("corporate.stripe", "WARNING") as m, ): response = self.upgrade(talk_to_stripe=False) self.assertIn("ERROR:corporate.stripe:Uncaught exception in billing", m.output[0]) self.assertIn(m.records[0].stack_info, m.output[0]) self.assert_json_error_contains( response, "Something went wrong. Please contact desdemona+admin@zulip.com." ) self.assertEqual( orjson.loads(response.content)["error_description"], "uncaught exception during upgrade" ) @mock_stripe() def test_invoice_payment_succeeded_event_with_uncaught_exception(self, *mock_args: Any) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) self.add_card_to_customer_for_upgrade() with ( patch( "corporate.lib.stripe.BillingSession.process_initial_upgrade", side_effect=Exception ), self.assertLogs("corporate.stripe", "WARNING"), ): response = self.upgrade() response_dict = self.assert_json_success(response) self.assert_details_of_valid_invoice_payment_from_event_status_endpoint( response_dict["stripe_invoice_id"], { "status": "paid", "event_handler": { "status": "failed", "error": { "message": "Something went wrong. Please contact desdemona+admin@zulip.com.", "description": "uncaught exception in invoice.paid event handler", }, }, }, ) def test_redirect_for_billing_page(self) -> None: user = self.example_user("othello") self.login_user(user) response = self.client_get("/billing/") not_admin_message = "You do not have permission to view this page." self.assert_in_success_response([not_admin_message], response) user.realm.plan_type = Realm.PLAN_TYPE_STANDARD_FREE user.realm.save() response = self.client_get("/billing/") self.assert_in_success_response([not_admin_message], response) # Billing page redirects to sponsorship page for standard free admins. user = self.example_user("hamlet") self.login_user(user) response = self.client_get("/billing/") self.assertEqual(response.status_code, 302) self.assertEqual("/sponsorship/", response["Location"]) user.realm.plan_type = Realm.PLAN_TYPE_LIMITED user.realm.save() customer = Customer.objects.create(realm=user.realm, stripe_customer_id="cus_123") response = self.client_get("/billing/") self.assertEqual(response.status_code, 302) self.assertEqual("/plans/", response["Location"]) # Check redirects for sponsorship pending customer.sponsorship_pending = True customer.save() response = self.client_get("/billing/") self.assertEqual(response.status_code, 302) self.assertEqual("/sponsorship/", response["Location"]) # Don't redirect to sponsorship for paid plans. user.realm.plan_type = Realm.PLAN_TYPE_STANDARD user.realm.save() response = self.client_get("/billing/") self.assertNotEqual("/sponsorship/", response["Location"]) user.realm.plan_type = Realm.PLAN_TYPE_PLUS user.realm.save() response = self.client_get("/billing/") self.assertNotEqual("/sponsorship/", response["Location"]) @mock_stripe() def test_redirect_for_billing_page_downgrade_at_free_trial_end(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) with self.settings(CLOUD_FREE_TRIAL_DAYS=30): response = self.client_get("/upgrade/") free_trial_end_date = self.now + timedelta(days=30) self.assert_in_success_response( ["Your card will not be charged", "free trial", "30-day"], response ) self.assertNotEqual(user.realm.plan_type, Realm.PLAN_TYPE_STANDARD) self.assertFalse(Customer.objects.filter(realm=user.realm).exists()) stripe_customer = self.add_card_and_upgrade(user) customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm) plan = CustomerPlan.objects.get( customer=customer, automanage_licenses=True, price_per_license=8000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, invoiced_through=LicenseLedger.objects.first(), next_invoice_date=free_trial_end_date, tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.FREE_TRIAL, # For payment through card. charge_automatically=True, ) self.check_initial_ledger_entry(plan, self.seat_count) realm = get_realm("zulip") self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually"], response) for substring in [ "Zulip Cloud Standard (free trial)", "Your plan will automatically renew on", "February 1, 2012", "Visa ending in 4242", "Update card", ]: self.assert_in_response(substring, response) # schedule downgrade with ( time_machine.travel(self.now + timedelta(days=3), tick=False), self.assertLogs("corporate.stripe", "INFO") as m, ): response = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}, ) self.assert_json_success(response) plan.refresh_from_db() self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL) expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}" self.assertEqual(m.output[0], expected_log) # Visit /billing on free-trial end date before the invoice cron runs. with time_machine.travel(free_trial_end_date, tick=False): response = self.client_get("/billing/") self.assertEqual(response.status_code, 302) self.assertEqual("/plans/", response["Location"]) def test_upgrade_page_for_demo_organizations(self) -> None: user = self.example_user("hamlet") user.realm.demo_organization_scheduled_deletion_date = timezone_now() + timedelta(days=30) user.realm.save() self.login_user(user) response = self.client_get("/billing/", follow=True) self.assert_in_success_response( ["Demo organizations cannot be directly upgraded to a paid plan."], response ) def test_redirect_for_upgrade_page(self) -> None: user = self.example_user("iago") cordelia = self.example_user("cordelia") self.login_user(cordelia) # Cordelia is not in `can_manage_billing_group`, so can't access the page. response = self.client_get("/upgrade/") self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], "/billing/") self.login_user(user) response = self.client_get("/upgrade/") self.assertEqual(response.status_code, 200) user.realm.plan_type = Realm.PLAN_TYPE_STANDARD_FREE user.realm.save() response = self.client_get("/upgrade/") self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], "http://zulip.testserver/sponsorship") stripe_customer_id = "cus_123" # Avoid contacting stripe as we only want to check redirects here. with ( 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="test@zulip.com"), ), ): user.realm.plan_type = Realm.PLAN_TYPE_LIMITED user.realm.save() customer = Customer.objects.create( realm=user.realm, stripe_customer_id=stripe_customer_id ) response = self.client_get("/upgrade/") self.assertEqual(response.status_code, 200) CustomerPlan.objects.create( customer=customer, billing_cycle_anchor=timezone_now(), billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, tier=CustomerPlan.TIER_CLOUD_STANDARD, ) response = self.client_get("/upgrade/") self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], "http://zulip.testserver/billing") with self.settings(CLOUD_FREE_TRIAL_DAYS=30): response = self.client_get("/upgrade/") self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], "http://zulip.testserver/billing") def test_get_latest_seat_count(self) -> None: realm = get_realm("zulip") initial_count = get_latest_seat_count(realm) user1 = UserProfile.objects.create( realm=realm, email="user1@zulip.com", delivery_email="user1@zulip.com" ) user2 = UserProfile.objects.create( realm=realm, email="user2@zulip.com", delivery_email="user2@zulip.com" ) self.assertEqual(get_latest_seat_count(realm), initial_count + 2) # Test that bots aren't counted user1.is_bot = True user1.save(update_fields=["is_bot"]) self.assertEqual(get_latest_seat_count(realm), initial_count + 1) # Test that inactive users aren't counted do_deactivate_user(user2, acting_user=None) self.assertEqual(get_latest_seat_count(realm), initial_count) # Test guests # Adding a guest to a realm with a lot of members shouldn't change anything UserProfile.objects.create( realm=realm, email="user3@zulip.com", delivery_email="user3@zulip.com", role=UserProfile.ROLE_GUEST, ) self.assertEqual(get_latest_seat_count(realm), initial_count) # Test 1 member and 5 guests realm = do_create_realm(string_id="second", name="second") UserProfile.objects.create( realm=realm, email="member@second.com", delivery_email="member@second.com" ) for i in range(5): UserProfile.objects.create( realm=realm, email=f"guest{i}@second.com", delivery_email=f"guest{i}@second.com", role=UserProfile.ROLE_GUEST, ) self.assertEqual(get_latest_seat_count(realm), 1) # Test 1 member and 6 guests UserProfile.objects.create( realm=realm, email="guest5@second.com", delivery_email="guest5@second.com", role=UserProfile.ROLE_GUEST, ) self.assertEqual(get_latest_seat_count(realm), 2) def test_sign_string(self) -> None: string = "abc" signed_string, salt = sign_string(string) self.assertEqual(string, unsign_string(signed_string, salt)) with self.assertRaises(signing.BadSignature): unsign_string(signed_string, "randomsalt") @mock_stripe() def test_payment_method_string(self, *mocks: Mock) -> None: # If you pay by invoice, your payment method should be # "Invoice", even if you have a card on file. user = self.example_user("hamlet") billing_session = RealmBillingSession(user) billing_session.create_stripe_customer() self.login_user(user) self.add_card_to_customer_for_upgrade() self.upgrade(invoice=True) response = self.client_get("/billing/") self.assert_not_in_success_response(["Visa ending in"], response) self.assert_in_success_response(["Invoice", "You will receive an invoice for"], response) @mock_stripe() def test_replace_payment_method(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) self.add_card_and_upgrade(user) # Check that the card is displayed on the billing page. response = self.client_get("/billing/") self.assert_in_success_response(["Visa ending in 4242"], response) # Create an open invoice customer = Customer.objects.first() assert customer is not None stripe_customer_id = customer.stripe_customer_id assert stripe_customer_id is not None stripe_invoice = stripe.Invoice.create(customer=stripe_customer_id) assert stripe_invoice.id is not None stripe.InvoiceItem.create( invoice=stripe_invoice.id, amount=5000, currency="usd", customer=stripe_customer_id ) stripe.Invoice.finalize_invoice(stripe_invoice) RealmAuditLog.objects.filter(event_type=AuditLogEventType.STRIPE_CARD_CHANGED).delete() start_session_json_response = self.client_billing_post( "/billing/session/start_card_update_session" ) response_dict = self.assert_json_success(start_session_json_response) self.assert_details_of_valid_session_from_event_status_endpoint( response_dict["stripe_session_id"], { "type": "card_update_from_billing_page", "status": "created", "is_manual_license_management_upgrade_session": False, "tier": None, }, ) with self.assertRaises(stripe.CardError): # We don't have to handle this since the Stripe Checkout page would # ask Customer to enter a valid card number. trigger_stripe_checkout_session_completed_webhook # emulates what happens in the Stripe Checkout page. Adding this check mostly for coverage of # create_payment_method. self.trigger_stripe_checkout_session_completed_webhook( self.get_test_card_token(attaches_to_customer=False) ) start_session_json_response = self.client_billing_post( "/billing/session/start_card_update_session" ) response_dict = self.assert_json_success(start_session_json_response) self.assert_details_of_valid_session_from_event_status_endpoint( response_dict["stripe_session_id"], { "type": "card_update_from_billing_page", "status": "created", "is_manual_license_management_upgrade_session": False, "tier": None, }, ) with self.assertLogs("corporate.stripe", "INFO") as m: self.trigger_stripe_checkout_session_completed_webhook( self.get_test_card_token(attaches_to_customer=True, charge_succeeds=False) ) self.assertEqual( m.output[0], "INFO:corporate.stripe:Stripe card error: 402 card_error card_declined None", ) response_dict = self.assert_json_success(start_session_json_response) self.assert_details_of_valid_session_from_event_status_endpoint( response_dict["stripe_session_id"], { "type": "card_update_from_billing_page", "status": "completed", "is_manual_license_management_upgrade_session": False, "tier": None, "event_handler": { "status": "failed", "error": {"message": "Your card was declined.", "description": "card error"}, }, }, ) response = self.client_get("/billing/") self.assert_in_success_response(["Visa ending in 0341"], response) assert RealmAuditLog.objects.filter( event_type=AuditLogEventType.STRIPE_CARD_CHANGED ).exists() stripe_payment_methods = stripe.PaymentMethod.list(customer=stripe_customer_id, type="card") self.assert_length(stripe_payment_methods, 2) for stripe_payment_method in stripe_payment_methods: stripe.PaymentMethod.detach(stripe_payment_method.id) response = self.client_get("/billing/") self.assert_in_success_response(["No payment method on file."], response) start_session_json_response = self.client_billing_post( "/billing/session/start_card_update_session" ) self.assert_json_success(start_session_json_response) self.trigger_stripe_checkout_session_completed_webhook( self.get_test_card_token( attaches_to_customer=True, charge_succeeds=True, card_provider="mastercard" ) ) response_dict = self.assert_json_success(start_session_json_response) self.assert_details_of_valid_session_from_event_status_endpoint( response_dict["stripe_session_id"], { "type": "card_update_from_billing_page", "status": "completed", "is_manual_license_management_upgrade_session": False, "tier": None, "event_handler": {"status": "succeeded"}, }, ) self.login_user(self.example_user("othello")) response = self.client_billing_get( "/billing/event/status", {"stripe_session_id": response_dict["stripe_session_id"]}, ) self.assert_json_error_contains(response, "Insufficient permission") self.login_user(self.example_user("hamlet")) response = self.client_get("/billing/") self.assert_in_success_response(["Mastercard ending in 4444"], response) self.assert_length(stripe.PaymentMethod.list(customer=stripe_customer_id, type="card"), 1) # Ideally we'd also test that we don't pay invoices with collection_method=='send_invoice' for stripe_invoice in stripe.Invoice.list(customer=stripe_customer_id): self.assertEqual(stripe_invoice.status, "paid") self.assertEqual( 2, RealmAuditLog.objects.filter(event_type=AuditLogEventType.STRIPE_CARD_CHANGED).count(), ) # Test if manual license management upgrade session is created and is successfully recovered. start_session_json_response = self.client_billing_post( "/upgrade/session/start_card_update_session", { "manual_license_management": "true", "tier": 1, }, ) response_dict = self.assert_json_success(start_session_json_response) self.assert_details_of_valid_session_from_event_status_endpoint( response_dict["stripe_session_id"], { "type": "card_update_from_upgrade_page", "status": "created", "is_manual_license_management_upgrade_session": True, "tier": 1, }, ) def test_downgrade(self) -> None: user = self.example_user("hamlet") self.login_user(user) with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) customer = get_customer_by_realm(user.realm) assert customer is not None plan = get_current_plan_by_customer(customer) assert plan is not None self.assertEqual(plan.licenses(), self.seat_count) self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count) with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), ): response = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}, ) expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) plan.refresh_from_db() self.assertEqual(plan.licenses(), self.seat_count) self.assertEqual(plan.licenses_at_next_renewal(), None) with time_machine.travel(self.now, tick=False): mock_customer = Mock(email=user.delivery_email) mock_customer.invoice_settings.default_payment_method = Mock( spec=stripe.PaymentMethod, type=Mock() ) with patch("corporate.lib.stripe.stripe_get_customer", return_value=mock_customer): response = self.client_get("/billing/") self.assert_in_success_response( [ "Your organization will be downgraded to Zulip Cloud Free at the end of the current billing", "January 2, 2013", "Reactivate subscription", ], response, ) # Verify that we still write LicenseLedger rows during the remaining # part of the cycle billing_session = RealmBillingSession(user=user, realm=user.realm) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20): billing_session.update_license_ledger_if_needed(self.now) self.check_last_ledger_entry_license_counts(plan, 20, 20) # Verify that we invoice them for the additional users mocked = self.setup_mocked_stripe(invoice_plans_as_needed, self.next_month) mocked["InvoiceItem"].create.assert_called_once() mocked["Invoice"].finalize_invoice.assert_called_once() mocked["Invoice"].create.assert_called_once() # Check that we downgrade properly if the cycle is over with patch("corporate.lib.stripe.get_latest_seat_count", return_value=30): billing_session.update_license_ledger_if_needed(self.next_year) plan.refresh_from_db() self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_LIMITED) self.assertEqual(plan.status, CustomerPlan.ENDED) self.check_last_ledger_entry_license_counts(plan, 20, 20) realm_audit_log = RealmAuditLog.objects.latest("id") self.assertEqual(realm_audit_log.event_type, AuditLogEventType.REALM_PLAN_TYPE_CHANGED) self.assertEqual(realm_audit_log.acting_user, None) # Verify that we don't write LicenseLedger rows once we've downgraded with patch("corporate.lib.stripe.get_latest_seat_count", return_value=40): billing_session.update_license_ledger_if_needed(self.next_year) self.check_last_ledger_entry_license_counts(plan, 20, 20) # Verify that we call invoice_plan once more after cycle end but # don't invoice them for users added after the cycle end plan.refresh_from_db() self.assertIsNotNone(plan.next_invoice_date) mocked = self.setup_mocked_stripe( invoice_plans_as_needed, self.next_year + timedelta(days=32) ) mocked["InvoiceItem"].create.assert_not_called() mocked["Invoice"].finalize_invoice.assert_not_called() mocked["Invoice"].create.assert_not_called() # Check that we updated next_invoice_date in invoice_plan plan.refresh_from_db() self.assertIsNone(plan.next_invoice_date) # Check that we don't call invoice_plan after that final call with patch("corporate.lib.stripe.get_latest_seat_count", return_value=50): billing_session.update_license_ledger_if_needed(self.next_year + timedelta(days=80)) mocked = self.setup_mocked_stripe( invoice_plans_as_needed, self.next_year + timedelta(days=400) ) mocked["InvoiceItem"].create.assert_not_called() mocked["Invoice"].finalize_invoice.assert_not_called() mocked["Invoice"].create.assert_not_called() @mock_stripe() def test_switch_from_monthly_plan_to_annual_plan_for_automatic_license_management( self, *mocks: Mock ) -> None: user = self.example_user("hamlet") self.login_user(user) self.add_card_and_upgrade(user, schedule="monthly") customer = get_customer_by_realm(user.realm) assert customer is not None monthly_plan = get_current_plan_by_customer(customer) assert monthly_plan is not None self.assertEqual(monthly_plan.automanage_licenses, True) self.assertEqual(monthly_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_MONTHLY) with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), ): response = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}, ) expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {monthly_plan.id}, status: {CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) monthly_plan.refresh_from_db() self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE) with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_in_success_response( ["Your plan will switch to annual billing on February 2, 2012"], response ) billing_session = RealmBillingSession(user=user, realm=user.realm) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20): billing_session.update_license_ledger_if_needed(self.now) self.assertEqual(LicenseLedger.objects.filter(plan=monthly_plan).count(), 2) self.check_last_ledger_entry_license_counts(monthly_plan, 20, 20) with ( time_machine.travel(self.next_month, tick=False), patch("corporate.lib.stripe.get_latest_seat_count", return_value=25), ): billing_session.update_license_ledger_if_needed(self.next_month) self.assertEqual(LicenseLedger.objects.filter(plan=monthly_plan).count(), 2) customer = get_customer_by_realm(user.realm) assert customer is not None self.assertEqual(CustomerPlan.objects.filter(customer=customer).count(), 2) monthly_plan.refresh_from_db() self.assertEqual(monthly_plan.status, CustomerPlan.ENDED) self.assertEqual(monthly_plan.next_invoice_date, self.next_month) annual_plan = get_current_plan_by_realm(user.realm) assert annual_plan is not None self.assertEqual(annual_plan.status, CustomerPlan.ACTIVE) self.assertEqual(annual_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_ANNUAL) self.assertEqual( annual_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT ) self.assertEqual(annual_plan.billing_cycle_anchor, self.next_month) self.assertEqual(annual_plan.next_invoice_date, self.next_month) self.assertEqual(annual_plan.invoiced_through, None) annual_ledger_entries = LicenseLedger.objects.filter(plan=annual_plan).order_by("id") self.assert_length(annual_ledger_entries, 2) self.assertEqual(annual_ledger_entries[0].is_renewal, True) self.assertEqual( annual_ledger_entries.values_list("licenses", "licenses_at_next_renewal")[0], (20, 20) ) self.assertEqual(annual_ledger_entries[1].is_renewal, False) self.assertEqual( annual_ledger_entries.values_list("licenses", "licenses_at_next_renewal")[1], (25, 25) ) audit_log = RealmAuditLog.objects.get( event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN ) self.assertEqual(audit_log.realm, user.realm) self.assertEqual(audit_log.extra_data["monthly_plan_id"], monthly_plan.id) self.assertEqual(audit_log.extra_data["annual_plan_id"], annual_plan.id) invoice_plans_as_needed(self.next_month) annual_ledger_entries = LicenseLedger.objects.filter(plan=annual_plan).order_by("id") self.assert_length(annual_ledger_entries, 2) annual_plan.refresh_from_db() self.assertEqual(annual_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_DONE) self.assertEqual(annual_plan.invoiced_through, annual_ledger_entries[1]) self.assertEqual(annual_plan.billing_cycle_anchor, self.next_month) self.assertEqual(annual_plan.next_invoice_date, add_months(self.next_month, 1)) monthly_plan.refresh_from_db() self.assertEqual(monthly_plan.next_invoice_date, None) assert customer.stripe_customer_id [invoice0, invoice1, _invoice2] = iter( stripe.Invoice.list(customer=customer.stripe_customer_id) ) [invoice_item0, invoice_item1] = iter(invoice0.lines) self.assertEqual(invoice_item0.amount, 5 * 80 * 100) self.assertEqual(invoice_item0.description, "Additional Zulip Cloud Standard license") self.assertEqual(invoice_item0.quantity, 5) self.assertEqual(invoice_item0.discountable, False) self.assertEqual(invoice_item0.period.start, datetime_to_timestamp(self.next_month)) self.assertEqual( invoice_item0.period.end, datetime_to_timestamp(add_months(self.next_month, 12)) ) self.assertEqual(invoice_item1.amount, 20 * 80 * 100) self.assertEqual(invoice_item1.description, "Zulip Cloud Standard - renewal") self.assertEqual(invoice_item1.quantity, 20) self.assertFalse(invoice_item1.discountable) self.assertEqual(invoice_item1.period.start, datetime_to_timestamp(self.next_month)) self.assertEqual( invoice_item1.period.end, datetime_to_timestamp(add_months(self.next_month, 12)) ) [monthly_plan_invoice_item] = iter(invoice1.lines) self.assertEqual(monthly_plan_invoice_item.amount, 14 * 8 * 100) self.assertEqual( monthly_plan_invoice_item.description, "Additional Zulip Cloud Standard license" ) self.assertEqual(monthly_plan_invoice_item.quantity, 14) self.assertFalse(monthly_plan_invoice_item.discountable) self.assertEqual(monthly_plan_invoice_item.period.start, datetime_to_timestamp(self.now)) self.assertEqual( monthly_plan_invoice_item.period.end, datetime_to_timestamp(self.next_month) ) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=30): billing_session.update_license_ledger_if_needed(add_months(self.next_month, 1)) invoice_plans_as_needed(add_months(self.next_month, 1)) [invoice0, invoice1, _invoice2, _invoice3] = iter( stripe.Invoice.list(customer=customer.stripe_customer_id) ) [monthly_plan_invoice_item] = iter(invoice0.lines) self.assertEqual(monthly_plan_invoice_item.amount, 5 * 7366) self.assertEqual( monthly_plan_invoice_item.description, "Additional Zulip Cloud Standard license" ) self.assertEqual(monthly_plan_invoice_item.quantity, 5) self.assertFalse(monthly_plan_invoice_item.discountable) self.assertEqual( monthly_plan_invoice_item.period.start, datetime_to_timestamp(add_months(self.next_month, 1)), ) self.assertEqual( monthly_plan_invoice_item.period.end, datetime_to_timestamp(add_months(self.next_month, 12)), ) # Fast forward next_invoice_date to one year from the day we switched to annual plan. annual_plan.next_invoice_date = add_months(self.now, 13) annual_plan.save(update_fields=["next_invoice_date"]) invoice_plans_as_needed(add_months(self.now, 13)) [invoice0, invoice1, _invoice2, _invoice3, _invoice4] = iter( stripe.Invoice.list(customer=customer.stripe_customer_id) ) [invoice_item] = iter(invoice0.lines) self.assertEqual(invoice_item.amount, 30 * 80 * 100) self.assertEqual(invoice_item.description, "Zulip Cloud Standard - renewal") self.assertEqual(invoice_item.quantity, 30) self.assertFalse(invoice_item.discountable) self.assertEqual( invoice_item.period.start, datetime_to_timestamp(add_months(self.next_month, 12)) ) self.assertEqual( invoice_item.period.end, datetime_to_timestamp(add_months(self.next_month, 24)) ) @mock_stripe() def test_switch_from_monthly_plan_to_annual_plan_for_manual_license_management( self, *mocks: Mock ) -> None: user = self.example_user("hamlet") num_licenses = 35 self.login_user(user) self.add_card_and_upgrade( user, schedule="monthly", license_management="manual", licenses=num_licenses ) customer = get_customer_by_realm(user.realm) assert customer is not None monthly_plan = get_current_plan_by_customer(customer) assert monthly_plan is not None self.assertEqual(monthly_plan.automanage_licenses, False) self.assertEqual(monthly_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_MONTHLY) with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), ): response = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}, ) self.assertEqual( m.output[0], f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {monthly_plan.id}, status: {CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}", ) self.assert_json_success(response) monthly_plan.refresh_from_db() self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE) with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_in_success_response( ["Your plan will switch to annual billing on February 2, 2012"], response ) invoice_plans_as_needed(self.next_month) self.assertEqual(LicenseLedger.objects.filter(plan=monthly_plan).count(), 1) customer = get_customer_by_realm(user.realm) assert customer is not None self.assertEqual(CustomerPlan.objects.filter(customer=customer).count(), 2) monthly_plan.refresh_from_db() self.assertEqual(monthly_plan.status, CustomerPlan.ENDED) self.assertEqual(monthly_plan.next_invoice_date, None) annual_plan = get_current_plan_by_realm(user.realm) assert annual_plan is not None self.assertEqual(annual_plan.status, CustomerPlan.ACTIVE) self.assertEqual(annual_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_ANNUAL) self.assertEqual( annual_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT ) self.assertEqual(annual_plan.billing_cycle_anchor, self.next_month) self.assertEqual(annual_plan.next_invoice_date, self.next_month) annual_ledger_entries = LicenseLedger.objects.filter(plan=annual_plan).order_by("id") self.assert_length(annual_ledger_entries, 1) self.assertEqual(annual_ledger_entries[0].is_renewal, True) self.assertEqual( annual_ledger_entries.values_list("licenses", "licenses_at_next_renewal")[0], (num_licenses, num_licenses), ) self.assertEqual(annual_plan.invoiced_through, None) # First call of invoice_plans_as_needed creates the new plan. Second call # calls invoice_plan on the newly created plan. invoice_plans_as_needed(self.next_month + timedelta(days=1)) annual_plan.refresh_from_db() self.assertEqual(annual_plan.invoiced_through, annual_ledger_entries[0]) self.assertEqual(annual_plan.next_invoice_date, add_months(self.next_month, 1)) self.assertEqual(annual_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_DONE) assert customer.stripe_customer_id [invoice0, _invoice1] = iter(stripe.Invoice.list(customer=customer.stripe_customer_id)) [invoice_item] = iter(invoice0.lines) self.assertEqual(invoice_item.amount, num_licenses * 80 * 100) self.assertEqual(invoice_item.description, "Zulip Cloud Standard - renewal") self.assertEqual(invoice_item.quantity, num_licenses) self.assertFalse(invoice_item.discountable) self.assertEqual(invoice_item.period.start, datetime_to_timestamp(self.next_month)) self.assertEqual( invoice_item.period.end, datetime_to_timestamp(add_months(self.next_month, 12)) ) invoice_plans_as_needed(add_months(self.now, 13)) [invoice0, _invoice1, _invoice2] = iter( stripe.Invoice.list(customer=customer.stripe_customer_id) ) [invoice_item] = iter(invoice0.lines) self.assertEqual(invoice_item.amount, num_licenses * 80 * 100) self.assertEqual(invoice_item.description, "Zulip Cloud Standard - renewal") self.assertEqual(invoice_item.quantity, num_licenses) self.assertFalse(invoice_item.discountable) self.assertEqual( invoice_item.period.start, datetime_to_timestamp(add_months(self.next_month, 12)) ) self.assertEqual( invoice_item.period.end, datetime_to_timestamp(add_months(self.next_month, 24)) ) @mock_stripe() def test_switch_from_annual_plan_to_monthly_plan_for_automatic_license_management( self, *mocks: Mock ) -> None: user = self.example_user("hamlet") self.login_user(user) self.add_card_and_upgrade(user, schedule="annual") customer = get_customer_by_realm(user.realm) assert customer is not None annual_plan = get_current_plan_by_customer(customer) assert annual_plan is not None self.assertEqual(annual_plan.automanage_licenses, True) self.assertEqual(annual_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_ANNUAL) assert self.now is not None with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), ): response = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE}, ) expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {annual_plan.id}, status: {CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) annual_plan.refresh_from_db() self.assertEqual(annual_plan.status, CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE) with time_machine.travel(self.now, tick=False): response = self.client_get("/billing/") self.assert_in_success_response( ["Your plan will switch to monthly billing on January 2, 2013"], response ) billing_session = RealmBillingSession(user=user, realm=user.realm) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20): billing_session.update_license_ledger_if_needed(self.now) self.assertEqual(LicenseLedger.objects.filter(plan=annual_plan).count(), 2) self.check_last_ledger_entry_license_counts(annual_plan, 20, 20) # Check that we don't switch to monthly plan at next invoice date (which is used to charge user for # additional licenses) but at the end of current billing cycle. self.assertEqual(annual_plan.next_invoice_date, self.next_month) assert annual_plan.next_invoice_date is not None with ( time_machine.travel(annual_plan.next_invoice_date, tick=False), patch("corporate.lib.stripe.get_latest_seat_count", return_value=25), ): billing_session.update_license_ledger_if_needed(annual_plan.next_invoice_date) annual_plan.refresh_from_db() self.assertEqual(annual_plan.status, CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE) self.assertEqual(annual_plan.next_invoice_date, self.next_month) self.assertEqual(annual_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_ANNUAL) self.assertEqual(LicenseLedger.objects.filter(plan=annual_plan).count(), 3) invoice_plans_as_needed(self.next_month + timedelta(days=1)) annual_plan.refresh_from_db() self.assertEqual(annual_plan.next_invoice_date, add_months(self.next_month, 1)) self.assertEqual(annual_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_DONE) self.assertEqual(LicenseLedger.objects.filter(plan=annual_plan).count(), 3) customer = get_customer_by_realm(user.realm) assert customer is not None assert customer.stripe_customer_id [invoice0, _invoice1] = iter(stripe.Invoice.list(customer=customer.stripe_customer_id)) [invoice_item1, invoice_item2] = iter(invoice0.lines) self.assertEqual(invoice_item1.amount, 7322 * 5) self.assertEqual(invoice_item1.description, "Additional Zulip Cloud Standard license") self.assertEqual(invoice_item1.quantity, 5) self.assertFalse(invoice_item1.discountable) self.assertEqual(invoice_item1.period.start, datetime_to_timestamp(self.next_month)) self.assertEqual(invoice_item1.period.end, datetime_to_timestamp(self.next_year)) self.assertEqual(invoice_item2.amount, 14 * 80 * 1 * 100) self.assertEqual(invoice_item2.description, "Additional Zulip Cloud Standard license") self.assertEqual(invoice_item2.quantity, 14) self.assertFalse(invoice_item2.discountable) self.assertEqual(invoice_item2.period.start, datetime_to_timestamp(self.now)) self.assertEqual(invoice_item2.period.end, datetime_to_timestamp(self.next_year)) # Check that we switch to monthly plan at the end of current billing cycle. with ( time_machine.travel(self.next_year, tick=False), patch("corporate.lib.stripe.get_latest_seat_count", return_value=25), ): billing_session.update_license_ledger_if_needed(self.next_year) self.assertEqual(LicenseLedger.objects.filter(plan=annual_plan).count(), 3) customer = get_customer_by_realm(user.realm) assert customer is not None annual_plan.refresh_from_db() self.assertEqual(annual_plan.status, CustomerPlan.ENDED) self.assertEqual(annual_plan.next_invoice_date, add_months(self.next_month, 1)) monthly_plan = get_current_plan_by_realm(user.realm) assert monthly_plan is not None self.assertEqual(monthly_plan.status, CustomerPlan.ACTIVE) self.assertEqual(monthly_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_MONTHLY) self.assertEqual( monthly_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT ) self.assertEqual(monthly_plan.billing_cycle_anchor, self.next_year) self.assertEqual(monthly_plan.next_invoice_date, self.next_year) self.assertEqual(monthly_plan.invoiced_through, None) monthly_ledger_entries = LicenseLedger.objects.filter(plan=monthly_plan).order_by("id") self.assert_length(monthly_ledger_entries, 2) self.assertEqual(monthly_ledger_entries[0].is_renewal, True) self.assertEqual( monthly_ledger_entries.values_list("licenses", "licenses_at_next_renewal")[0], (25, 25) ) self.assertEqual(monthly_ledger_entries[1].is_renewal, False) self.assertEqual( monthly_ledger_entries.values_list("licenses", "licenses_at_next_renewal")[1], (25, 25) ) audit_log = RealmAuditLog.objects.get( event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN ) self.assertEqual(audit_log.realm, user.realm) self.assertEqual(audit_log.extra_data["annual_plan_id"], annual_plan.id) self.assertEqual(audit_log.extra_data["monthly_plan_id"], monthly_plan.id) invoice_plans_as_needed(self.next_year) monthly_ledger_entries = LicenseLedger.objects.filter(plan=monthly_plan).order_by("id") self.assert_length(monthly_ledger_entries, 2) monthly_plan.refresh_from_db() self.assertEqual(monthly_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_DONE) self.assertEqual(monthly_plan.invoiced_through, monthly_ledger_entries[1]) self.assertEqual(monthly_plan.billing_cycle_anchor, self.next_year) self.assertEqual(monthly_plan.next_invoice_date, add_months(self.next_year, 1)) annual_plan.refresh_from_db() self.assertEqual(annual_plan.next_invoice_date, None) assert customer.stripe_customer_id [invoice0, _invoice1, _invoice2] = iter( stripe.Invoice.list(customer=customer.stripe_customer_id) ) [invoice_item0] = iter(invoice0.lines) self.assertEqual(invoice_item0.amount, 25 * 8 * 100) self.assertEqual(invoice_item0.description, "Zulip Cloud Standard - renewal") self.assertEqual(invoice_item0.quantity, 25) self.assertFalse(invoice_item0.discountable) self.assertEqual(invoice_item0.period.start, datetime_to_timestamp(self.next_year)) self.assertEqual( invoice_item0.period.end, datetime_to_timestamp(add_months(self.next_year, 1)) ) with time_machine.travel(self.next_year, tick=False): response = self.client_get("/billing/") self.assert_not_in_success_response( ["Your plan will switch to annual billing on February 2, 2012"], response ) def test_reupgrade_after_plan_status_changed_to_downgrade_at_end_of_cycle(self) -> None: user = self.example_user("hamlet") self.login_user(user) with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), ): response = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}, ) customer = get_customer_by_realm(user.realm) assert customer is not None plan = get_current_plan_by_customer(customer) assert plan is not None expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) plan.refresh_from_db() self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE) with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), ): response = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.ACTIVE}, ) expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.ACTIVE}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) plan.refresh_from_db() self.assertEqual(plan.status, CustomerPlan.ACTIVE) @patch("stripe.Invoice.create") @patch("stripe.Invoice.finalize_invoice") @patch("stripe.InvoiceItem.create") def test_downgrade_during_invoicing(self, *mocks: Mock) -> None: # The difference between this test and test_downgrade is that # CustomerPlan.status is DOWNGRADE_AT_END_OF_CYCLE rather than ENDED # when we call invoice_plans_as_needed # This test is essentially checking that we call make_end_of_cycle_updates_if_needed # during the invoicing process. user = self.example_user("hamlet") self.login_user(user) with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) customer = get_customer_by_realm(user.realm) assert customer is not None plan = get_current_plan_by_customer(customer) assert plan is not None with self.assertLogs("corporate.stripe", "INFO") as m: with time_machine.travel(self.now, tick=False): self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}, ) expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) plan.refresh_from_db() self.assertIsNotNone(plan.next_invoice_date) self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE) # Fast forward the next_invoice_date to next year. plan.next_invoice_date = self.next_year plan.save(update_fields=["next_invoice_date"]) invoice_plans_as_needed(self.next_year) plan.refresh_from_db() self.assertIsNone(plan.next_invoice_date) self.assertEqual(plan.status, CustomerPlan.ENDED) @mock_stripe() def test_switch_now_free_trial_from_monthly_to_annual(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) free_trial_end_date = self.now + timedelta(days=60) with self.settings(CLOUD_FREE_TRIAL_DAYS=60), time_machine.travel(self.now, tick=False): self.add_card_and_upgrade(user, schedule="monthly") plan = CustomerPlan.objects.get() self.assertEqual(plan.next_invoice_date, free_trial_end_date) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL) customer = get_customer_by_realm(user.realm) assert customer is not None result = self.client_billing_patch( "/billing/plan", { "status": CustomerPlan.FREE_TRIAL, "schedule": CustomerPlan.BILLING_SCHEDULE_ANNUAL, }, ) self.assert_json_success(result) plan.refresh_from_db() self.assertEqual(plan.status, CustomerPlan.ENDED) self.assertIsNone(plan.next_invoice_date) new_plan = CustomerPlan.objects.get( customer=customer, automanage_licenses=True, price_per_license=8000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL, next_invoice_date=free_trial_end_date, tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.FREE_TRIAL, charge_automatically=True, ) ledger_entry = self.check_initial_ledger_entry(new_plan, self.seat_count) self.assertEqual(new_plan.invoiced_through, ledger_entry) realm_audit_log = RealmAuditLog.objects.filter( event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN ).last() assert realm_audit_log is not None @mock_stripe() def test_switch_now_free_trial_from_annual_to_monthly(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) free_trial_end_date = self.now + timedelta(days=60) with self.settings(CLOUD_FREE_TRIAL_DAYS=60), time_machine.travel(self.now, tick=False): self.add_card_and_upgrade(user, schedule="annual") plan = CustomerPlan.objects.get() self.assertEqual(plan.next_invoice_date, free_trial_end_date) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL) customer = get_customer_by_realm(user.realm) assert customer is not None result = self.client_billing_patch( "/billing/plan", { "status": CustomerPlan.FREE_TRIAL, "schedule": CustomerPlan.BILLING_SCHEDULE_MONTHLY, }, ) self.assert_json_success(result) plan.refresh_from_db() self.assertEqual(plan.status, CustomerPlan.ENDED) self.assertIsNone(plan.next_invoice_date) new_plan = CustomerPlan.objects.get( customer=customer, automanage_licenses=True, price_per_license=800, fixed_price=None, discount=None, billing_cycle_anchor=self.now, billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY, next_invoice_date=free_trial_end_date, tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.FREE_TRIAL, charge_automatically=True, ) ledger_entry = self.check_initial_ledger_entry(new_plan, self.seat_count) self.assertEqual(new_plan.invoiced_through, ledger_entry) realm_audit_log = RealmAuditLog.objects.filter( event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN ).last() assert realm_audit_log is not None @mock_stripe() def test_end_free_trial(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) free_trial_end_date = self.now + timedelta(days=60) with self.settings(CLOUD_FREE_TRIAL_DAYS=60): with time_machine.travel(self.now, tick=False): self.add_card_and_upgrade(user, schedule="annual") plan = CustomerPlan.objects.get() self.assertEqual(plan.next_invoice_date, free_trial_end_date) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL) # Add some extra users before the realm is deactivated billing_session = RealmBillingSession(user=user, realm=user.realm) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=21): billing_session.update_license_ledger_if_needed(self.now) last_ledger_entry = self.check_last_ledger_entry_license_counts(plan, 21, 21) self.login_user(user) with time_machine.travel(self.now, tick=False): self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.ENDED}, ) plan.refresh_from_db() self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_LIMITED) self.assertEqual(plan.status, CustomerPlan.ENDED) self.assertEqual(plan.invoiced_through, last_ledger_entry) self.assertIsNone(plan.next_invoice_date) self.login_user(user) response = self.client_get("/billing/") self.assertEqual(response.status_code, 302) self.assertEqual("/plans/", response["Location"]) # The extra users added in the final month are not charged with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked: invoice_plans_as_needed(self.next_month) mocked.assert_not_called() # The plan is not renewed after an year with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked: invoice_plans_as_needed(self.next_year) mocked.assert_not_called() @mock_stripe() def test_downgrade_at_end_of_free_trial(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) free_trial_end_date = self.now + timedelta(days=60) with self.settings(CLOUD_FREE_TRIAL_DAYS=60): with time_machine.travel(self.now, tick=False): self.add_card_and_upgrade(user, schedule="annual") customer = get_customer_by_realm(user.realm) assert customer is not None plan = get_current_plan_by_customer(customer) assert plan is not None self.assertEqual(plan.next_invoice_date, free_trial_end_date) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL) self.assertEqual(plan.licenses(), self.seat_count) self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count) # Schedule downgrade with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), ): response = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}, ) expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) plan.refresh_from_db() self.assertEqual(plan.next_invoice_date, free_trial_end_date) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL) self.assertEqual(plan.licenses(), self.seat_count) self.assertEqual(plan.licenses_at_next_renewal(), None) with time_machine.travel(self.now, tick=False): mock_customer = Mock(email=user.delivery_email) mock_customer.invoice_settings.default_payment_method = Mock( spec=stripe.PaymentMethod, type=Mock() ) with patch("corporate.lib.stripe.stripe_get_customer", return_value=mock_customer): response = self.client_get("/billing/") self.assert_in_success_response( [ "Your organization will be downgraded to Zulip Cloud Free at the end of the free trial", "March 2, 2012", ], response, ) billing_session = RealmBillingSession(user=user, realm=user.realm) # Verify that we still write LicenseLedger rows during the remaining # part of the cycle with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20): billing_session.update_license_ledger_if_needed(self.now) self.check_last_ledger_entry_license_counts(plan, 20, 20) # Verify that we don't invoice them for the additional users during free trial. mocked = self.setup_mocked_stripe(invoice_plans_as_needed, self.next_month) mocked["InvoiceItem"].create.assert_not_called() mocked["Invoice"].finalize_invoice.assert_not_called() mocked["Invoice"].create.assert_not_called() # Check that we downgrade properly if the cycle is over with patch("corporate.lib.stripe.get_latest_seat_count", return_value=30): billing_session.update_license_ledger_if_needed(free_trial_end_date) plan.refresh_from_db() self.assertIsNone(plan.next_invoice_date) self.assertEqual(plan.status, CustomerPlan.ENDED) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_LIMITED) self.check_last_ledger_entry_license_counts(plan, 20, 20) # Verify that we don't write LicenseLedger rows once we've downgraded with patch("corporate.lib.stripe.get_latest_seat_count", return_value=40): billing_session.update_license_ledger_if_needed(self.next_year) self.check_last_ledger_entry_license_counts(plan, 20, 20) self.login_user(user) response = self.client_get("/billing/") self.assertEqual(response.status_code, 302) self.assertEqual("/plans/", response["Location"]) # The extra users added in the final month are not charged with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked: invoice_plans_as_needed(self.next_month) mocked.assert_not_called() # The plan is not renewed after an year with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked: invoice_plans_as_needed(self.next_year) mocked.assert_not_called() @mock_stripe() def test_cancel_downgrade_at_end_of_free_trial(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) free_trial_end_date = self.now + timedelta(days=60) with self.settings(CLOUD_FREE_TRIAL_DAYS=60): with time_machine.travel(self.now, tick=False): self.add_card_and_upgrade(user, schedule="annual") customer = get_customer_by_realm(user.realm) assert customer is not None plan = get_current_plan_by_customer(customer) assert plan is not None self.assertEqual(plan.next_invoice_date, free_trial_end_date) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL) self.assertEqual(plan.licenses(), self.seat_count) self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count) # Schedule downgrade with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), ): response = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}, ) expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) plan.refresh_from_db() self.assertEqual(plan.next_invoice_date, free_trial_end_date) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL) self.assertEqual(plan.licenses(), self.seat_count) self.assertEqual(plan.licenses_at_next_renewal(), None) # Cancel downgrade with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), ): response = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.FREE_TRIAL}, ) expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.FREE_TRIAL}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) plan.refresh_from_db() self.assertEqual(plan.next_invoice_date, free_trial_end_date) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL) self.assertEqual(plan.licenses(), self.seat_count) self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count) def test_reupgrade_by_billing_admin_after_downgrade(self) -> None: user = self.example_user("hamlet") with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) self.login_user(user) with self.assertLogs("corporate.stripe", "INFO") as m: with time_machine.travel(self.now, tick=False): self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}, ) customer = get_customer_by_realm(user.realm) assert customer is not None plan = get_current_plan_by_customer(customer) assert plan is not None expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) with ( self.assertRaises(BillingError) as context, self.assertLogs("corporate.stripe", "WARNING") as m, time_machine.travel(self.now, tick=False), ): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) self.assertEqual( m.output[0], "WARNING:corporate.stripe:Upgrade of (stripe: cus_123) failed because of existing active plan.", ) self.assertEqual( context.exception.error_description, "subscribing with existing subscription" ) # Fast forward the next_invoice_date to next year. plan.next_invoice_date = self.next_year plan.save(update_fields=["next_invoice_date"]) invoice_plans_as_needed(self.next_year) with time_machine.travel(self.next_year, tick=False): response = self.client_get("/billing/") self.assertEqual(response.status_code, 302) self.assertEqual("/plans/", response["Location"]) with time_machine.travel(self.next_year, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) self.assertEqual(Customer.objects.count(), 1) self.assertEqual(CustomerPlan.objects.count(), 2) current_plan = CustomerPlan.objects.all().order_by("id").last() assert current_plan is not None next_invoice_date = add_months(self.next_year, 1) self.assertEqual(current_plan.next_invoice_date, next_invoice_date) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(current_plan.status, CustomerPlan.ACTIVE) old_plan = CustomerPlan.objects.all().order_by("id").first() assert old_plan is not None self.assertEqual(old_plan.next_invoice_date, None) self.assertEqual(old_plan.status, CustomerPlan.ENDED) @mock_stripe() def test_update_licenses_of_manual_plan_from_billing_page(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login_user(user) with time_machine.travel(self.now, tick=False): self.upgrade(invoice=True, licenses=100) with time_machine.travel(self.now, tick=False): result = self.client_billing_patch("/billing/plan", {"licenses": 100}) self.assert_json_error_contains( result, "Your plan is already on 100 licenses in the current billing period." ) with time_machine.travel(self.now, tick=False): result = self.client_billing_patch( "/billing/plan", {"licenses_at_next_renewal": 100}, ) self.assert_json_error_contains( result, "Your plan is already scheduled to renew with 100 licenses." ) with time_machine.travel(self.now, tick=False): result = self.client_billing_patch("/billing/plan", {"licenses": 50}) self.assert_json_error_contains( result, "You cannot decrease the licenses in the current billing period." ) with time_machine.travel(self.now, tick=False): result = self.client_billing_patch( "/billing/plan", {"licenses_at_next_renewal": 25}, ) self.assert_json_error_contains( result, "You must purchase licenses for all active users in your organization (minimum 30).", ) with time_machine.travel(self.now, tick=False): result = self.client_billing_patch("/billing/plan", {"licenses": 2000}) self.assert_json_error_contains( result, "Invoices with more than 1000 licenses can't be processed from this page." ) with time_machine.travel(self.now, tick=False): result = self.client_billing_patch("/billing/plan", {"licenses": 150}) self.assert_json_success(result) invoice_plans_as_needed(self.next_year) stripe_customer = stripe_get_customer( assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id) ) [renewal_invoice, additional_licenses_invoice, _old_renewal_invoice] = iter( stripe.Invoice.list(customer=stripe_customer.id) ) self.assertEqual(renewal_invoice.amount_due, 8000 * 150) self.assertEqual(renewal_invoice.amount_paid, 0) self.assertEqual(renewal_invoice.attempt_count, 0) self.assertTrue(renewal_invoice.auto_advance) self.assertEqual(renewal_invoice.collection_method, "send_invoice") self.assertEqual(renewal_invoice.statement_descriptor, "Zulip Cloud Standard") self.assertEqual(renewal_invoice.status, "open") [renewal_item] = iter(renewal_invoice.lines) self.assertEqual(renewal_item.amount, 8000 * 150) self.assertEqual(renewal_item.description, "Zulip Cloud Standard - renewal") self.assertFalse(renewal_item.discountable) self.assertEqual(renewal_item.period.start, datetime_to_timestamp(self.next_year)) self.assertEqual( renewal_item.period.end, datetime_to_timestamp(self.next_year + timedelta(days=365)) ) self.assertEqual(renewal_item.quantity, 150) self.assertEqual(additional_licenses_invoice.amount_due, 8000 * 50) self.assertEqual(additional_licenses_invoice.amount_paid, 0) self.assertEqual(additional_licenses_invoice.attempt_count, 0) self.assertTrue(additional_licenses_invoice.auto_advance) self.assertEqual(additional_licenses_invoice.collection_method, "send_invoice") self.assertEqual(additional_licenses_invoice.statement_descriptor, "Zulip Cloud Standard") self.assertEqual(additional_licenses_invoice.status, "open") [extra_license_item] = iter(additional_licenses_invoice.lines) self.assertEqual(extra_license_item.amount, 8000 * 50) self.assertEqual(extra_license_item.description, "Additional Zulip Cloud Standard license") self.assertFalse(extra_license_item.discountable) self.assertEqual(extra_license_item.period.start, datetime_to_timestamp(self.now)) self.assertEqual(extra_license_item.period.end, datetime_to_timestamp(self.next_year)) self.assertEqual(extra_license_item.quantity, 50) with time_machine.travel(self.next_year, tick=False): result = self.client_billing_patch( "/billing/plan", {"licenses_at_next_renewal": 120}, ) self.assert_json_success(result) invoice_plans_as_needed(self.next_year + timedelta(days=365)) [renewal_invoice, _, _, _] = iter(stripe.Invoice.list(customer=stripe_customer.id)) self.assertEqual(renewal_invoice.amount_due, 8000 * 120) self.assertEqual(renewal_invoice.amount_paid, 0) self.assertEqual(renewal_invoice.attempt_count, 0) self.assertTrue(renewal_invoice.auto_advance) self.assertEqual(renewal_invoice.collection_method, "send_invoice") self.assertEqual(renewal_invoice.statement_descriptor, "Zulip Cloud Standard") self.assertEqual(renewal_invoice.status, "open") [renewal_item] = iter(renewal_invoice.lines) self.assertEqual(renewal_item.amount, 8000 * 120) self.assertEqual(renewal_item.description, "Zulip Cloud Standard - renewal") self.assertFalse(renewal_item.discountable) self.assertEqual( renewal_item.period.start, datetime_to_timestamp(self.next_year + timedelta(days=365)) ) self.assertEqual( renewal_item.period.end, datetime_to_timestamp(self.next_year + timedelta(days=2 * 365)) ) self.assertEqual(renewal_item.quantity, 120) def test_update_licenses_of_manual_plan_from_billing_page_exempt_from_license_number_check( self, ) -> None: """ Verifies that an organization exempt from the license number check can reduce their number of licenses. """ user = self.example_user("hamlet") self.login_user(user) customer = Customer.objects.get_or_create(realm=user.realm)[0] reduced_seat_count = get_latest_seat_count(user.realm) - 2 customer.exempt_from_license_number_check = True customer.save() paid_license_count = 100 with time_machine.travel(self.now, tick=False): self.local_upgrade( paid_license_count, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) with time_machine.travel(self.now, tick=False): result = self.client_billing_patch( "/billing/plan", {"licenses_at_next_renewal": reduced_seat_count}, ) self.assert_json_success(result) plan = get_current_plan_by_customer(customer) assert plan is not None self.check_last_ledger_entry_license_counts(plan, paid_license_count, reduced_seat_count) def test_upgrade_exempt_from_license_number_check_realm_less_licenses_than_seat_count( self, ) -> None: """ Verifies that an organization exempt from the license number check can upgrade their plan, specifying a number of licenses less than their current number of licenses and be charged for the number of licenses specified. Tests against a former bug, where the organization was charged for the current seat count, despite specifying a lower number of licenses. """ user = self.example_user("hamlet") self.login_user(user) customer = Customer.objects.get_or_create(realm=user.realm)[0] customer.exempt_from_license_number_check = True customer.save() reduced_seat_count = get_latest_seat_count(user.realm) - 2 with time_machine.travel(self.now, tick=False): self.local_upgrade( reduced_seat_count, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) plan = get_current_plan_by_customer(customer) assert plan is not None self.check_last_ledger_entry_license_counts(plan, reduced_seat_count, reduced_seat_count) def test_update_licenses_of_automatic_plan_from_billing_page(self) -> None: user = self.example_user("hamlet") self.login_user(user) with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) with time_machine.travel(self.now, tick=False): result = self.client_billing_patch("/billing/plan", {"licenses": 100}) self.assert_json_error_contains(result, "Your plan is on automatic license management.") with time_machine.travel(self.now, tick=False): result = self.client_billing_patch( "/billing/plan", {"licenses_at_next_renewal": 100}, ) self.assert_json_error_contains(result, "Your plan is on automatic license management.") def test_update_plan_with_invalid_status(self) -> None: with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) self.login_user(self.example_user("hamlet")) response = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.NEVER_STARTED}, ) self.assert_json_error_contains(response, "Invalid status") def test_update_plan_without_any_params(self) -> None: with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) self.login_user(self.example_user("hamlet")) with time_machine.travel(self.now, tick=False): response = self.client_billing_patch("/billing/plan", {}) self.assert_json_error_contains(response, "Nothing to change") def test_update_plan_that_which_is_due_for_expiry(self) -> None: with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) self.login_user(self.example_user("hamlet")) with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), ): result = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}, ) self.assert_json_success(result) self.assertRegex( m.output[0], r"INFO:corporate.stripe:Change plan status: Customer.id: \d*, CustomerPlan.id: \d*, status: 2", ) with time_machine.travel(self.next_year, tick=False): result = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.ACTIVE}, ) self.assert_json_error_contains( result, "Unable to update the plan. The plan has ended." ) def test_update_plan_that_which_is_due_for_replacement(self) -> None: with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_MONTHLY, True, False ) self.login_user(self.example_user("hamlet")) with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), ): result = self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}, ) self.assert_json_success(result) self.assertRegex( m.output[0], r"INFO:corporate.stripe:Change plan status: Customer.id: \d*, CustomerPlan.id: \d*, status: 4", ) with time_machine.travel(self.next_month, tick=False): result = self.client_billing_patch("/billing/plan", {}) self.assert_json_error_contains( result, "Unable to update the plan. The plan has been expired and replaced with a new plan.", ) @patch("corporate.lib.stripe.billing_logger.info") def test_deactivate_realm(self, mock_: Mock) -> None: user = self.example_user("hamlet") with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) plan = CustomerPlan.objects.get() self.assertEqual(plan.next_invoice_date, self.next_month) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(plan.status, CustomerPlan.ACTIVE) # Add some extra users before the realm is deactivated billing_session = RealmBillingSession(user=user, realm=user.realm) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20): billing_session.update_license_ledger_if_needed(self.now) last_ledger_entry = self.check_last_ledger_entry_license_counts(plan, 20, 20) do_deactivate_realm( get_realm("zulip"), acting_user=None, deactivation_reason="owner_request", email_owners=False, ) plan.refresh_from_db() self.assertTrue(get_realm("zulip").deactivated) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_LIMITED) self.assertEqual(plan.status, CustomerPlan.ENDED) self.assertEqual(plan.invoiced_through, last_ledger_entry) self.assertIsNone(plan.next_invoice_date) do_reactivate_realm(get_realm("zulip")) self.login_user(user) response = self.client_get("/billing/") self.assertEqual(response.status_code, 302) self.assertEqual("/plans/", response["Location"]) # The extra users added in the final month are not charged with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked: invoice_plans_as_needed(self.next_month) mocked.assert_not_called() # The plan is not renewed after an year with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked: invoice_plans_as_needed(self.next_year) mocked.assert_not_called() def test_reupgrade_by_billing_admin_after_realm_deactivation(self) -> None: user = self.example_user("hamlet") with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) do_deactivate_realm( get_realm("zulip"), acting_user=None, deactivation_reason="owner_request", email_owners=False, ) self.assertTrue(get_realm("zulip").deactivated) do_reactivate_realm(get_realm("zulip")) self.login_user(user) response = self.client_get("/billing/") self.assertEqual(response.status_code, 302) self.assertEqual("/plans/", response["Location"]) with time_machine.travel(self.now, tick=False): self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) self.assertEqual(Customer.objects.count(), 1) self.assertEqual(CustomerPlan.objects.count(), 2) current_plan = CustomerPlan.objects.all().order_by("id").last() assert current_plan is not None self.assertEqual(current_plan.next_invoice_date, self.next_month) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) self.assertEqual(current_plan.status, CustomerPlan.ACTIVE) old_plan = CustomerPlan.objects.all().order_by("id").first() assert old_plan is not None self.assertEqual(old_plan.next_invoice_date, None) self.assertEqual(old_plan.status, CustomerPlan.ENDED) @mock_stripe() def test_void_all_open_invoices(self, *mock: Mock) -> None: iago = self.example_user("iago") king = self.lear_user("king") voided_invoice_count = RealmBillingSession( user=None, realm=iago.realm ).void_all_open_invoices() self.assertEqual(voided_invoice_count, 0) zulip_customer = RealmBillingSession(iago).update_or_create_stripe_customer() lear_customer = RealmBillingSession(king).update_or_create_stripe_customer() assert zulip_customer.stripe_customer_id stripe_invoice = stripe.Invoice.create( auto_advance=True, collection_method="send_invoice", customer=zulip_customer.stripe_customer_id, days_until_due=30, statement_descriptor="Zulip Cloud Standard", ) assert stripe_invoice.id is not None stripe.InvoiceItem.create( invoice=stripe_invoice.id, currency="usd", customer=zulip_customer.stripe_customer_id, description="Zulip Cloud Standard upgrade", discountable=False, unit_amount_decimal=Decimal(800), quantity=8, ) stripe.Invoice.finalize_invoice(stripe_invoice) assert lear_customer.stripe_customer_id stripe_invoice = stripe.Invoice.create( auto_advance=True, collection_method="send_invoice", customer=lear_customer.stripe_customer_id, days_until_due=30, statement_descriptor="Zulip Cloud Standard", ) assert stripe_invoice.id is not None stripe.InvoiceItem.create( invoice=stripe_invoice.id, currency="usd", customer=lear_customer.stripe_customer_id, description="Zulip Cloud Standard upgrade", discountable=False, unit_amount_decimal=Decimal(800), quantity=8, ) stripe.Invoice.finalize_invoice(stripe_invoice) voided_invoice_count = RealmBillingSession( user=None, realm=iago.realm ).void_all_open_invoices() self.assertEqual(voided_invoice_count, 1) invoices = stripe.Invoice.list(customer=zulip_customer.stripe_customer_id) self.assert_length(invoices, 1) for invoice in invoices: self.assertEqual(invoice.status, "void") lear_stripe_customer_id = lear_customer.stripe_customer_id lear_customer.stripe_customer_id = None lear_customer.save(update_fields=["stripe_customer_id"]) voided_invoice_count = RealmBillingSession( user=None, realm=king.realm ).void_all_open_invoices() self.assertEqual(voided_invoice_count, 0) lear_customer.stripe_customer_id = lear_stripe_customer_id lear_customer.save(update_fields=["stripe_customer_id"]) voided_invoice_count = RealmBillingSession( user=None, realm=king.realm ).void_all_open_invoices() self.assertEqual(voided_invoice_count, 1) invoices = stripe.Invoice.list(customer=lear_customer.stripe_customer_id) self.assert_length(invoices, 1) for invoice in invoices: self.assertEqual(invoice.status, "void") def create_invoices(self, customer: Customer, num_invoices: int) -> list[stripe.Invoice]: invoices = [] assert customer.stripe_customer_id is not None for _ in range(num_invoices): invoice = stripe.Invoice.create( auto_advance=True, collection_method="send_invoice", customer=customer.stripe_customer_id, days_until_due=DEFAULT_INVOICE_DAYS_UNTIL_DUE, statement_descriptor="Zulip Cloud Standard", ) assert invoice.id is not None stripe.InvoiceItem.create( invoice=invoice.id, amount=10000, currency="usd", customer=customer.stripe_customer_id, description="Zulip Cloud Standard", discountable=False, ) stripe.Invoice.finalize_invoice(invoice) invoices.append(invoice) return invoices @mock_stripe() def test_downgrade_small_realms_behind_on_payments_as_needed(self, *mock: Mock) -> None: test_realm_count = 0 def create_realm( users_to_create: int, create_stripe_customer: bool, create_plan: bool, num_invoices: int | None = None, ) -> tuple[Realm, CustomerPlan | None, list[stripe.Invoice]]: nonlocal test_realm_count test_realm_count += 1 realm_string_id = "test-realm-" + str(test_realm_count) realm = do_create_realm( string_id=realm_string_id, name=realm_string_id, plan_type=Realm.PLAN_TYPE_SELF_HOSTED, ) users = [] for i in range(users_to_create): user = UserProfile.objects.create( delivery_email=f"user-{i}-{realm_string_id}@zulip.com", email=f"user-{i}-{realm_string_id}@zulip.com", realm=realm, ) users.append(user) user = users[0] user.role = UserProfile.ROLE_REALM_OWNER user.save(update_fields=["role"]) customer = None if create_stripe_customer: billing_session = RealmBillingSession(users[0]) customer = billing_session.create_stripe_customer() plan = None if create_plan: plan, _ = self.subscribe_realm_to_monthly_plan_on_manual_license_management( realm, users_to_create, users_to_create ) invoices = [] if num_invoices is not None: assert customer is not None invoices = self.create_invoices(customer, num_invoices) return realm, plan, invoices @dataclass class Row: realm: Realm expected_plan_type: int plan: CustomerPlan | None expected_plan_status: int | None expected_invoice_count: int email_expected_to_be_sent: bool rows: list[Row] = [] # no stripe customer ID (excluded from query) realm, _, _ = create_realm( users_to_create=1, create_stripe_customer=False, create_plan=False ) billing_session = RealmBillingSession( user=self.example_user("iago"), realm=realm, support_session=True ) billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD) billing_session.attach_discount_to_customer(640, 6400) rows.append(Row(realm, Realm.PLAN_TYPE_SELF_HOSTED, None, None, 0, False)) # no active paid plan or invoices (no action) realm, _, _ = create_realm( users_to_create=1, create_stripe_customer=True, create_plan=False ) rows.append(Row(realm, Realm.PLAN_TYPE_SELF_HOSTED, None, None, 0, False)) # no active plan, one unpaid invoice (will be voided, no downgrade or email) realm, _, _ = create_realm( users_to_create=1, create_stripe_customer=True, create_plan=False, num_invoices=1 ) rows.append(Row(realm, Realm.PLAN_TYPE_SELF_HOSTED, None, None, 0, False)) # active plan, no invoices (no action) realm, plan, _ = create_realm( users_to_create=1, create_stripe_customer=True, create_plan=True ) rows.append(Row(realm, Realm.PLAN_TYPE_STANDARD, plan, CustomerPlan.ACTIVE, 0, False)) # active plan, only one unpaid invoice (not downgraded or voided) realm, plan, _ = create_realm( users_to_create=1, create_stripe_customer=True, create_plan=True, num_invoices=1 ) rows.append(Row(realm, Realm.PLAN_TYPE_STANDARD, plan, CustomerPlan.ACTIVE, 1, False)) # active plan, two unpaid invoices (will be downgraded, voided and emailed) realm, plan, _ = create_realm( users_to_create=3, create_stripe_customer=True, create_plan=True, num_invoices=2 ) rows.append(Row(realm, Realm.PLAN_TYPE_LIMITED, plan, CustomerPlan.ENDED, 0, True)) # active plan, two paid invoices (not downgraded) realm, plan, invoices = create_realm( users_to_create=1, create_stripe_customer=True, create_plan=True, num_invoices=2 ) for invoice in invoices: stripe.Invoice.pay(invoice, paid_out_of_band=True) rows.append(Row(realm, Realm.PLAN_TYPE_STANDARD, plan, CustomerPlan.ACTIVE, 0, False)) # not a small realm, two unpaid invoices (not downgraded or voided) realm, plan, _ = create_realm( users_to_create=20, create_stripe_customer=True, create_plan=True, num_invoices=2 ) rows.append(Row(realm, Realm.PLAN_TYPE_STANDARD, plan, CustomerPlan.ACTIVE, 2, False)) # Customer objects without a realm should be excluded from query. remote_server = RemoteZulipServer.objects.create( uuid=str(uuid.uuid4()), api_key="magic_secret_api_key", hostname="demo.example.com", contact_email="email@example.com", ) Customer.objects.create(remote_server=remote_server, stripe_customer_id="cus_xxx") downgrade_small_realms_behind_on_payments_as_needed() from django.core.mail import outbox for row in rows: row.realm.refresh_from_db() self.assertEqual(row.realm.plan_type, row.expected_plan_type) if row.plan is not None: row.plan.refresh_from_db() self.assertEqual(row.plan.status, row.expected_plan_status) customer = get_customer_by_realm(row.realm) if customer is not None and customer.stripe_customer_id is not None: open_invoices = customer_has_last_n_invoices_open( customer, row.expected_invoice_count ) self.assertTrue(open_invoices) email_found = False for email in outbox: recipient = UserProfile.objects.get(email=email.to[0]) if recipient.realm == row.realm: self.assertIn( f"Your organization, http://{row.realm.string_id}.testserver, has been downgraded", outbox[0].body, ) self.assert_length(email.to, 1) email_found = True self.assertEqual(row.email_expected_to_be_sent, email_found) @mock_stripe() def test_upgrade_pay_by_invoice(self, *mock: Mock) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) response = self.client_get("/upgrade/?setup_payment_by_invoice=true") self.assert_in_success_response(["pay by card", "Send invoice"], response) # Send invoice response = self.client_billing_post( "/billing/upgrade", { "billing_modality": "send_invoice", "schedule": "annual", "signed_seat_count": self.get_signed_seat_count_from_response(response), "salt": self.get_salt_from_response(response), "license_management": "manual", "licenses": 40, }, ) self.assert_json_success(response) response = self.client_get("/upgrade/?setup_payment_by_invoice=true") self.assert_in_success_response(["An invoice", "has been sent"], response) @mock_stripe() def test_change_plan_tier_from_standard_to_plus(self, *mock: Mock) -> None: iago = self.example_user("iago") realm = iago.realm iago_billing_session = RealmBillingSession(iago) iago_billing_session.update_or_create_customer() # Test upgrading to Plus when realm has no active subscription with self.assertRaises(BillingError) as billing_context: iago_billing_session.do_change_plan_to_new_tier(CustomerPlan.TIER_CLOUD_PLUS) self.assertEqual( "Organization does not have an active plan", billing_context.exception.error_description, ) plan, ledger = self.subscribe_realm_to_manual_license_management_plan( realm, 9, 9, CustomerPlan.BILLING_SCHEDULE_MONTHLY ) # Test upgrading to Plus when realm has no stripe_customer_id with self.assertRaises(BillingError) as billing_context: iago_billing_session.do_change_plan_to_new_tier(CustomerPlan.TIER_CLOUD_PLUS) self.assertEqual( "Organization missing Stripe customer.", billing_context.exception.error_description ) king = self.lear_user("king") realm = king.realm king_billing_session = RealmBillingSession(king) customer = king_billing_session.update_or_create_stripe_customer() plan = CustomerPlan.objects.create( customer=customer, automanage_licenses=True, billing_cycle_anchor=timezone_now(), billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY, tier=CustomerPlan.TIER_CLOUD_STANDARD, ) ledger = LicenseLedger.objects.create( plan=plan, is_renewal=True, event_time=timezone_now(), licenses=9, licenses_at_next_renewal=9, ) realm.plan_type = Realm.PLAN_TYPE_STANDARD realm.save(update_fields=["plan_type"]) plan.invoiced_through = ledger plan.price_per_license = get_price_per_license( CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_MONTHLY ) plan.save(update_fields=["invoiced_through", "price_per_license"]) with self.assertRaises(BillingError) as billing_context: king_billing_session.do_change_plan_to_new_tier(CustomerPlan.TIER_CLOUD_STANDARD) self.assertEqual( "Invalid change of customer plan tier.", billing_context.exception.error_description ) king_billing_session.do_change_plan_to_new_tier(CustomerPlan.TIER_CLOUD_PLUS) plan.refresh_from_db() self.assertEqual(plan.status, CustomerPlan.ENDED) plus_plan = get_current_plan_by_realm(realm) assert plus_plan is not None self.assertEqual(plus_plan.tier, CustomerPlan.TIER_CLOUD_PLUS) self.assertEqual(LicenseLedger.objects.filter(plan=plus_plan).count(), 1) realm.refresh_from_db() self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_PLUS) # There are 9 licenses and the realm is on the Standard monthly plan. # Therefore, the customer has already paid 800 * 9 = 7200 = $72 for # the month. Once they upgrade to Plus, they will have to pay for 10 # licenses as that is the minimum licenses for that plan. # The new price for their 10 licenses will be 1200 * 10 = 12000 = $120. # Since the customer has already paid $72 for a month, -7200 = -$72 will # be credited to the customer's balance. stripe_customer_id = customer.stripe_customer_id assert stripe_customer_id is not None _, cb_txn = iter(stripe.Customer.list_balance_transactions(stripe_customer_id)) self.assertEqual(cb_txn.amount, -7200) self.assertEqual( cb_txn.description, "Credit from early termination of active plan", ) self.assertEqual(cb_txn.type, "adjustment") # The customer now only pays the difference 12000 - 7200 = 4800 = $48, # since the unused proration is for the whole month. (invoice,) = iter(stripe.Invoice.list(customer=stripe_customer_id)) self.assertEqual(invoice.amount_due, 4800) @mock_stripe() def test_customer_has_credit_card_as_default_payment_method(self, *mocks: Mock) -> None: iago = self.example_user("iago") customer = Customer.objects.create(realm=iago.realm) self.assertFalse(customer_has_credit_card_as_default_payment_method(customer)) billing_session = RealmBillingSession(iago) customer = billing_session.update_or_create_stripe_customer() self.assertFalse(customer_has_credit_card_as_default_payment_method(customer)) self.login_user(iago) self.add_card_and_upgrade(iago) self.assertTrue(customer_has_credit_card_as_default_payment_method(customer)) class StripeWebhookEndpointTest(ZulipTestCase): def test_stripe_webhook_with_invalid_data(self) -> None: result = self.client_post( "/stripe/webhook/", '["dsdsds"]', content_type="application/json", ) self.assertEqual(result.status_code, 400) def test_stripe_webhook_endpoint_invalid_api_version(self) -> None: event_data = { "id": "stripe_event_id", "api_version": "1991-02-20", "type": "event_type", "data": {"object": {"object": "checkout.session", "id": "stripe_session_id"}}, } expected_error_message = rf"Mismatch between billing system Stripe API version({STRIPE_API_VERSION}) and Stripe webhook event API version(1991-02-20)." with self.assertLogs("corporate.stripe", "ERROR") as error_log: self.client_post( "/stripe/webhook/", event_data, content_type="application/json", ) self.assertEqual(error_log.output, [f"ERROR:corporate.stripe:{expected_error_message}"]) def test_stripe_webhook_drops_unhandled_event_type(self) -> None: # The polling loop when generating test fixtures filters down to # HANDLED_STRIPE_EVENT_TYPES, so saved test fixtures never carry # unhandled types, but Stripe will still POST other types in # production. The webhook view must send a 200 response for # those events without touching the database. unhandled_event_data = { "id": "stripe_event_id", "api_version": STRIPE_API_VERSION, "type": "invoice.updated", "data": {"object": {"object": "invoice", "id": "stripe_invoice_id"}}, } result = self.client_post( "/stripe/webhook/", unhandled_event_data, content_type="application/json", ) self.assertEqual(result.status_code, 200) self.assert_length(Event.objects.all(), 0) def test_stripe_webhook_for_session_completed_event(self) -> None: # We don't process sessions for which we don't have a `Session` entry. valid_session_event_data = { "id": "stripe_event_id", "api_version": STRIPE_API_VERSION, "type": "checkout.session.completed", "data": {"object": {"object": "checkout.session", "id": "stripe_session_id"}}, } with patch( "corporate.lib.stripe_event_handler.handle_checkout_session_completed_event" ) as m: result = self.client_post( "/stripe/webhook/", valid_session_event_data, content_type="application/json", ) self.assert_length(Event.objects.all(), 0) self.assertEqual(result.status_code, 200) m.assert_not_called() def test_stripe_webhook_for_invoice_payment_events(self) -> None: customer = Customer.objects.create(realm=get_realm("zulip")) stripe_event_id = "stripe_event_id" stripe_invoice_id = "stripe_invoice_id" valid_session_event_data = { "id": stripe_event_id, "type": "invoice.paid", "api_version": STRIPE_API_VERSION, "data": {"object": {"object": "invoice", "id": stripe_invoice_id}}, } with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m: result = self.client_post( "/stripe/webhook/", valid_session_event_data, content_type="application/json", ) self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 0) self.assertEqual(result.status_code, 200) m.assert_not_called() Invoice.objects.create( stripe_invoice_id=stripe_invoice_id, customer=customer, status=Invoice.SENT, ) self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 0) with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m: result = self.client_post( "/stripe/webhook/", valid_session_event_data, content_type="application/json", ) [event] = Event.objects.filter(stripe_event_id=stripe_event_id) self.assertEqual(result.status_code, 200) strip_event = stripe.Event.construct_from(valid_session_event_data, stripe.api_key) m.assert_called_once_with(strip_event.data.object, event) with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m: result = self.client_post( "/stripe/webhook/", valid_session_event_data, content_type="application/json", ) self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 1) self.assertEqual(result.status_code, 200) m.assert_not_called() def test_stripe_webhook_for_invoice_paid_events(self) -> None: customer = Customer.objects.create(realm=get_realm("zulip")) stripe_event_id = "stripe_event_id" stripe_invoice_id = "stripe_invoice_id" valid_invoice_paid_event_data = { "id": stripe_event_id, "type": "invoice.paid", "api_version": STRIPE_API_VERSION, "data": {"object": {"object": "invoice", "id": stripe_invoice_id}}, } with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m: result = self.client_post( "/stripe/webhook/", valid_invoice_paid_event_data, content_type="application/json", ) self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 0) self.assertEqual(result.status_code, 200) m.assert_not_called() Invoice.objects.create( stripe_invoice_id=stripe_invoice_id, customer=customer, status=Invoice.SENT, ) self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 0) with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m: result = self.client_post( "/stripe/webhook/", valid_invoice_paid_event_data, content_type="application/json", ) [event] = Event.objects.filter(stripe_event_id=stripe_event_id) self.assertEqual(result.status_code, 200) strip_event = stripe.Event.construct_from(valid_invoice_paid_event_data, stripe.api_key) m.assert_called_once_with(strip_event.data.object, event) with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m: result = self.client_post( "/stripe/webhook/", valid_invoice_paid_event_data, content_type="application/json", ) self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 1) self.assertEqual(result.status_code, 200) m.assert_not_called() def test_stripe_event_handler_billing_error_logging(self) -> None: customer = Customer.objects.create(realm=get_realm("zulip")) stripe_invoice_id = "stripe_invoice_id" invoice = Invoice.objects.create( stripe_invoice_id=stripe_invoice_id, customer=customer, status=Invoice.SENT, ) content_type = ContentType.objects.get_for_model(Invoice) event = Event.objects.create( stripe_event_id="stripe_event_id", type="invoice.paid", content_type=content_type, object_id=invoice.id, ) stripe_invoice = stripe.Invoice.construct_from( { "id": stripe_invoice_id, "object": "invoice", "customer": "cus_test123", "metadata": {"plan_tier": "1", "billing_schedule": "1"}, }, stripe.api_key, ) from corporate.lib.stripe_event_handler import stripe_event_handler_decorator @stripe_event_handler_decorator def raise_billing_error(stripe_object: stripe.Invoice, invoice: Invoice) -> None: raise BillingError("test error", "test error description") with self.assertLogs("corporate.stripe", "WARNING") as warning_log: raise_billing_error(stripe_invoice, event) self.assert_length(warning_log.output, 1) self.assertIn( "BillingError in invoice.paid event handler: test error." f" stripe_object_id={stripe_invoice_id}," " customer_id=cus_test123 metadata=", warning_log.output[0], ) event.refresh_from_db() self.assertEqual(event.status, Event.EVENT_HANDLER_FAILED) self.assertEqual( event.handler_error, {"message": "test error description", "description": "test error"}, ) class EventStatusTest(StripeTestCase): def test_event_status_json_endpoint_errors(self) -> None: self.login_user(self.example_user("iago")) response = self.client_get("/json/billing/event/status") self.assert_json_error_contains(response, "No customer for this organization!") Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_123") response = self.client_get( "/json/billing/event/status", {"stripe_session_id": "invalid_session_id"} ) self.assert_json_error_contains(response, "Session not found") response = self.client_get( "/json/billing/event/status", {"stripe_invoice_id": "invalid_invoice_id"} ) self.assert_json_error_contains(response, "Payment intent not found") response = self.client_get( "/json/billing/event/status", ) self.assert_json_error_contains(response, "Pass stripe_session_id or stripe_invoice_id") def test_event_status_page(self) -> None: self.login_user(self.example_user("polonius")) stripe_session_id = "cs_test_9QCz62mPTJQUwvhcwZHBpJMHmMZiLU512AQHU9g5znkx6NweU3j7kJvY" response = self.client_get( "/billing/event_status/", {"stripe_session_id": stripe_session_id} ) self.assert_in_success_response([f'data-stripe-session-id="{stripe_session_id}"'], response) stripe_invoice_id = "pi_1JGLpnA4KHR4JzRvUfkF9Tn7" response = self.client_get( "/billing/event_status/", {"stripe_invoice_id": stripe_invoice_id} ) self.assert_in_success_response([f'data-stripe-invoice-id="{stripe_invoice_id}"'], response) class RequiresBillingAccessTest(StripeTestCase): @override def setUp(self, *mocks: Mock) -> None: super().setUp() desdemona = self.example_user("desdemona") desdemona.role = UserProfile.ROLE_REALM_OWNER desdemona.save(update_fields=["role"]) def test_json_endpoints_permissions(self) -> None: guest = self.example_user("polonius") member = self.example_user("othello") tested_endpoints = set() def check_users_cant_access( users: list[UserProfile], error_message: str, url: str, method: str, data: dict[str, Any], ) -> None: tested_endpoints.add(url) for user in users: self.login_user(user) if method == "POST": client_func: Any = self.client_post elif method == "GET": client_func = self.client_get else: client_func = self.client_patch result = client_func( url, data, content_type="application/json", ) self.assert_json_error_contains(result, error_message) check_users_cant_access( [guest, member], "Insufficient permission", "/json/billing/upgrade", "POST", {}, ) check_users_cant_access( [guest, member], "Insufficient permission", "/json/billing/sponsorship", "POST", {}, ) check_users_cant_access( [guest, member], "Insufficient permission", "/json/billing/plan", "PATCH", {}, ) check_users_cant_access( [guest, member], "Insufficient permission", "/json/billing/session/start_card_update_session", "POST", {}, ) check_users_cant_access( [guest, member], "Insufficient permission", "/json/upgrade/session/start_card_update_session", "POST", {}, ) check_users_cant_access( [guest, member], "Insufficient permission", "/json/billing/event/status", "GET", {}, ) # Make sure that we are testing all the JSON endpoints # Quite a hack, but probably fine for now reverse_dict = get_resolver("corporate.urls").reverse_dict json_endpoints = { pat for name in reverse_dict for matches, pat, defaults, converters in reverse_dict.getlist(name) if pat.startswith("json/") and not (pat.startswith(("json/realm/", "json/server/"))) } self.assert_length(json_endpoints, len(tested_endpoints)) @mock_stripe() def test_billing_page_permissions(self, *mocks: Mock) -> None: # Guest users can't access /upgrade/ page self.login_user(self.example_user("polonius")) response = self.client_get("/upgrade/", follow=True) self.assertEqual(response.status_code, 404) # Check user in `can_manage_billing_group` has access desdemona = self.example_user("desdemona") desdemona.role = UserProfile.ROLE_REALM_OWNER desdemona.save(update_fields=["role"]) self.login_user(self.example_user("desdemona")) self.add_card_and_upgrade(desdemona) response = self.client_get("/billing/") self.assert_in_success_response(["Zulip Cloud Standard"], response) # Check that member who is not in `can_manage_billing_group` does not have access self.login_user(self.example_user("cordelia")) response = self.client_get("/billing/") self.assert_in_success_response(["You do not have permission to view this page."], response) def test_start_card_update_stripe_session_requires_billing_access(self) -> None: # Verify that only users with billing access can update card. guest = self.example_user("polonius") member = self.example_user("othello") realm_owner = self.example_user("desdemona") realm_owner.role = UserProfile.ROLE_REALM_OWNER realm_owner.save(update_fields=["role"]) # Guest users should not have access self.login_user(guest) response = self.client_post( "/json/billing/session/start_card_update_session", {}, content_type="application/json", ) self.assert_json_error_contains(response, "Insufficient permission") # Regular members should not have access self.login_user(member) response = self.client_post( "/json/billing/session/start_card_update_session", {}, content_type="application/json", ) self.assert_json_error_contains(response, "Insufficient permission") # Realm owner should have access (they have billing access) self.login_user(realm_owner) with patch( "corporate.lib.stripe.RealmBillingSession.create_card_update_session", return_value={"stripe_session_id": "cs_test_session_id"}, ): response = self.client_post( "/json/billing/session/start_card_update_session", {}, content_type="application/json", ) self.assert_json_success(response)