Files
upstream-zulip/corporate/tests/test_billing_lib.py
upstream-mirror 64898b2c79
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
2026-09-15 15:05:30 +05:00

1976 lines
87 KiB
Python

import itertools
import typing
import uuid
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal
from typing import cast
from unittest import mock
from unittest.mock import Mock, patch
import stripe
import time_machine
from django.conf import settings
from django.utils.timezone import now as timezone_now
from corporate.lib.stripe import (
BillingError,
BillingSessionAuditLogEventError,
BillingSessionEventType,
BillingUserCounts,
InvalidBillingScheduleError,
InvalidTierError,
RealmBillingSession,
RemoteRealmBillingSession,
RemoteServerBillingSession,
SupportRequestError,
SupportType,
SupportViewRequest,
UpdatePlanRequest,
add_months,
compute_plan_parameters,
get_next_billing_cycle_for_plan,
get_plan_renewal_or_end_date,
get_price_per_license,
invoice_plans_as_needed,
is_realm_on_free_trial,
next_month,
)
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,
CustomerPlanOffer,
get_current_plan_by_customer,
get_current_plan_by_realm,
)
from zerver.actions.create_user import (
do_activate_mirror_dummy_user,
do_create_user,
do_reactivate_user,
)
from zerver.actions.users import change_user_is_active, do_deactivate_user
from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.utils import assert_is_not_none
from zerver.models import Message, Realm, RealmAuditLog, Recipient, UserProfile
from zerver.models.realm_audit_logs import AuditLogEventType
from zerver.models.realms import get_realm
from zerver.models.users import get_system_bot
from zilencer.models import (
RemoteRealm,
RemoteRealmAuditLog,
RemoteRealmBillingUser,
RemoteServerBillingUser,
RemoteZulipServer,
RemoteZulipServerAuditLog,
)
class BillingHelpersTest(ZulipTestCase):
def test_next_month(self) -> None:
anchor = datetime(2019, 12, 31, 1, 2, 3, tzinfo=timezone.utc)
period_boundaries = [
anchor,
datetime(2020, 1, 31, 1, 2, 3, tzinfo=timezone.utc),
# Test that this is the 28th even during leap years
datetime(2020, 2, 28, 1, 2, 3, tzinfo=timezone.utc),
datetime(2020, 3, 31, 1, 2, 3, tzinfo=timezone.utc),
datetime(2020, 4, 30, 1, 2, 3, tzinfo=timezone.utc),
datetime(2020, 5, 31, 1, 2, 3, tzinfo=timezone.utc),
datetime(2020, 6, 30, 1, 2, 3, tzinfo=timezone.utc),
datetime(2020, 7, 31, 1, 2, 3, tzinfo=timezone.utc),
datetime(2020, 8, 31, 1, 2, 3, tzinfo=timezone.utc),
datetime(2020, 9, 30, 1, 2, 3, tzinfo=timezone.utc),
datetime(2020, 10, 31, 1, 2, 3, tzinfo=timezone.utc),
datetime(2020, 11, 30, 1, 2, 3, tzinfo=timezone.utc),
datetime(2020, 12, 31, 1, 2, 3, tzinfo=timezone.utc),
datetime(2021, 1, 31, 1, 2, 3, tzinfo=timezone.utc),
datetime(2021, 2, 28, 1, 2, 3, tzinfo=timezone.utc),
]
with self.assertRaises(AssertionError):
add_months(anchor, -1)
# Explicitly test add_months for each value of MAX_DAY_FOR_MONTH and
# for crossing a year boundary
for i, boundary in enumerate(period_boundaries):
self.assertEqual(add_months(anchor, i), boundary)
# Test next_month for small values
for last, next_ in itertools.pairwise(period_boundaries):
self.assertEqual(next_month(anchor, last), next_)
# Test next_month for large values
period_boundaries = [dt.replace(year=dt.year + 100) for dt in period_boundaries]
for last, next_ in itertools.pairwise(period_boundaries):
self.assertEqual(next_month(anchor, last), next_)
def test_compute_plan_parameters(self) -> None:
anchor = datetime(2019, 12, 31, 1, 2, 3, tzinfo=timezone.utc)
month_later = datetime(2020, 1, 31, 1, 2, 3, tzinfo=timezone.utc)
year_later = datetime(2020, 12, 31, 1, 2, 3, tzinfo=timezone.utc)
customer_with_discount = Customer.objects.create(
realm=get_realm("lear"),
monthly_discounted_price=600,
annual_discounted_price=6000,
required_plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
)
customer_no_discount = Customer.objects.create(realm=get_realm("zulip"))
test_cases = [
# Annual standard no customer
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
None,
),
(anchor, month_later, year_later, 8000),
),
# Annual standard with discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
customer_with_discount,
),
(anchor, month_later, year_later, 6000),
),
# Annual standard customer but no discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
customer_no_discount,
),
(anchor, month_later, year_later, 8000),
),
# Annual plus customer with discount but different tier than required for discount
(
(
CustomerPlan.TIER_CLOUD_PLUS,
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
customer_with_discount,
),
(anchor, month_later, year_later, 12000),
),
# Monthly standard no customer
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
None,
),
(anchor, month_later, month_later, 800),
),
# Monthly standard with discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
customer_with_discount,
),
(anchor, month_later, month_later, 600),
),
# Monthly standard customer but no discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
customer_no_discount,
),
(anchor, month_later, month_later, 800),
),
# Monthly plus customer with discount but different tier than required for discount
(
(
CustomerPlan.TIER_CLOUD_PLUS,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
customer_with_discount,
),
(anchor, month_later, month_later, 1200),
),
]
# compute_plan_parameters truncates microseconds in the anchor datetime.
with time_machine.travel(anchor + timedelta(microseconds=654321), tick=False):
for (tier, billing_schedule, customer), output in test_cases:
output_ = compute_plan_parameters(
tier,
billing_schedule,
customer,
)
self.assertEqual(output_, output)
def test_get_price_per_license(self) -> None:
standard_discounted_customer = Customer.objects.create(
realm=get_realm("lear"),
monthly_discounted_price=400,
annual_discounted_price=4000,
required_plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
)
plus_discounted_customer = Customer.objects.create(
realm=get_realm("zulip"),
monthly_discounted_price=600,
annual_discounted_price=6000,
required_plan_tier=CustomerPlan.TIER_CLOUD_PLUS,
)
self.assertEqual(
get_price_per_license(
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_ANNUAL
),
8000,
)
self.assertEqual(
get_price_per_license(
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_MONTHLY
),
800,
)
self.assertEqual(
get_price_per_license(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
standard_discounted_customer,
),
400,
)
self.assertEqual(
get_price_per_license(
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_ANNUAL
),
12000,
)
self.assertEqual(
get_price_per_license(
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_MONTHLY
),
1200,
)
self.assertEqual(
get_price_per_license(
CustomerPlan.TIER_CLOUD_PLUS,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
# Wrong tier so discount not applied.
standard_discounted_customer,
),
1200,
)
self.assertEqual(
get_price_per_license(
CustomerPlan.TIER_CLOUD_PLUS,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
plus_discounted_customer,
),
600,
)
with self.assertRaisesRegex(InvalidBillingScheduleError, "Unknown billing_schedule: 1000"):
get_price_per_license(CustomerPlan.TIER_CLOUD_STANDARD, 1000)
with self.assertRaisesRegex(InvalidTierError, "Unknown tier: 4"):
get_price_per_license(
CustomerPlan.TIER_CLOUD_ENTERPRISE, CustomerPlan.BILLING_SCHEDULE_ANNUAL
)
def test_get_plan_renewal_or_end_date(self) -> None:
realm = get_realm("zulip")
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
billing_cycle_anchor = timezone_now()
plan = CustomerPlan.objects.create(
customer=customer,
status=CustomerPlan.ACTIVE,
billing_cycle_anchor=billing_cycle_anchor,
billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY,
tier=CustomerPlan.TIER_CLOUD_STANDARD,
)
renewal_date = get_plan_renewal_or_end_date(plan, billing_cycle_anchor)
self.assertEqual(renewal_date, add_months(billing_cycle_anchor, 1))
# When the plan ends 2 days before the start of the next billing cycle,
# the function should return the end_date.
plan_end_date = add_months(billing_cycle_anchor, 1) - timedelta(days=2)
plan.end_date = plan_end_date
plan.save(update_fields=["end_date"])
renewal_date = get_plan_renewal_or_end_date(plan, billing_cycle_anchor)
self.assertEqual(renewal_date, plan_end_date)
def test_update_or_create_stripe_customer_logic(self) -> None:
user = self.example_user("hamlet")
# No existing Customer object
with patch(
"corporate.lib.stripe.BillingSession.create_stripe_customer", return_value="returned"
) as mocked1:
billing_session = RealmBillingSession(user)
returned = billing_session.update_or_create_stripe_customer()
mocked1.assert_called_once()
self.assertEqual(returned, "returned")
customer = Customer.objects.create(realm=get_realm("zulip"))
# Customer exists but stripe_customer_id is None
with patch(
"corporate.lib.stripe.BillingSession.create_stripe_customer", return_value="returned"
) as mocked2:
billing_session = RealmBillingSession(user)
returned = billing_session.update_or_create_stripe_customer()
mocked2.assert_called_once()
self.assertEqual(returned, "returned")
customer.stripe_customer_id = "cus_12345"
customer.save()
# Customer exists, replace payment source
with patch("corporate.lib.stripe.BillingSession.replace_payment_method") as mocked3:
billing_session = RealmBillingSession(user)
returned_customer = billing_session.update_or_create_stripe_customer("pm_card_visa")
mocked3.assert_called_once()
self.assertEqual(returned_customer, customer)
# Customer exists, do nothing
with patch("corporate.lib.stripe.BillingSession.replace_payment_method") as mocked4:
billing_session = RealmBillingSession(user)
returned_customer = billing_session.update_or_create_stripe_customer(None)
mocked4.assert_not_called()
self.assertEqual(returned_customer, customer)
def test_get_customer_by_realm(self) -> None:
realm = get_realm("zulip")
self.assertEqual(get_customer_by_realm(realm), None)
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
self.assertEqual(get_customer_by_realm(realm), customer)
def test_get_current_plan_by_customer(self) -> None:
realm = get_realm("zulip")
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
self.assertEqual(get_current_plan_by_customer(customer), None)
plan = CustomerPlan.objects.create(
customer=customer,
status=CustomerPlan.ACTIVE,
billing_cycle_anchor=timezone_now(),
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
tier=CustomerPlan.TIER_CLOUD_STANDARD,
)
self.assertEqual(get_current_plan_by_customer(customer), plan)
plan.status = CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
plan.save(update_fields=["status"])
self.assertEqual(get_current_plan_by_customer(customer), plan)
plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"])
self.assertEqual(get_current_plan_by_customer(customer), None)
plan.status = CustomerPlan.NEVER_STARTED
plan.save(update_fields=["status"])
self.assertEqual(get_current_plan_by_customer(customer), None)
def test_get_current_plan_by_realm(self) -> None:
realm = get_realm("zulip")
self.assertEqual(get_current_plan_by_realm(realm), None)
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
self.assertEqual(get_current_plan_by_realm(realm), None)
plan = CustomerPlan.objects.create(
customer=customer,
status=CustomerPlan.ACTIVE,
billing_cycle_anchor=timezone_now(),
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
tier=CustomerPlan.TIER_CLOUD_STANDARD,
)
self.assertEqual(get_current_plan_by_realm(realm), plan)
def test_is_realm_on_free_trial(self) -> None:
realm = get_realm("zulip")
self.assertFalse(is_realm_on_free_trial(realm))
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
plan = CustomerPlan.objects.create(
customer=customer,
status=CustomerPlan.ACTIVE,
billing_cycle_anchor=timezone_now(),
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
tier=CustomerPlan.TIER_CLOUD_STANDARD,
)
self.assertFalse(is_realm_on_free_trial(realm))
plan.status = CustomerPlan.FREE_TRIAL
plan.save(update_fields=["status"])
self.assertTrue(is_realm_on_free_trial(realm))
def test_deactivate_reactivate_remote_server(self) -> None:
server_uuid = str(uuid.uuid4())
remote_server = RemoteZulipServer.objects.create(
uuid=server_uuid,
api_key="magic_secret_api_key",
hostname="demo.example.com",
contact_email="email@example.com",
)
self.assertFalse(remote_server.deactivated)
remote_server_billing_user = RemoteServerBillingUser.objects.create(
remote_server=remote_server, email="admin@example.com"
)
billing_session = RemoteServerBillingSession(remote_server, remote_server_billing_user)
billing_session.do_deactivate_remote_server()
remote_server = RemoteZulipServer.objects.get(uuid=server_uuid)
remote_realm_audit_log = RemoteZulipServerAuditLog.objects.filter(
event_type=AuditLogEventType.REMOTE_SERVER_DEACTIVATED
).last()
assert remote_realm_audit_log is not None
self.assertTrue(remote_server.deactivated)
self.assertEqual(remote_realm_audit_log.acting_remote_user, remote_server_billing_user)
# Try to deactivate a remote server that is already deactivated
with self.assertLogs("corporate.stripe", "WARN") as warning_log:
billing_session.do_deactivate_remote_server()
self.assertEqual(
warning_log.output,
[
(
"WARNING:corporate.stripe:Cannot deactivate remote server with ID "
f"{remote_server.id}, server has already been deactivated."
)
],
)
billing_session.do_reactivate_remote_server()
remote_server.refresh_from_db()
self.assertFalse(remote_server.deactivated)
remote_realm_audit_log = RemoteZulipServerAuditLog.objects.latest("id")
self.assertEqual(
remote_realm_audit_log.event_type, AuditLogEventType.REMOTE_SERVER_REACTIVATED
)
self.assertEqual(remote_realm_audit_log.server, remote_server)
with self.assertLogs("corporate.stripe", "WARN") as warning_log:
billing_session.do_reactivate_remote_server()
self.assertEqual(
warning_log.output,
[
(
"WARNING:corporate.stripe:Cannot reactivate remote server with ID "
f"{remote_server.id}, server is already active."
)
],
)
def test_initialize_fixed_price_plan_realm(self) -> None:
billing_cycle_anchor = datetime(2012, 1, 1, 1, 1, 1, tzinfo=timezone.utc)
realm = get_realm("zulip")
# Requires a Customer object for the billing entity.
billing_session = RealmBillingSession(None, realm)
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"no_customer",
billing_context.exception.error_description,
)
# Requires that the Customer object is linked to a
# customer ID in Stripe.
billing_session.update_or_create_customer()
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"no_stripe_id",
billing_context.exception.error_description,
)
# Requires a fixed price plan offer to be configured
# for the customer.
customer = billing_session.get_customer()
assert customer is not None
customer.stripe_customer_id = "cus_123"
customer.save(update_fields=["stripe_customer_id"])
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"no_plan_offer",
billing_context.exception.error_description,
)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
billing_session.configure_fixed_price_plan(1000, None)
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_SELF_HOSTED)
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
plan = CustomerPlan.objects.first()
assert plan is not None
self.assertEqual(plan.fixed_price, 100000)
self.assertEqual(plan.billing_cycle_anchor, billing_cycle_anchor)
license_ledger = LicenseLedger.objects.filter(plan=plan, is_renewal=True).last()
assert license_ledger is not None
self.assertEqual(license_ledger.event_time, billing_cycle_anchor)
# Confirm that if there is an active paid plan for the
# customer, a new plan can not be initialized.
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"on_paid_plan",
billing_context.exception.error_description,
)
def test_initialize_fixed_price_plan_remote_realm(self) -> None:
billing_cycle_anchor = datetime(2012, 1, 1, 1, 1, 1, tzinfo=timezone.utc)
server_uuid = str(uuid.uuid4())
remote_server = RemoteZulipServer.objects.create(
uuid=server_uuid,
api_key="magic_secret_api_key",
hostname="demo.example.com",
contact_email="email@example.com",
)
realm_uuid = str(uuid.uuid4())
remote_realm = RemoteRealm.objects.create(
server=remote_server,
uuid=realm_uuid,
uuid_owner_secret="dummy-owner-secret",
host="dummy-hostname",
realm_date_created=timezone_now(),
)
# Requires a Customer object for the billing entity.
billing_session = RemoteRealmBillingSession(remote_realm)
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_SELF_HOSTED_BASIC,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"no_customer",
billing_context.exception.error_description,
)
# Requires that the Customer object is linked to a
# customer ID in Stripe.
billing_session.update_or_create_customer()
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"no_stripe_id",
billing_context.exception.error_description,
)
# Requires a fixed price plan offer to be configured
# for the customer.
customer = billing_session.get_customer()
assert customer is not None
customer.stripe_customer_id = "cus_123"
customer.save(update_fields=["stripe_customer_id"])
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"no_plan_offer",
billing_context.exception.error_description,
)
billing_session.set_required_plan_tier(CustomerPlan.TIER_SELF_HOSTED_BASIC)
billing_session.configure_fixed_price_plan(1200, None)
self.assertEqual(remote_realm.plan_type, RemoteRealm.PLAN_TYPE_SELF_MANAGED)
with mock.patch(
"corporate.lib.stripe.RemoteRealmBillingSession.current_counts_for_billed_users",
return_value=BillingUserCounts(60, 0),
):
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_SELF_HOSTED_BASIC,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(remote_realm.plan_type, RemoteRealm.PLAN_TYPE_BASIC)
plan = CustomerPlan.objects.first()
assert plan is not None
self.assertEqual(plan.fixed_price, 120000)
self.assertEqual(plan.billing_cycle_anchor, billing_cycle_anchor)
license_ledger = LicenseLedger.objects.filter(plan=plan, is_renewal=True).last()
assert license_ledger is not None
self.assertEqual(license_ledger.event_time, billing_cycle_anchor)
# Confirm that if there is an active paid plan for the
# customer, a new plan can not be initialized.
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"on_paid_plan",
billing_context.exception.error_description,
)
def test_initialize_fixed_price_plan_remote_server(self) -> None:
billing_cycle_anchor = datetime(2012, 1, 1, 1, 1, 1, tzinfo=timezone.utc)
server_uuid = str(uuid.uuid4())
remote_server = RemoteZulipServer.objects.create(
uuid=server_uuid,
api_key="magic_secret_api_key",
hostname="demo.example.com",
contact_email="email@example.com",
)
# Requires a Customer object for the billing entity.
billing_session = RemoteServerBillingSession(remote_server)
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_SELF_HOSTED_BASIC,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"no_customer",
billing_context.exception.error_description,
)
# Requires that the Customer object is linked to a
# customer ID in Stripe.
billing_session.update_or_create_customer()
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"no_stripe_id",
billing_context.exception.error_description,
)
# Requires a fixed price plan offer to be configured
# for the customer.
customer = billing_session.get_customer()
assert customer is not None
customer.stripe_customer_id = "cus_123"
customer.save(update_fields=["stripe_customer_id"])
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"no_plan_offer",
billing_context.exception.error_description,
)
billing_session.set_required_plan_tier(CustomerPlan.TIER_SELF_HOSTED_BASIC)
billing_session.configure_fixed_price_plan(1200, None)
self.assertEqual(remote_server.plan_type, RemoteRealm.PLAN_TYPE_SELF_MANAGED)
with mock.patch(
"corporate.lib.stripe.RemoteServerBillingSession.current_counts_for_billed_users",
return_value=BillingUserCounts(60, 0),
):
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_SELF_HOSTED_BASIC,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(remote_server.plan_type, RemoteRealm.PLAN_TYPE_BASIC)
plan = CustomerPlan.objects.first()
assert plan is not None
self.assertEqual(plan.fixed_price, 120000)
self.assertEqual(plan.billing_cycle_anchor, billing_cycle_anchor)
license_ledger = LicenseLedger.objects.filter(plan=plan, is_renewal=True).last()
assert license_ledger is not None
self.assertEqual(license_ledger.event_time, billing_cycle_anchor)
# Confirm that if there is an active paid plan for the
# customer, a new plan can not be initialized.
with self.assertRaises(BillingError) as billing_context:
billing_session.initialize_prepaid_fixed_price_plan(
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
billing_cycle_anchor=billing_cycle_anchor,
)
self.assertEqual(
"on_paid_plan",
billing_context.exception.error_description,
)
class LicenseLedgerTest(StripeTestCase):
def test_add_plan_renewal_if_needed(self) -> None:
with time_machine.travel(self.now, tick=False):
self.local_upgrade(
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
)
self.assertEqual(LicenseLedger.objects.count(), 1)
plan = CustomerPlan.objects.get()
# Plan hasn't renewed yet
realm = plan.customer.realm
billing_session = RealmBillingSession(user=None, realm=realm)
billing_session.make_end_of_cycle_updates_if_needed(
plan, self.next_year - timedelta(days=1)
)
self.assertEqual(LicenseLedger.objects.count(), 1)
# Plan needs to renew
# TODO: do_deactivate_user for a user, so that licenses_at_next_renewal != licenses
new_plan, ledger_entry = billing_session.make_end_of_cycle_updates_if_needed(
plan, self.next_year
)
self.assertIsNone(new_plan)
self.assertEqual(LicenseLedger.objects.count(), 2)
assert ledger_entry is not None
self.assertEqual(ledger_entry.plan, plan)
self.assertTrue(ledger_entry.is_renewal)
self.assertEqual(ledger_entry.event_time, self.next_year)
self.assertEqual(ledger_entry.licenses, self.seat_count)
self.assertEqual(ledger_entry.licenses_at_next_renewal, self.seat_count)
# Plan needs to renew, but we already added the plan_renewal ledger entry
billing_session.make_end_of_cycle_updates_if_needed(
plan, self.next_year + timedelta(days=1)
)
self.assertEqual(LicenseLedger.objects.count(), 2)
def test_update_license_ledger_if_needed(self) -> None:
realm = get_realm("zulip")
billing_session = RealmBillingSession(user=None, realm=realm)
# Test no Customer
billing_session.update_license_ledger_if_needed(self.now)
self.assertFalse(LicenseLedger.objects.exists())
# Test plan not automanaged
with time_machine.travel(self.now, tick=False):
self.local_upgrade(
self.seat_count + 1, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
)
plan = CustomerPlan.objects.get()
self.assertEqual(LicenseLedger.objects.count(), 1)
self.assertEqual(plan.licenses(), self.seat_count + 1)
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count + 1)
billing_session.update_license_ledger_if_needed(self.now)
self.assertEqual(LicenseLedger.objects.count(), 1)
# Test no active plan
plan.automanage_licenses = True
plan.status = CustomerPlan.ENDED
plan.save(update_fields=["automanage_licenses", "status"])
billing_session.update_license_ledger_if_needed(self.now)
self.assertEqual(LicenseLedger.objects.count(), 1)
# Test update needed
plan.status = CustomerPlan.ACTIVE
plan.save(update_fields=["status"])
billing_session.update_license_ledger_if_needed(self.now)
self.assertEqual(LicenseLedger.objects.count(), 2)
def test_update_license_ledger_for_automanaged_plan(self) -> None:
realm = get_realm("zulip")
with time_machine.travel(self.now, tick=False):
self.local_upgrade(
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
)
plan = CustomerPlan.objects.first()
assert plan is not None
self.assertEqual(plan.licenses(), self.seat_count)
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
billing_session = RealmBillingSession(user=None, realm=realm)
# Simple increase
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=23):
billing_session.update_license_ledger_for_automanaged_plan(plan, self.now)
self.assertEqual(plan.licenses(), 23)
self.assertEqual(plan.licenses_at_next_renewal(), 23)
# Decrease
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20):
billing_session.update_license_ledger_for_automanaged_plan(plan, self.now)
self.assertEqual(plan.licenses(), 23)
self.assertEqual(plan.licenses_at_next_renewal(), 20)
# Increase, but not past high watermark
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=21):
billing_session.update_license_ledger_for_automanaged_plan(plan, self.now)
self.assertEqual(plan.licenses(), 23)
self.assertEqual(plan.licenses_at_next_renewal(), 21)
# Increase, but after renewal date, and below last year's high watermark
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=22):
billing_session.update_license_ledger_for_automanaged_plan(
plan, self.next_year + timedelta(seconds=1)
)
self.assertEqual(plan.licenses(), 22)
self.assertEqual(plan.licenses_at_next_renewal(), 22)
ledger_entries = list(
LicenseLedger.objects.values_list(
"is_renewal", "event_time", "licenses", "licenses_at_next_renewal"
).order_by("id")
)
self.assertEqual(
ledger_entries,
[
(True, self.now, self.seat_count, self.seat_count),
(False, self.now, 23, 23),
(False, self.now, 23, 20),
(False, self.now, 23, 21),
(True, self.next_year, 21, 21),
(False, self.next_year + timedelta(seconds=1), 22, 22),
],
)
def test_update_license_ledger_for_manual_plan(self) -> None:
realm = get_realm("zulip")
with time_machine.travel(self.now, tick=False):
self.local_upgrade(
self.seat_count + 1, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
)
billing_session = RealmBillingSession(user=None, realm=realm)
plan = get_current_plan_by_realm(realm)
assert plan is not None
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count):
billing_session.update_license_ledger_for_manual_plan(
plan, self.now, licenses=self.seat_count + 3
)
self.assertEqual(plan.licenses(), self.seat_count + 3)
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count + 3)
with (
patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count),
self.assertRaises(AssertionError),
):
billing_session.update_license_ledger_for_manual_plan(
plan, self.now, licenses=self.seat_count
)
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count):
billing_session.update_license_ledger_for_manual_plan(
plan, self.now, licenses_at_next_renewal=self.seat_count
)
self.assertEqual(plan.licenses(), self.seat_count + 3)
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
with (
patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count),
self.assertRaises(AssertionError),
):
billing_session.update_license_ledger_for_manual_plan(
plan, self.now, licenses_at_next_renewal=self.seat_count - 1
)
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count):
billing_session.update_license_ledger_for_manual_plan(
plan, self.now, licenses=self.seat_count + 10
)
self.assertEqual(plan.licenses(), self.seat_count + 10)
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count + 10)
billing_session.make_end_of_cycle_updates_if_needed(plan, self.next_year)
self.assertEqual(plan.licenses(), self.seat_count + 10)
ledger_entries = list(
LicenseLedger.objects.values_list(
"is_renewal", "event_time", "licenses", "licenses_at_next_renewal"
).order_by("id")
)
self.assertEqual(
ledger_entries,
[
(True, self.now, self.seat_count + 1, self.seat_count + 1),
(False, self.now, self.seat_count + 3, self.seat_count + 3),
(False, self.now, self.seat_count + 3, self.seat_count),
(False, self.now, self.seat_count + 10, self.seat_count + 10),
(True, self.next_year, self.seat_count + 10, self.seat_count + 10),
],
)
with self.assertRaises(AssertionError):
billing_session.update_license_ledger_for_manual_plan(plan, self.now)
def test_user_changes(self) -> None:
self.local_upgrade(self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False)
user = do_create_user("email", "password", get_realm("zulip"), "name", acting_user=None)
do_deactivate_user(user, acting_user=None)
do_reactivate_user(user, acting_user=None)
# Not a proper use of do_activate_mirror_dummy_user, but fine for this test
change_user_is_active(user, False)
user.is_mirror_dummy = True
user.save(update_fields=["is_mirror_dummy"])
do_activate_mirror_dummy_user(user, acting_user=None)
# Add a guest user
guest = do_create_user(
"guest_email",
"guest_password",
get_realm("zulip"),
"guest_name",
role=UserProfile.ROLE_GUEST,
acting_user=None,
)
# Change guest user role to member
self.set_user_role(guest, UserProfile.ROLE_MEMBER)
# Change again to moderator, no LicenseLedger created
self.set_user_role(guest, UserProfile.ROLE_MODERATOR)
ledger_entries = list(
LicenseLedger.objects.values_list(
"is_renewal", "licenses", "licenses_at_next_renewal"
).order_by("id")
)
self.assertEqual(
ledger_entries,
[
(True, self.seat_count, self.seat_count),
(False, self.seat_count + 1, self.seat_count + 1),
(False, self.seat_count + 1, self.seat_count),
(False, self.seat_count + 1, self.seat_count + 1),
(False, self.seat_count + 1, self.seat_count + 1),
(False, self.seat_count + 1, self.seat_count + 1),
(False, self.seat_count + 2, self.seat_count + 2),
],
)
def test_toggle_license_management(self) -> None:
self.local_upgrade(self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False)
plan = get_current_plan_by_realm(get_realm("zulip"))
assert plan is not None
self.assertEqual(plan.automanage_licenses, True)
self.assertEqual(plan.licenses(), self.seat_count)
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
billing_session = RealmBillingSession(user=None, realm=get_realm("zulip"))
update_plan_request = UpdatePlanRequest(
status=None,
licenses=None,
licenses_at_next_renewal=None,
schedule=None,
toggle_license_management=True,
)
billing_session.do_update_plan(update_plan_request)
plan.refresh_from_db()
self.assertEqual(plan.automanage_licenses, False)
billing_session.do_update_plan(update_plan_request)
plan.refresh_from_db()
self.assertEqual(plan.automanage_licenses, True)
class InvoiceTest(StripeTestCase):
def test_invoicing_status_is_started(self) -> None:
# local_upgrade uses hamlet as user, therefore realm is zulip.
self.local_upgrade(self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False)
plan = CustomerPlan.objects.first()
assert plan is not None
plan.invoicing_status = CustomerPlan.INVOICING_STATUS_STARTED
plan.save(update_fields=["invoicing_status"])
with self.assertRaises(NotImplementedError):
billing_session = RealmBillingSession(realm=get_realm("zulip"))
billing_session.invoice_plan(assert_is_not_none(CustomerPlan.objects.first()), self.now)
def test_invoice_plan_without_stripe_customer(self) -> None:
# local_upgrade uses hamlet as user, therefore realm is zulip.
realm = get_realm("zulip")
self.local_upgrade(
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, False, False
)
plan = get_current_plan_by_realm(realm)
assert plan is not None
plan.customer.stripe_customer_id = None
plan.customer.save(update_fields=["stripe_customer_id"])
with self.assertRaises(BillingError) as context:
billing_session = RealmBillingSession(realm=realm)
billing_session.invoice_plan(plan, timezone_now())
self.assertRegex(
context.exception.error_description,
"Customer has a paid plan without a Stripe customer ID:",
)
@mock_stripe()
def test_validate_licenses_for_manual_plan_management(self, *mocks: Mock) -> None:
user = self.example_user("hamlet")
self.login_user(user)
# Upgrade with one extra license
with (
time_machine.travel(self.now, tick=False),
patch("corporate.lib.stripe.MIN_INVOICED_LICENSES", 3),
):
self.upgrade(invoice=True, licenses=self.seat_count + 1)
# Set renewal licenses to current seat count
with (
time_machine.travel(self.now, tick=False),
patch("corporate.lib.stripe.MIN_INVOICED_LICENSES", 3),
):
result = self.client_billing_patch(
"/billing/plan",
{"licenses_at_next_renewal": self.seat_count},
)
self.assert_json_success(result)
# Add an extra user
do_create_user(
"email-extra-user",
"password-extra-user",
get_realm("zulip"),
"name-extra-user",
acting_user=None,
)
with self.assertLogs("corporate.stripe", level="ERROR") as m:
invoice_plans_as_needed(self.next_year)
self.assertIn(
"ERROR:corporate.stripe:Invoicing failed: Customer.id:",
m.output[0],
)
self.assertIn(
"Customer has not manually updated plan for current license count:",
m.output[0],
)
@mock_stripe()
def test_invoice_plan(self, *mocks: Mock) -> None:
user = self.example_user("hamlet")
self.login_user(user)
with time_machine.travel(self.now, tick=False):
self.add_card_and_upgrade(user)
realm = get_realm("zulip")
billing_session = RealmBillingSession(user=user, realm=realm)
# Increase
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count + 3):
billing_session.update_license_ledger_if_needed(self.now + timedelta(days=100))
# Decrease
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count):
billing_session.update_license_ledger_if_needed(self.now + timedelta(days=200))
# Increase, but not past high watermark
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count + 1):
billing_session.update_license_ledger_if_needed(self.now + timedelta(days=300))
# Increase, but after renewal date, and below last year's high watermark
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count + 2):
billing_session.update_license_ledger_if_needed(self.now + timedelta(days=400))
# Increase, but after event_time
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count + 3):
billing_session.update_license_ledger_if_needed(self.now + timedelta(days=500))
plan = CustomerPlan.objects.first()
assert plan is not None
billing_session.invoice_plan(plan, self.now + timedelta(days=400))
stripe_customer_id = plan.customer.stripe_customer_id
assert stripe_customer_id is not None
[invoice0, _invoice1] = iter(stripe.Invoice.list(customer=stripe_customer_id))
self.assertIsNotNone(invoice0.status_transitions.finalized_at)
[item0, item1, item2] = iter(invoice0.lines)
self.assertEqual(item0.amount, int(8000 * (1 - ((400 - 366) / 365)) + 0.5))
self.assertEqual(item0.description, "Additional Zulip Cloud Standard license")
self.assertFalse(item0.discountable)
self.assertEqual(item0.period.start, datetime_to_timestamp(self.now + timedelta(days=400)))
self.assertEqual(
item0.period.end, datetime_to_timestamp(self.now + timedelta(days=2 * 365 + 1))
)
self.assertEqual(item0.quantity, 1)
self.assertEqual(item1.amount, 3 * int(8000 * (366 - 100) / 366 + 0.5))
self.assertEqual(item1.description, "Additional Zulip Cloud Standard license")
self.assertFalse(item1.discountable)
self.assertEqual(item1.period.start, datetime_to_timestamp(self.now + timedelta(days=100)))
self.assertEqual(item1.period.end, datetime_to_timestamp(self.now + timedelta(days=366)))
self.assertEqual(item1.quantity, 3)
self.assertEqual(item2.amount, 8000 * (self.seat_count + 1))
self.assertEqual(item2.description, "Zulip Cloud Standard - renewal")
self.assertFalse(item2.discountable)
self.assertEqual(item2.period.start, datetime_to_timestamp(self.now + timedelta(days=366)))
self.assertEqual(
item2.period.end, datetime_to_timestamp(self.now + timedelta(days=2 * 365 + 1))
)
self.assertEqual(item2.quantity, self.seat_count + 1)
@mock_stripe()
def test_invoice_plan_bundle_additional_licenses(self, *mocks: Mock) -> None:
user = self.example_user("hamlet")
self.login_user(user)
with time_machine.travel(self.now, tick=False):
self.add_card_and_upgrade(user)
realm = get_realm("zulip")
billing_session = RealmBillingSession(user=user, realm=realm)
# Increase by 300 additional licenses in the first month of the
# annual plan.
updated_seat_count = self.seat_count
for i in range(50):
updated_seat_count += 1
with patch(
"corporate.lib.stripe.get_latest_seat_count", return_value=updated_seat_count
):
billing_session.update_license_ledger_if_needed(
self.now + timedelta(days=10, seconds=i)
)
for i in range(100):
updated_seat_count += 1
with patch(
"corporate.lib.stripe.get_latest_seat_count", return_value=updated_seat_count
):
billing_session.update_license_ledger_if_needed(
self.now + timedelta(days=15, seconds=i)
)
for i in range(100):
updated_seat_count += 1
with patch(
"corporate.lib.stripe.get_latest_seat_count", return_value=updated_seat_count
):
billing_session.update_license_ledger_if_needed(
self.now + timedelta(days=20, seconds=i)
)
# Remove 20 active licenses to confirm we're still handling these ledger entries
# correctly when bundling.
for i in range(20):
updated_seat_count -= 1
with patch(
"corporate.lib.stripe.get_latest_seat_count", return_value=updated_seat_count
):
billing_session.update_license_ledger_if_needed(
self.now + timedelta(days=22, seconds=i)
)
for i in range(70):
updated_seat_count += 1
with patch(
"corporate.lib.stripe.get_latest_seat_count", return_value=updated_seat_count
):
billing_session.update_license_ledger_if_needed(
self.now + timedelta(days=25, seconds=i)
)
plan = CustomerPlan.objects.first()
assert plan is not None
billing_session.invoice_plan(plan, self.next_month)
stripe_customer_id = plan.customer.stripe_customer_id
assert stripe_customer_id is not None
[invoice0, _invoice1] = iter(stripe.Invoice.list(customer=stripe_customer_id))
self.assertIsNotNone(invoice0.status_transitions.finalized_at)
[item0, item1, item2, item3] = iter(invoice0.lines.data)
self.assertEqual(item0.amount, 50 * int(8000 * (366 - 25) / 366 + 0.5))
self.assertEqual(item0.description, "Additional Zulip Cloud Standard license")
self.assertFalse(item0.discountable)
# We have to adjust the start datetime for the 20 license ledger
# entries (by 20 seconds) that were for licenses that were already
# paid for in the billing period.
self.assertEqual(
item0.period.start, datetime_to_timestamp(self.now + timedelta(days=25, seconds=20))
)
self.assertEqual(item0.period.end, datetime_to_timestamp(self.next_year))
self.assertEqual(item0.quantity, 50)
self.assertEqual(item1.amount, 100 * int(8000 * (366 - 20) / 366 + 0.5))
self.assertEqual(item1.description, "Additional Zulip Cloud Standard license")
self.assertFalse(item1.discountable)
self.assertEqual(item1.period.start, datetime_to_timestamp(self.now + timedelta(days=20)))
self.assertEqual(item1.period.end, datetime_to_timestamp(self.next_year))
self.assertEqual(item1.quantity, 100)
self.assertEqual(item2.amount, 100 * int(8000 * (366 - 15) / 366 + 0.5))
self.assertEqual(item2.description, "Additional Zulip Cloud Standard license")
self.assertFalse(item2.discountable)
self.assertEqual(item2.period.start, datetime_to_timestamp(self.now + timedelta(days=15)))
self.assertEqual(item2.period.end, datetime_to_timestamp(self.next_year))
self.assertEqual(item2.quantity, 100)
self.assertEqual(item3.amount, 50 * int(8000 * (366 - 10) / 366 + 0.5))
self.assertEqual(item3.description, "Additional Zulip Cloud Standard license")
self.assertFalse(item3.discountable)
self.assertEqual(item3.period.start, datetime_to_timestamp(self.now + timedelta(days=10)))
self.assertEqual(item3.period.end, datetime_to_timestamp(self.next_year))
self.assertEqual(item3.quantity, 50)
@mock_stripe()
def test_fixed_price_plans(self, *mocks: Mock) -> None:
# Also tests charge_automatically=False
user = self.example_user("hamlet")
self.login_user(user)
with time_machine.travel(self.now, tick=False):
self.upgrade(invoice=True)
plan = CustomerPlan.objects.first()
assert plan is not None
plan.fixed_price = 100
plan.price_per_license = 0
plan.save(update_fields=["fixed_price", "price_per_license"])
user.realm.refresh_from_db()
billing_session = RealmBillingSession(realm=user.realm)
billing_session.invoice_plan(plan, self.next_year)
stripe_customer_id = plan.customer.stripe_customer_id
assert stripe_customer_id is not None
[invoice0, _invoice1] = iter(stripe.Invoice.list(customer=stripe_customer_id))
self.assertEqual(invoice0.collection_method, "send_invoice")
[item] = iter(invoice0.lines)
self.assertEqual(item.amount, 100)
self.assertEqual(item.description, "Zulip Cloud Standard - renewal")
self.assertFalse(item.discountable)
self.assertEqual(item.period.start, datetime_to_timestamp(self.next_year))
self.assertEqual(
item.period.end, datetime_to_timestamp(self.next_year + timedelta(days=365))
)
self.assertEqual(item.quantity, 1)
@mock_stripe()
def test_upgrade_to_fixed_price_plus_plan(self, *mocks: Mock) -> None:
iago = self.example_user("iago")
hamlet = self.example_user("hamlet")
realm = get_realm("zulip")
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_SELF_HOSTED)
self.login_user(hamlet)
with time_machine.travel(self.now, tick=False):
self.upgrade(invoice=True)
plan = CustomerPlan.objects.first()
assert plan is not None
self.assertIsNone(plan.end_date)
self.assertEqual(plan.tier, CustomerPlan.TIER_CLOUD_STANDARD)
realm.refresh_from_db()
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
next_billing_cycle = get_next_billing_cycle_for_plan(plan)
plan_end_date_string = next_billing_cycle.date().isoformat()
plan_end_date = datetime.combine(
date.fromisoformat(plan_end_date_string), time(0, 0, 0), tzinfo=timezone.utc
)
self.logout()
self.login_user(iago)
result = self.client_post(
"/activity/support",
{
"realm_id": f"{realm.id}",
"required_plan_tier": f"{CustomerPlanOffer.TIER_CLOUD_PLUS}",
},
)
self.assert_in_success_response(
["Required plan tier for zulip set to Zulip Cloud Plus."],
result,
)
with time_machine.travel(self.now, tick=False):
result = self.client_post(
"/activity/support",
{
"realm_id": f"{realm.id}",
"plan_end_date": plan_end_date_string,
},
)
self.assert_in_success_response(
[f"Current plan for zulip updated to end on {plan_end_date_string}."],
result,
)
plan.refresh_from_db()
self.assertEqual(plan.end_date, plan_end_date)
result = self.client_post(
"/activity/support",
{
"realm_id": f"{realm.id}",
"fixed_price": 360,
},
)
self.assert_in_success_response(
[f"Fixed price Zulip Cloud Plus plan scheduled to start on {plan_end_date_string}."],
result,
)
plan.refresh_from_db()
self.assertEqual(plan.status, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END)
self.assertEqual(plan.next_invoice_date, plan_end_date)
new_plan = CustomerPlan.objects.filter(fixed_price__isnull=False).first()
assert new_plan is not None
self.assertEqual(new_plan.next_invoice_date, plan_end_date)
self.assertEqual(
new_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT
)
with time_machine.travel(next_billing_cycle, tick=False):
invoice_plans_as_needed()
plan.refresh_from_db()
self.assertEqual(plan.status, CustomerPlan.ENDED)
self.assertEqual(plan.next_invoice_date, None)
new_plan.refresh_from_db()
self.assertEqual(new_plan.tier, CustomerPlan.TIER_CLOUD_PLUS)
self.assertIsNotNone(new_plan.fixed_price)
self.assertIsNone(new_plan.price_per_license)
realm.refresh_from_db()
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_PLUS)
# Visit /billing
self.logout()
self.login_user(hamlet)
with time_machine.travel(plan_end_date + timedelta(days=1), tick=False):
response = self.client_get(f"{self.billing_session.billing_base_url}/billing/")
for substring in [
"Zulip Cloud Plus",
"Annual",
"Invoice",
"This is a fixed-price plan",
"You will be contacted by Zulip Sales",
]:
self.assert_in_response(substring, response)
self.assert_not_in_success_response(["Update card"], response)
def test_no_invoice_needed(self) -> None:
# local_upgrade uses hamlet as user, therefore realm is zulip.
with time_machine.travel(self.now, tick=False):
self.local_upgrade(
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
)
plan = CustomerPlan.objects.first()
assert plan is not None
self.assertEqual(plan.next_invoice_date, self.next_month)
# Test this doesn't make any calls to stripe.Invoice or stripe.InvoiceItem
assert plan.customer.realm is not None
billing_session = RealmBillingSession(realm=plan.customer.realm)
billing_session.invoice_plan(plan, self.next_month)
plan = CustomerPlan.objects.first()
# Test that we still update next_invoice_date
assert plan is not None
self.assertEqual(plan.next_invoice_date, self.next_month + timedelta(days=29))
def test_invoice_plans_as_needed(self) -> None:
with time_machine.travel(self.now, tick=False):
self.local_upgrade(
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
)
plan = CustomerPlan.objects.first()
assert plan is not None
self.assertEqual(plan.next_invoice_date, self.next_month)
# Test nothing needed to be done
with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked:
invoice_plans_as_needed(self.next_month - timedelta(days=1))
mocked.assert_not_called()
# Test something needing to be done
invoice_plans_as_needed(self.next_month)
plan = CustomerPlan.objects.first()
assert plan is not None
self.assertEqual(plan.next_invoice_date, self.next_month + timedelta(days=29))
@mock_stripe()
def test_invoice_for_additional_license(self, *mocks: Mock) -> None:
user = self.example_user("hamlet")
self.login_user(user)
with time_machine.travel(self.now, tick=False):
self.add_card_and_upgrade(user)
plan = CustomerPlan.objects.first()
assert plan is not None
self.assertEqual(plan.next_invoice_date, self.next_month)
assert plan.customer.realm is not None
realm = plan.customer.realm
# Adding a guest user and then changing their role to member
# should invoice for a pro-rated license at the next invoice
# date on a plan with annual billing.
with time_machine.travel(self.now + timedelta(days=5), tick=False):
user = do_create_user(
"email",
"password",
realm,
"name",
role=UserProfile.ROLE_GUEST,
acting_user=None,
)
with time_machine.travel(self.now + timedelta(days=10), tick=False):
self.set_user_role(user, UserProfile.ROLE_MEMBER)
billing_session = RealmBillingSession(realm=realm)
billing_session.invoice_plan(plan, self.next_month)
plan = CustomerPlan.objects.first()
assert plan is not None
self.assertEqual(plan.next_invoice_date, self.next_month + timedelta(days=29))
stripe_customer_id = plan.customer.stripe_customer_id
assert stripe_customer_id is not None
[invoice0, _invoice1] = iter(stripe.Invoice.list(customer=stripe_customer_id))
self.assertIsNotNone(invoice0.status_transitions.finalized_at)
[item0] = iter(invoice0.lines)
self.assertEqual(item0.amount, int(8000 * (1 - ((366 - 356) / 366)) + 0.5))
self.assertEqual(item0.description, "Additional Zulip Cloud Standard license")
self.assertEqual(item0.quantity, 1)
class TestTestClasses(ZulipTestCase):
def test_subscribe_realm_to_manual_license_management_plan(self) -> None:
realm = get_realm("zulip")
plan, ledger = self.subscribe_realm_to_manual_license_management_plan(
realm, 50, 60, CustomerPlan.BILLING_SCHEDULE_ANNUAL
)
plan.refresh_from_db()
self.assertEqual(plan.automanage_licenses, False)
self.assertEqual(plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_ANNUAL)
self.assertEqual(plan.tier, CustomerPlan.TIER_CLOUD_STANDARD)
self.assertEqual(plan.licenses(), 50)
self.assertEqual(plan.licenses_at_next_renewal(), 60)
ledger.refresh_from_db()
self.assertEqual(ledger.plan, plan)
self.assertEqual(ledger.licenses, 50)
self.assertEqual(ledger.licenses_at_next_renewal, 60)
realm.refresh_from_db()
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
def test_subscribe_realm_to_monthly_plan_on_manual_license_management(self) -> None:
realm = get_realm("zulip")
plan, ledger = self.subscribe_realm_to_monthly_plan_on_manual_license_management(
realm, 20, 30
)
plan.refresh_from_db()
self.assertEqual(plan.automanage_licenses, False)
self.assertEqual(plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_MONTHLY)
self.assertEqual(plan.tier, CustomerPlan.TIER_CLOUD_STANDARD)
self.assertEqual(plan.licenses(), 20)
self.assertEqual(plan.licenses_at_next_renewal(), 30)
ledger.refresh_from_db()
self.assertEqual(ledger.plan, plan)
self.assertEqual(ledger.licenses, 20)
self.assertEqual(ledger.licenses_at_next_renewal, 30)
realm.refresh_from_db()
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
class TestRealmBillingSession(StripeTestCase):
def test_get_audit_log_error(self) -> None:
user = self.example_user("hamlet")
billing_session = RealmBillingSession(user)
fake_audit_log = typing.cast(BillingSessionEventType, 0)
with self.assertRaisesRegex(
BillingSessionAuditLogEventError, "Unknown audit log event type: 0"
):
billing_session.get_audit_log_event(event_type=fake_audit_log)
def test_get_customer(self) -> None:
user = self.example_user("hamlet")
billing_session = RealmBillingSession(user)
customer = billing_session.get_customer()
self.assertEqual(customer, None)
customer = Customer.objects.create(realm=user.realm, stripe_customer_id="cus_12345")
self.assertEqual(billing_session.get_customer(), customer)
class TestSupportBillingHelpers(StripeTestCase):
@mock_stripe()
def test_attach_discount_to_realm(self, *mocks: Mock) -> None:
# Attach discount before Stripe customer exists
support_admin = self.example_user("iago")
user = self.example_user("hamlet")
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
# Cannot attach discount without a required_plan_tier set.
with self.assertRaises(AssertionError):
billing_session.attach_discount_to_customer(
monthly_discounted_price=120,
annual_discounted_price=1200,
)
billing_session.update_or_create_customer()
with self.assertRaises(AssertionError):
billing_session.attach_discount_to_customer(
monthly_discounted_price=120,
annual_discounted_price=1200,
)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
billing_session.attach_discount_to_customer(
monthly_discounted_price=120,
annual_discounted_price=1200,
)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=AuditLogEventType.REALM_DISCOUNT_CHANGED
).last()
assert realm_audit_log is not None
expected_extra_data = {
"new_annual_discounted_price": 1200,
"new_monthly_discounted_price": 120,
"old_annual_discounted_price": 0,
"old_monthly_discounted_price": 0,
}
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
self.login_user(user)
# Check that the discount appears in page_params
self.assert_in_success_response(["85"], self.client_get("/upgrade/"))
# Check that the customer was charged the discounted amount
self.add_card_and_upgrade(user)
customer = Customer.objects.first()
assert customer is not None
assert customer.stripe_customer_id is not None
[charge] = iter(stripe.Charge.list(customer=customer.stripe_customer_id))
self.assertEqual(1200 * self.seat_count, charge.amount)
stripe_customer_id = customer.stripe_customer_id
assert stripe_customer_id is not None
[invoice] = iter(stripe.Invoice.list(customer=stripe_customer_id))
self.assertEqual(
[1200 * self.seat_count],
[item.amount for item in invoice.lines],
)
# Check CustomerPlan reflects the discount
plan = CustomerPlan.objects.get(price_per_license=1200, discount="85")
# Attach discount to existing Stripe customer
plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"])
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
billing_session.attach_discount_to_customer(
monthly_discounted_price=600,
annual_discounted_price=6000,
)
with time_machine.travel(self.now, tick=False):
self.add_card_and_upgrade(
user, license_management="automatic", billing_modality="charge_automatically"
)
[charge, _] = iter(stripe.Charge.list(customer=customer.stripe_customer_id))
self.assertEqual(6000 * self.seat_count, charge.amount)
stripe_customer_id = customer.stripe_customer_id
assert stripe_customer_id is not None
[invoice, _] = iter(stripe.Invoice.list(customer=stripe_customer_id))
self.assertEqual(
[6000 * self.seat_count],
[item.amount for item in invoice.lines],
)
plan = CustomerPlan.objects.get(price_per_license=6000, discount=Decimal(25))
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.attach_discount_to_customer(
monthly_discounted_price=400,
annual_discounted_price=4000,
)
plan.refresh_from_db()
self.assertEqual(plan.price_per_license, 4000)
self.assertEqual(plan.discount, "50")
customer.refresh_from_db()
self.assertEqual(customer.monthly_discounted_price, 400)
self.assertEqual(customer.annual_discounted_price, 4000)
# 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 + timedelta(days=10))
stripe_customer_id = customer.stripe_customer_id
assert stripe_customer_id is not None
[invoice, _, _] = iter(stripe.Invoice.list(customer=stripe_customer_id))
self.assertEqual([4000 * self.seat_count], [item.amount for item in invoice.lines])
realm_audit_log = RealmAuditLog.objects.filter(
event_type=AuditLogEventType.REALM_DISCOUNT_CHANGED
).last()
assert realm_audit_log is not None
expected_extra_data = {
"new_annual_discounted_price": 4000,
"new_monthly_discounted_price": 400,
"old_annual_discounted_price": 6000,
"old_monthly_discounted_price": 600,
}
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
self.assertEqual(realm_audit_log.acting_user, support_admin)
# Confirm that once a plan has been purchased and is active,
# approving a full sponsorship (our version of 100% discount) fails.
with self.assertRaisesRegex(
SupportRequestError,
"Customer on plan Zulip Cloud Standard. Please end current plan before approving sponsorship!",
):
billing_session.approve_sponsorship()
@mock_stripe()
def test_add_minimum_licenses(self, *mocks: Mock) -> None:
min_licenses = 25
support_view_request = SupportViewRequest(
support_type=SupportType.update_minimum_licenses, minimum_licenses=min_licenses
)
support_admin = self.example_user("iago")
user = self.example_user("hamlet")
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.update_or_create_customer()
with self.assertRaisesRegex(
SupportRequestError,
"Discount for zulip must be updated before setting a minimum number of licenses.",
):
billing_session.process_support_view_request(support_view_request)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
billing_session.attach_discount_to_customer(
monthly_discounted_price=400,
annual_discounted_price=4000,
)
message = billing_session.process_support_view_request(support_view_request)
self.assertEqual("Minimum licenses for zulip changed to 25 from 0.", message)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=AuditLogEventType.CUSTOMER_PROPERTY_CHANGED
).last()
assert realm_audit_log is not None
expected_extra_data = {"old_value": None, "new_value": 25, "property": "minimum_licenses"}
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
self.login_user(user)
self.add_card_and_upgrade(user)
customer = billing_session.get_customer()
assert customer is not None
assert customer.stripe_customer_id is not None
[charge] = iter(stripe.Charge.list(customer=customer.stripe_customer_id))
self.assertEqual(4000 * min_licenses, charge.amount)
min_licenses = 50
support_view_request = SupportViewRequest(
support_type=SupportType.update_minimum_licenses, minimum_licenses=min_licenses
)
with self.assertRaisesRegex(
SupportRequestError,
"Cannot set minimum licenses; active plan already exists for zulip.",
):
billing_session.process_support_view_request(support_view_request)
def test_set_required_plan_tier(self) -> None:
valid_plan_tier = CustomerPlan.TIER_CLOUD_STANDARD
support_view_request = SupportViewRequest(
support_type=SupportType.update_required_plan_tier,
required_plan_tier=valid_plan_tier,
)
support_admin = self.example_user("iago")
user = self.example_user("hamlet")
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
customer = billing_session.get_customer()
assert customer is None
# Set valid plan tier - creates Customer object
message = billing_session.process_support_view_request(support_view_request)
self.assertEqual("Required plan tier for zulip set to Zulip Cloud Standard.", message)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=AuditLogEventType.CUSTOMER_PROPERTY_CHANGED
).last()
assert realm_audit_log is not None
expected_extra_data = {
"old_value": None,
"new_value": valid_plan_tier,
"property": "required_plan_tier",
}
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
customer = billing_session.get_customer()
assert customer is not None
self.assertEqual(customer.required_plan_tier, valid_plan_tier)
self.assertEqual(customer.monthly_discounted_price, 0)
self.assertEqual(customer.annual_discounted_price, 0)
# Check that discount is only applied to set plan tier
billing_session.attach_discount_to_customer(
monthly_discounted_price=400,
annual_discounted_price=4000,
)
customer.refresh_from_db()
self.assertEqual(customer.monthly_discounted_price, 400)
self.assertEqual(customer.annual_discounted_price, 4000)
monthly_discounted_price = customer.get_discounted_price_for_plan(
valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
)
self.assertEqual(monthly_discounted_price, customer.monthly_discounted_price)
annual_discounted_price = customer.get_discounted_price_for_plan(
valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL
)
self.assertEqual(annual_discounted_price, customer.annual_discounted_price)
monthly_discounted_price = customer.get_discounted_price_for_plan(
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_MONTHLY
)
self.assertEqual(monthly_discounted_price, None)
annual_discounted_price = customer.get_discounted_price_for_plan(
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_ANNUAL
)
self.assertEqual(annual_discounted_price, None)
# Try to set invalid plan tier
invalid_plan_tier = CustomerPlan.TIER_SELF_HOSTED_BASE
support_view_request = SupportViewRequest(
support_type=SupportType.update_required_plan_tier,
required_plan_tier=invalid_plan_tier,
)
with self.assertRaisesRegex(SupportRequestError, "Invalid plan tier for zulip."):
billing_session.process_support_view_request(support_view_request)
# Cannot set required plan tier to None before setting discount to 0.
support_view_request = SupportViewRequest(
support_type=SupportType.update_required_plan_tier, required_plan_tier=0
)
with self.assertRaisesRegex(
SupportRequestError,
"Discount for zulip must be 0 before setting required plan tier to None.",
):
billing_session.process_support_view_request(support_view_request)
billing_session.attach_discount_to_customer(
monthly_discounted_price=0,
annual_discounted_price=0,
)
message = billing_session.process_support_view_request(support_view_request)
self.assertEqual("Required plan tier for zulip set to None.", message)
customer.refresh_from_db()
self.assertIsNone(customer.required_plan_tier)
discount_for_standard_plan = customer.get_discounted_price_for_plan(
valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
)
self.assertEqual(discount_for_standard_plan, None)
discount_for_plus_plan = customer.get_discounted_price_for_plan(
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_MONTHLY
)
self.assertEqual(discount_for_plus_plan, None)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=AuditLogEventType.CUSTOMER_PROPERTY_CHANGED
).last()
assert realm_audit_log is not None
expected_extra_data = {
"old_value": valid_plan_tier,
"new_value": None,
"property": "required_plan_tier",
}
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
def test_approve_realm_sponsorship(self) -> None:
realm = get_realm("zulip")
self.assertNotEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE)
support_admin = self.example_user("iago")
billing_session = RealmBillingSession(user=support_admin, realm=realm, support_session=True)
billing_session.approve_sponsorship()
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE)
expected_message = (
"Your organization's request for sponsored hosting has been approved! You have been upgraded to Zulip Cloud Standard, free of charge. :tada:"
"\n\nIf you could [list Zulip as a sponsor on your website](/help/linking-to-zulip-website), we would really appreciate it!"
)
sender = get_system_bot(settings.NOTIFICATION_BOT, realm.id)
# Organization owners get the notification bot message
bot_and_desdemona_recipient = self.get_dm_group_recipient(
sender, self.example_user("desdemona")
)
message_to_owner = Message.objects.filter(
realm_id=realm.id, sender=sender.id, recipient=bot_and_desdemona_recipient
).first()
assert message_to_owner is not None
self.assertEqual(message_to_owner.content, expected_message)
self.assertEqual(message_to_owner.recipient.type, Recipient.DIRECT_MESSAGE_GROUP)
# Hamlet is in `can_manage_billing_group` so should get the notification bot message
bot_and_hamlet_recipient = self.get_dm_group_recipient(sender, self.example_user("hamlet"))
message_to_hamlet = Message.objects.filter(
realm_id=realm.id, sender=sender.id, recipient=bot_and_hamlet_recipient
).last()
assert message_to_hamlet is not None
self.assertEqual(message_to_hamlet.content, expected_message)
self.assertEqual(message_to_hamlet.recipient.type, Recipient.DIRECT_MESSAGE_GROUP)
def test_update_realm_sponsorship_status(self) -> None:
lear = get_realm("lear")
iago = self.example_user("iago")
billing_session = RealmBillingSession(user=iago, realm=lear, support_session=True)
billing_session.update_customer_sponsorship_status(True)
customer = get_customer_by_realm(realm=lear)
assert customer is not None
self.assertTrue(customer.sponsorship_pending)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=AuditLogEventType.REALM_SPONSORSHIP_PENDING_STATUS_CHANGED
).last()
assert realm_audit_log is not None
expected_extra_data = {"sponsorship_pending": True}
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
self.assertEqual(realm_audit_log.acting_user, iago)
def test_update_realm_billing_modality(self) -> None:
realm = get_realm("zulip")
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
plan = CustomerPlan.objects.create(
customer=customer,
status=CustomerPlan.ACTIVE,
billing_cycle_anchor=timezone_now(),
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
tier=CustomerPlan.TIER_CLOUD_STANDARD,
)
self.assertEqual(plan.charge_automatically, False)
support_admin = self.example_user("iago")
billing_session = RealmBillingSession(user=support_admin, realm=realm, support_session=True)
billing_session.update_billing_modality_of_current_plan(True)
plan.refresh_from_db()
self.assertEqual(plan.charge_automatically, True)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=AuditLogEventType.REALM_BILLING_MODALITY_CHANGED
).last()
assert realm_audit_log is not None
expected_extra_data = {"charge_automatically": plan.charge_automatically}
self.assertEqual(realm_audit_log.acting_user, support_admin)
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
billing_session.update_billing_modality_of_current_plan(False)
plan.refresh_from_db()
self.assertEqual(plan.charge_automatically, False)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=AuditLogEventType.REALM_BILLING_MODALITY_CHANGED
).last()
assert realm_audit_log is not None
expected_extra_data = {"charge_automatically": plan.charge_automatically}
self.assertEqual(realm_audit_log.acting_user, support_admin)
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
@mock_stripe()
def test_switch_realm_from_standard_to_plus_plan(self, *mocks: Mock) -> None:
user = self.example_user("hamlet")
self.login_user(user)
self.add_card_and_upgrade(user)
customer = get_customer_by_realm(user.realm)
assert customer is not None
original_plan = get_current_plan_by_customer(customer)
assert original_plan is not None
self.assertEqual(original_plan.tier, CustomerPlan.TIER_CLOUD_STANDARD)
support_admin = self.example_user("iago")
billing_session = RealmBillingSession(
user=support_admin, realm=user.realm, support_session=True
)
support_request = SupportViewRequest(
support_type=SupportType.modify_plan,
plan_modification="upgrade_plan_tier",
new_plan_tier=CustomerPlan.TIER_CLOUD_PLUS,
)
# Freeze time so the prorated credit (computed inside
# ``change_plan_tier`` from ``timezone_now()``) doesn't drift
# between fixture regenerations.
with time_machine.travel(self.now, tick=False):
success_message = billing_session.process_support_view_request(support_request)
self.assertEqual(success_message, "zulip upgraded to Zulip Cloud Plus")
customer.refresh_from_db()
new_plan = get_current_plan_by_customer(customer)
assert new_plan is not None
self.assertEqual(new_plan.tier, CustomerPlan.TIER_CLOUD_PLUS)
@mock_stripe()
def test_downgrade_realm_and_void_open_invoices(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)
customer = get_customer_by_realm(user.realm)
assert customer is not None
original_plan = get_current_plan_by_customer(customer)
assert original_plan is not None
self.assertEqual(original_plan.status, CustomerPlan.ACTIVE)
support_admin = self.example_user("iago")
billing_session = RealmBillingSession(
user=support_admin, realm=user.realm, support_session=True
)
# Send renewal invoice.
invoice_plans_as_needed(self.now + timedelta(days=367))
support_request = SupportViewRequest(
support_type=SupportType.modify_plan,
plan_modification="downgrade_now_void_open_invoices",
)
success_message = billing_session.process_support_view_request(support_request)
self.assertEqual(success_message, "zulip downgraded and voided 1 open invoices")
original_plan.refresh_from_db()
self.assertEqual(original_plan.status, CustomerPlan.ENDED)
class TestRemoteBillingWriteAuditLog(StripeTestCase):
def test_write_audit_log(self) -> None:
support_admin = self.example_user("iago")
server_uuid = str(uuid.uuid4())
remote_server = RemoteZulipServer.objects.create(
uuid=server_uuid,
api_key="magic_secret_api_key",
hostname="demo.example.com",
contact_email="email@example.com",
)
realm_uuid = str(uuid.uuid4())
remote_realm = RemoteRealm.objects.create(
server=remote_server,
uuid=realm_uuid,
uuid_owner_secret="dummy-owner-secret",
host="dummy-hostname",
realm_date_created=timezone_now(),
)
remote_realm_billing_user = RemoteRealmBillingUser.objects.create(
remote_realm=remote_realm, email="admin@example.com", user_uuid=uuid.uuid4()
)
remote_server_billing_user = RemoteServerBillingUser.objects.create(
remote_server=remote_server, email="admin@example.com"
)
event_time = timezone_now()
def assert_audit_log(
audit_log: RemoteRealmAuditLog | RemoteZulipServerAuditLog,
acting_remote_user: RemoteRealmBillingUser | RemoteServerBillingUser | None,
acting_support_user: UserProfile | None,
event_type: int,
event_time: datetime,
) -> None:
self.assertEqual(audit_log.event_type, event_type)
self.assertEqual(audit_log.event_time, event_time)
self.assertEqual(audit_log.acting_remote_user, acting_remote_user)
self.assertEqual(audit_log.acting_support_user, acting_support_user)
for session_class, audit_log_class, remote_object, remote_user in [
(
RemoteRealmBillingSession,
RemoteRealmAuditLog,
remote_realm,
remote_realm_billing_user,
),
(
RemoteServerBillingSession,
RemoteZulipServerAuditLog,
remote_server,
remote_server_billing_user,
),
]:
# Necessary cast or mypy doesn't understand that we can use Django's
# model .objects. style queries on this.
audit_log_model = cast(
type[RemoteRealmAuditLog] | type[RemoteZulipServerAuditLog], audit_log_class
)
assert isinstance(remote_user, RemoteRealmBillingUser | RemoteServerBillingUser)
# No acting user:
session = session_class(remote_object)
session.write_to_audit_log(
# This "ordinary billing" event type value gets translated by write_to_audit_log
# into a AuditLogEventType.CUSTOMER_PLAN_CREATED value.
event_type=BillingSessionEventType.CUSTOMER_PLAN_CREATED,
event_time=event_time,
)
audit_log = audit_log_model.objects.latest("id")
assert_audit_log(
audit_log, None, None, AuditLogEventType.CUSTOMER_PLAN_CREATED, event_time
)
session = session_class(remote_object, remote_billing_user=remote_user)
session.write_to_audit_log(
event_type=BillingSessionEventType.CUSTOMER_PLAN_CREATED,
event_time=event_time,
)
audit_log = audit_log_model.objects.latest("id")
assert_audit_log(
audit_log, remote_user, None, AuditLogEventType.CUSTOMER_PLAN_CREATED, event_time
)
session = session_class(
remote_object, remote_billing_user=None, support_staff=support_admin
)
session.write_to_audit_log(
event_type=BillingSessionEventType.CUSTOMER_PLAN_CREATED,
event_time=event_time,
)
audit_log = audit_log_model.objects.latest("id")
assert_audit_log(
audit_log, None, support_admin, AuditLogEventType.CUSTOMER_PLAN_CREATED, event_time
)