billing: Add support for Zulip Standard free trial.

This commit is contained in:
Vishnu KS 2020-04-23 23:40:15 +05:30 committed by Tim Abbott
parent 66a437bbf1
commit f1b1bf5a0d
45 changed files with 474 additions and 47 deletions

View File

@ -85,6 +85,10 @@ def next_month(billing_cycle_anchor: datetime, dt: datetime) -> datetime:
'billing_cycle_anchor: %s, dt: %s' % (billing_cycle_anchor, dt))
def start_of_next_billing_cycle(plan: CustomerPlan, event_time: datetime) -> datetime:
if plan.status == CustomerPlan.FREE_TRIAL:
assert(plan.next_invoice_date is not None) # for mypy
return plan.next_invoice_date
months_per_period = {
CustomerPlan.ANNUAL: 12,
CustomerPlan.MONTHLY: 1,
@ -237,6 +241,16 @@ def make_end_of_cycle_updates_if_needed(plan: CustomerPlan,
plan=plan, is_renewal=True, event_time=next_billing_cycle,
licenses=last_ledger_entry.licenses_at_next_renewal,
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal)
if plan.status == CustomerPlan.FREE_TRIAL:
plan.invoiced_through = last_ledger_entry
assert(plan.next_invoice_date is not None)
plan.billing_cycle_anchor = plan.next_invoice_date.replace(microsecond=0)
plan.status = CustomerPlan.ACTIVE
plan.save(update_fields=["invoiced_through", "billing_cycle_anchor", "status"])
return LicenseLedger.objects.create(
plan=plan, is_renewal=True, event_time=next_billing_cycle,
licenses=last_ledger_entry.licenses_at_next_renewal,
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal)
if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
process_downgrade(plan)
return None
@ -255,7 +269,8 @@ def update_or_create_stripe_customer(user: UserProfile, stripe_token: Optional[s
def compute_plan_parameters(
automanage_licenses: bool, billing_schedule: int,
discount: Optional[Decimal]) -> Tuple[datetime, datetime, datetime, int]:
discount: Optional[Decimal],
free_trial: Optional[bool]=False) -> Tuple[datetime, datetime, datetime, int]:
# Everything in Stripe is stored as timestamps with 1 second resolution,
# so standardize on 1 second resolution.
# TODO talk about leapseconds?
@ -275,6 +290,9 @@ def compute_plan_parameters(
next_invoice_date = period_end
if automanage_licenses:
next_invoice_date = add_months(billing_cycle_anchor, 1)
if free_trial:
period_end = add_months(billing_cycle_anchor, settings.FREE_TRIAL_MONTHS)
next_invoice_date = period_end
return billing_cycle_anchor, next_invoice_date, period_end, price_per_license
# Only used for cloud signups
@ -283,6 +301,9 @@ def process_initial_upgrade(user: UserProfile, licenses: int, automanage_license
billing_schedule: int, stripe_token: Optional[str]) -> None:
realm = user.realm
customer = update_or_create_stripe_customer(user, stripe_token=stripe_token)
charge_automatically = stripe_token is not None
free_trial = settings.FREE_TRIAL_MONTHS not in (None, 0)
if get_current_plan_by_customer(customer) is not None:
# Unlikely race condition from two people upgrading (clicking "Make payment")
# at exactly the same time. Doesn't fully resolve the race condition, but having
@ -293,29 +314,30 @@ def process_initial_upgrade(user: UserProfile, licenses: int, automanage_license
raise BillingError('subscribing with existing subscription', BillingError.TRY_RELOADING)
billing_cycle_anchor, next_invoice_date, period_end, price_per_license = compute_plan_parameters(
automanage_licenses, billing_schedule, customer.default_discount)
automanage_licenses, billing_schedule, customer.default_discount, free_trial)
# The main design constraint in this function is that if you upgrade with a credit card, and the
# charge fails, everything should be rolled back as if nothing had happened. This is because we
# expect frequent card failures on initial signup.
# Hence, if we're going to charge a card, do it at the beginning, even if we later may have to
# adjust the number of licenses.
charge_automatically = stripe_token is not None
if charge_automatically:
stripe_charge = stripe.Charge.create(
amount=price_per_license * licenses,
currency='usd',
customer=customer.stripe_customer_id,
description="Upgrade to Zulip Standard, ${} x {}".format(price_per_license/100, licenses),
receipt_email=user.delivery_email,
statement_descriptor='Zulip Standard')
# Not setting a period start and end, but maybe we should? Unclear what will make things
# most similar to the renewal case from an accounting perspective.
stripe.InvoiceItem.create(
amount=price_per_license * licenses * -1,
currency='usd',
customer=customer.stripe_customer_id,
description="Payment (Card ending in {})".format(cast(stripe.Card, stripe_charge.source).last4),
discountable=False)
if not free_trial:
stripe_charge = stripe.Charge.create(
amount=price_per_license * licenses,
currency='usd',
customer=customer.stripe_customer_id,
description="Upgrade to Zulip Standard, ${} x {}".format(price_per_license/100, licenses),
receipt_email=user.delivery_email,
statement_descriptor='Zulip Standard')
# Not setting a period start and end, but maybe we should? Unclear what will make things
# most similar to the renewal case from an accounting perspective.
description = "Payment (Card ending in {})".format(cast(stripe.Card, stripe_charge.source).last4)
stripe.InvoiceItem.create(
amount=price_per_license * licenses * -1,
currency='usd',
customer=customer.stripe_customer_id,
description=description,
discountable=False)
# TODO: The correctness of this relies on user creation, deactivation, etc being
# in a transaction.atomic() with the relevant RealmAuditLog entries
@ -331,6 +353,8 @@ def process_initial_upgrade(user: UserProfile, licenses: int, automanage_license
'billing_cycle_anchor': billing_cycle_anchor,
'billing_schedule': billing_schedule,
'tier': CustomerPlan.STANDARD}
if free_trial:
plan_params['status'] = CustomerPlan.FREE_TRIAL
plan = CustomerPlan.objects.create(
customer=customer,
next_invoice_date=next_invoice_date,
@ -347,29 +371,32 @@ def process_initial_upgrade(user: UserProfile, licenses: int, automanage_license
realm=realm, acting_user=user, event_time=billing_cycle_anchor,
event_type=RealmAuditLog.CUSTOMER_PLAN_CREATED,
extra_data=ujson.dumps(plan_params))
stripe.InvoiceItem.create(
currency='usd',
customer=customer.stripe_customer_id,
description='Zulip Standard',
discountable=False,
period = {'start': datetime_to_timestamp(billing_cycle_anchor),
'end': datetime_to_timestamp(period_end)},
quantity=billed_licenses,
unit_amount=price_per_license)
if charge_automatically:
billing_method = 'charge_automatically'
days_until_due = None
else:
billing_method = 'send_invoice'
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
stripe_invoice = stripe.Invoice.create(
auto_advance=True,
billing=billing_method,
customer=customer.stripe_customer_id,
days_until_due=days_until_due,
statement_descriptor='Zulip Standard')
stripe.Invoice.finalize_invoice(stripe_invoice)
if not free_trial:
stripe.InvoiceItem.create(
currency='usd',
customer=customer.stripe_customer_id,
description='Zulip Standard',
discountable=False,
period = {'start': datetime_to_timestamp(billing_cycle_anchor),
'end': datetime_to_timestamp(period_end)},
quantity=billed_licenses,
unit_amount=price_per_license)
if charge_automatically:
billing_method = 'charge_automatically'
days_until_due = None
else:
billing_method = 'send_invoice'
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
stripe_invoice = stripe.Invoice.create(
auto_advance=True,
billing=billing_method,
customer=customer.stripe_customer_id,
days_until_due=days_until_due,
statement_descriptor='Zulip Standard')
stripe.Invoice.finalize_invoice(stripe_invoice)
from zerver.lib.actions import do_change_plan_type
do_change_plan_type(realm, Realm.STANDARD)
@ -505,7 +532,8 @@ def estimate_annual_recurring_revenue_by_realm() -> Dict[str, int]: # nocoverag
return annual_revenue
# During realm deactivation we instantly downgrade the plan to Limited.
# Extra users added in the final month are not charged.
# Extra users added in the final month are not charged. Also used
# for the cancelation of Free Trial.
def downgrade_now(realm: Realm) -> None:
plan = get_current_plan_by_realm(realm)
if plan is None:

View File

@ -52,6 +52,7 @@ class CustomerPlan(models.Model):
ACTIVE = 1
DOWNGRADE_AT_END_OF_CYCLE = 2
FREE_TRIAL = 3
# "Live" plans should have a value < LIVE_STATUS_THRESHOLD.
# There should be at most one live plan per customer.
LIVE_STATUS_THRESHOLD = 10

View File

@ -529,6 +529,274 @@ class StripeTest(StripeTestCase):
'Billed by invoice']:
self.assert_in_response(substring, response)
@mock_stripe(tested_timestamp_fields=["created"])
def test_free_trial_upgrade_by_card(self, *mocks: Mock) -> None:
user = self.example_user("hamlet")
self.login_user(user)
with self.settings(FREE_TRIAL_MONTHS=2):
response = self.client_get("/upgrade/")
self.assert_in_success_response(['Pay annually', 'Free Trial', '2 month'], response)
self.assertNotEqual(user.realm.plan_type, Realm.STANDARD)
self.assertFalse(Customer.objects.filter(realm=user.realm).exists())
with patch('corporate.lib.stripe.timezone_now', return_value=self.now):
self.upgrade()
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
self.assertEqual(stripe_customer.default_source.id[:5], 'card_')
self.assertEqual(stripe_customer.description, "zulip (Zulip Dev)")
self.assertEqual(stripe_customer.discount, None)
self.assertEqual(stripe_customer.email, user.email)
metadata_dict = dict(stripe_customer.metadata)
self.assertEqual(metadata_dict['realm_str'], 'zulip')
try:
int(metadata_dict['realm_id'])
except ValueError: # nocoverage
raise AssertionError("realm_id is not a number")
stripe_charges = [charge for charge in stripe.Charge.list(customer=stripe_customer.id)]
self.assertEqual(len(stripe_charges), 0)
stripe_invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)]
self.assertEqual(len(stripe_invoices), 0)
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.ANNUAL, invoiced_through=LicenseLedger.objects.first(),
next_invoice_date=add_months(self.now, 2), tier=CustomerPlan.STANDARD,
status=CustomerPlan.FREE_TRIAL)
LicenseLedger.objects.get(
plan=plan, is_renewal=True, event_time=self.now, licenses=self.seat_count,
licenses_at_next_renewal=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, [
(RealmAuditLog.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created)),
(RealmAuditLog.STRIPE_CARD_CHANGED, timestamp_to_datetime(stripe_customer.created)),
(RealmAuditLog.CUSTOMER_PLAN_CREATED, self.now),
# TODO: Check for REALM_PLAN_TYPE_CHANGED
# (RealmAuditLog.REALM_PLAN_TYPE_CHANGED, Kandra()),
])
self.assertEqual(ujson.loads(RealmAuditLog.objects.filter(
event_type=RealmAuditLog.CUSTOMER_PLAN_CREATED).values_list(
'extra_data', flat=True).first())['automanage_licenses'], True)
realm = get_realm("zulip")
self.assertEqual(realm.plan_type, Realm.STANDARD)
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
with patch('corporate.views.timezone_now', return_value=self.now):
response = self.client_get("/billing/")
self.assert_not_in_success_response(['Pay annually'], response)
for substring in [
'Zulip Standard', 'Free Trial', str(self.seat_count),
'You are using', '%s of %s licenses' % (self.seat_count, self.seat_count),
'Your plan will be upgraded to', 'March 2, 2012', '$%s.00' % (80 * self.seat_count,),
'Visa ending in 4242',
'Update card']:
self.assert_in_response(substring, response)
with patch('corporate.lib.stripe.get_latest_seat_count', return_value=12):
update_license_ledger_if_needed(realm, self.now)
self.assertEqual(
LicenseLedger.objects.order_by('-id').values_list('licenses', 'licenses_at_next_renewal').first(),
(12, 12)
)
with patch('corporate.lib.stripe.get_latest_seat_count', return_value=15):
update_license_ledger_if_needed(realm, self.next_month)
self.assertEqual(
LicenseLedger.objects.order_by('-id').values_list('licenses', 'licenses_at_next_renewal').first(),
(15, 15)
)
invoice_plans_as_needed(self.next_month)
invoices = stripe.Invoice.list(customer=stripe_customer.id)
self.assertEqual(len(invoices), 0)
customer_plan = CustomerPlan.objects.get(customer=customer)
self.assertEqual(customer_plan.status, CustomerPlan.FREE_TRIAL)
self.assertEqual(customer_plan.next_invoice_date, add_months(self.now, 2))
invoice_plans_as_needed(add_months(self.now, 2))
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(self.now, 3))
self.assertEqual(realm.plan_type, Realm.STANDARD)
invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)]
self.assertEqual(len(invoices), 1)
invoice_params = {
"amount_due": 15 * 80 * 100, "amount_paid": 0, "amount_remaining": 15 * 80 * 100,
"auto_advance": True, "billing": "charge_automatically", "collection_method": "charge_automatically",
"customer_email": self.example_email("hamlet"), "discount": None, "paid": False, "status": "open",
"total": 15 * 80 * 100
}
for key, value in invoice_params.items():
self.assertEqual(invoices[0].get(key), value)
invoice_items = [invoice_item for invoice_item in invoices[0].get("lines")]
self.assertEqual(len(invoice_items), 1)
invoice_item_params = {
"amount": 15 * 80 * 100, "description": "Zulip Standard - renewal",
"plan": None, "quantity": 15, "subscription": None, "discountable": False,
"period": {
"start": datetime_to_timestamp(add_months(self.now, 2)),
"end": datetime_to_timestamp(add_months(self.now, 14))
},
}
for key, value in invoice_item_params.items():
self.assertEqual(invoice_items[0][key], value)
invoice_plans_as_needed(add_months(self.now, 3))
invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)]
self.assertEqual(len(invoices), 1)
with patch('corporate.lib.stripe.get_latest_seat_count', return_value=19):
update_license_ledger_if_needed(realm, add_months(self.now, 12))
self.assertEqual(
LicenseLedger.objects.order_by('-id').values_list('licenses', 'licenses_at_next_renewal').first(),
(19, 19)
)
invoice_plans_as_needed(add_months(self.now, 12))
invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)]
self.assertEqual(len(invoices), 2)
invoice_params = {
"amount_due": 5172, "auto_advance": True, "billing": "charge_automatically",
"collection_method": "charge_automatically", "customer_email": "hamlet@zulip.com"
}
invoice_items = [invoice_item for invoice_item in invoices[0].get("lines")]
self.assertEqual(len(invoice_items), 1)
invoice_item_params = {
"amount": 5172, "description": "Additional license (Jan 2, 2013 - Mar 2, 2013)",
"discountable": False, "quantity": 4,
"period": {
"start": datetime_to_timestamp(add_months(self.now, 12)),
"end": datetime_to_timestamp(add_months(self.now, 14))
}
}
invoice_plans_as_needed(add_months(self.now, 14))
invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)]
self.assertEqual(len(invoices), 3)
@mock_stripe(tested_timestamp_fields=["created"])
def test_free_trial_upgrade_by_invoice(self, *mocks: Mock) -> None:
user = self.example_user("hamlet")
self.login_user(user)
with self.settings(FREE_TRIAL_MONTHS=2):
response = self.client_get("/upgrade/")
self.assert_in_success_response(['Pay annually', 'Free Trial', '2 month'], response)
self.assertNotEqual(user.realm.plan_type, Realm.STANDARD)
self.assertFalse(Customer.objects.filter(realm=user.realm).exists())
with patch('corporate.lib.stripe.timezone_now', return_value=self.now):
self.upgrade(invoice=True)
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
self.assertEqual(stripe_customer.discount, None)
self.assertEqual(stripe_customer.email, user.email)
metadata_dict = dict(stripe_customer.metadata)
self.assertEqual(metadata_dict['realm_str'], 'zulip')
try:
int(metadata_dict['realm_id'])
except ValueError: # nocoverage
raise AssertionError("realm_id is not a number")
stripe_invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)]
self.assertEqual(len(stripe_invoices), 0)
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.ANNUAL, invoiced_through=LicenseLedger.objects.first(),
next_invoice_date=add_months(self.now, 2), tier=CustomerPlan.STANDARD,
status=CustomerPlan.FREE_TRIAL)
LicenseLedger.objects.get(
plan=plan, is_renewal=True, event_time=self.now, licenses=123,
licenses_at_next_renewal=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, [
(RealmAuditLog.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created)),
(RealmAuditLog.CUSTOMER_PLAN_CREATED, self.now),
# TODO: Check for REALM_PLAN_TYPE_CHANGED
# (RealmAuditLog.REALM_PLAN_TYPE_CHANGED, Kandra()),
])
self.assertEqual(ujson.loads(RealmAuditLog.objects.filter(
event_type=RealmAuditLog.CUSTOMER_PLAN_CREATED).values_list(
'extra_data', flat=True).first())['automanage_licenses'], False)
realm = get_realm("zulip")
self.assertEqual(realm.plan_type, Realm.STANDARD)
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
with patch('corporate.views.timezone_now', return_value=self.now):
response = self.client_get("/billing/")
self.assert_not_in_success_response(['Pay annually'], response)
for substring in [
'Zulip Standard', 'Free Trial', str(self.seat_count),
'You are using', '%s of %s licenses' % (self.seat_count, 123),
'Your plan will be upgraded to', 'March 2, 2012',
'{:,.2f}'.format(80 * 123), 'Billed by invoice'
]:
self.assert_in_response(substring, response)
with patch('corporate.lib.stripe.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, add_months(self.now, 2))
invoice_plans_as_needed(add_months(self.now, 2))
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(self.now, 14))
self.assertEqual(realm.plan_type, Realm.STANDARD)
invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)]
self.assertEqual(len(invoices), 1)
invoice_params = {
"amount_due": 123 * 80 * 100, "amount_paid": 0, "amount_remaining": 123 * 80 * 100,
"auto_advance": True, "billing": "send_invoice", "collection_method": "send_invoice",
"customer_email": self.example_email("hamlet"), "discount": None, "paid": False, "status": "open",
"total": 123 * 80 * 100
}
for key, value in invoice_params.items():
self.assertEqual(invoices[0].get(key), value)
invoice_items = [invoice_item for invoice_item in invoices[0].get("lines")]
self.assertEqual(len(invoice_items), 1)
invoice_item_params = {
"amount": 123 * 80 * 100, "description": "Zulip Standard - renewal",
"plan": None, "quantity": 123, "subscription": None, "discountable": False,
"period": {
"start": datetime_to_timestamp(add_months(self.now, 2)),
"end": datetime_to_timestamp(add_months(self.now, 14))
},
}
for key, value in invoice_item_params.items():
self.assertEqual(invoice_items[0][key], value)
invoice_plans_as_needed(add_months(self.now, 3))
invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)]
self.assertEqual(len(invoices), 1)
invoice_plans_as_needed(add_months(self.now, 12))
invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)]
self.assertEqual(len(invoices), 1)
invoice_plans_as_needed(add_months(self.now, 14))
invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)]
self.assertEqual(len(invoices), 2)
@mock_stripe()
def test_billing_page_permissions(self, *mocks: Mock) -> None:
hamlet = self.example_user('hamlet')
@ -972,6 +1240,22 @@ class StripeTest(StripeTestCase):
invoice_plans_as_needed(self.next_year + timedelta(days=400))
mocked.assert_not_called()
@patch("corporate.lib.stripe.billing_logger.info")
def test_reupgrade_after_plan_status_changed_to_downgrade_at_end_of_cycle(self, mock_: Mock) -> None:
user = self.example_user("hamlet")
self.login_user(user)
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, 'token')
response = self.client_post("/json/billing/plan/change",
{'status': CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE})
self.assert_json_success(response)
self.assertEqual(CustomerPlan.objects.first().status, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE)
response = self.client_post("/json/billing/plan/change",
{'status': CustomerPlan.ACTIVE})
self.assert_json_success(response)
self.assertEqual(CustomerPlan.objects.first().status, CustomerPlan.ACTIVE)
@patch("corporate.lib.stripe.billing_logger.info")
@patch("stripe.Invoice.create")
@patch("stripe.Invoice.finalize_invoice")
@ -997,6 +1281,49 @@ class StripeTest(StripeTestCase):
self.assertIsNone(plan.next_invoice_date)
self.assertEqual(plan.status, CustomerPlan.ENDED)
@patch("corporate.lib.stripe.billing_logger.info")
def test_downgrade_free_trial(self, mock_: Mock) -> None:
user = self.example_user("hamlet")
with self.settings(FREE_TRIAL_MONTHS=2):
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, 'token')
plan = CustomerPlan.objects.get()
self.assertEqual(plan.next_invoice_date, add_months(self.now, 2))
self.assertEqual(get_realm('zulip').plan_type, Realm.STANDARD)
self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL)
# Add some extra users before the realm is deactivated
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=21):
update_license_ledger_if_needed(user.realm, self.now)
last_ledger_entry = LicenseLedger.objects.order_by('id').last()
self.assertEqual(last_ledger_entry.licenses, 21)
self.assertEqual(last_ledger_entry.licenses_at_next_renewal, 21)
self.login_user(user)
self.client_post("/json/billing/plan/change", {'status': CustomerPlan.ENDED})
plan.refresh_from_db()
self.assertEqual(get_realm('zulip').plan_type, Realm.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.assert_in_success_response(["Your organization is on the <b>Zulip Free</b>"], response)
# The extra users added in the final month are not charged
with patch("corporate.lib.stripe.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.invoice_plan") as mocked:
invoice_plans_as_needed(self.next_year)
mocked.assert_not_called()
@patch("corporate.lib.stripe.billing_logger.warning")
@patch("corporate.lib.stripe.billing_logger.info")
def test_reupgrade_by_billing_admin_after_downgrade(self, *mocks: Mock) -> None:

View File

@ -22,7 +22,7 @@ from corporate.lib.stripe import STRIPE_PUBLISHABLE_KEY, \
unsign_string, BillingError, do_change_plan_status, do_replace_payment_source, \
MIN_INVOICED_LICENSES, MAX_INVOICED_LICENSES, DEFAULT_INVOICE_DAYS_UNTIL_DUE, \
start_of_next_billing_cycle, renewal_amount, \
make_end_of_cycle_updates_if_needed
make_end_of_cycle_updates_if_needed, downgrade_now
from corporate.models import CustomerPlan, get_current_plan_by_customer, \
get_customer_by_realm, get_current_plan_by_realm
@ -146,6 +146,7 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse:
'min_invoiced_licenses': max(seat_count, MIN_INVOICED_LICENSES),
'default_invoice_days_until_due': DEFAULT_INVOICE_DAYS_UNTIL_DUE,
'plan': "Zulip Standard",
"free_trial_months": settings.FREE_TRIAL_MONTHS,
'page_params': {
'seat_count': seat_count,
'annual_price': 8000,
@ -183,6 +184,7 @@ def billing_home(request: HttpRequest) -> HttpResponse:
CustomerPlan.STANDARD: 'Zulip Standard',
CustomerPlan.PLUS: 'Zulip Plus',
}[plan.tier]
free_trial = plan.status == CustomerPlan.FREE_TRIAL
licenses = last_ledger_entry.licenses
licenses_used = get_latest_seat_count(user.realm)
# Should do this in javascript, using the user's timezone
@ -198,6 +200,7 @@ def billing_home(request: HttpRequest) -> HttpResponse:
context.update({
'plan_name': plan_name,
'has_active_plan': True,
'free_trial': free_trial,
'licenses': licenses,
'licenses_used': licenses_used,
'renewal_date': renewal_date,
@ -214,9 +217,20 @@ def billing_home(request: HttpRequest) -> HttpResponse:
@has_request_variables
def change_plan_status(request: HttpRequest, user: UserProfile,
status: int=REQ("status", validator=check_int)) -> HttpResponse:
assert(status in [CustomerPlan.ACTIVE, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE, CustomerPlan.ENDED])
plan = get_current_plan_by_realm(user.realm)
assert(plan is not None) # for mypy
do_change_plan_status(plan, status)
if status == CustomerPlan.ACTIVE:
assert(plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE)
do_change_plan_status(plan, status)
elif status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
assert(plan.status == CustomerPlan.ACTIVE)
do_change_plan_status(plan, status)
elif status == CustomerPlan.ENDED:
assert(plan.status == CustomerPlan.FREE_TRIAL)
downgrade_now(user.realm)
return json_success()
@require_billing_access

View File

@ -28,14 +28,23 @@
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<div class="tab-content">
<div class="tab-pane active" id="overview">
{% if free_trial %}
<p>Your current plan is <strong>{{ plan_name }} Free Trial</strong>.</p>
{% else %}
<p>Your current plan is <strong>{{ plan_name }}</strong>.</p>
{% endif %}
<p>You are using <strong>{{ licenses_used }} of {{ licenses }} licenses</strong>.</p>
<p>
{% if renewal_amount %}
Your plan will renew on <strong>{{ renewal_date }}</strong> for
<strong>${{ renewal_amount }}</strong>.
{% if free_trial %}
Your plan will be upgraded to <strong>{{ plan_name }}</strong> on <strong>{{ renewal_date }}</strong> for
<strong>${{ renewal_amount }}</strong>.
{% else %}
Your plan will renew on <strong>{{ renewal_date }}</strong> for
<strong>${{ renewal_amount }}</strong>.
{% endif %}
{% else %}
Your plan ends on <strong>{{ renewal_date }}</strong>, and does not renew.
Your plan ends on <strong>{{ renewal_date }}</strong>, and does not renew.
{% endif %}
</p>
</div>

View File

@ -18,6 +18,12 @@
<div class="page-content">
<div class="main">
<h1>{% trans %}Upgrade to {{ plan }}{% endtrans %}</h1>
{% if free_trial_months %}
<div class="alert alert-info">
Upgrade now to start your {{ free_trial_months }} month Free Trial of Zulip Standard.
</div>
{% endif %}
{% if error_message %}
<div class="alert alert-danger" id="upgrade-error-message-box">
{{ error_message }}
@ -38,6 +44,12 @@
<input type="hidden" name="signed_seat_count" value="{{ signed_seat_count }}">
<input type="hidden" name="salt" value="{{ salt }}">
<input type="hidden" name="billing_modality" value="charge_automatically">
{% if free_trial_months %}
<p>
You won't be charged during the Free Trial. You can also downgrade back to Zulip Limited
during the Free Trial.
</p>
{% endif %}
<div class="payment-schedule">
<div id="autopay-error" class="alert alert-danger"></div>
<h3>{{ _("Payment schedule") }}</h3>
@ -82,21 +94,37 @@
<div id="license-automatic-section">
<p>
{% if free_trial_months %}
After the Free Trial, you&rsquo;ll be charged
<b>$<span id="charged_amount"></span></b> for <b>{{ seat_count }}</b>
users.
We'll automatically charge you for additional licenses as users
are added, and remove licenses not in use at the end of each billing
period.
{% else %}
You&rsquo;ll initially be charged
<b>$<span id="charged_amount"></span></b> for <b>{{ seat_count }}</b>
users.<br>
We'll automatically charge you for additional licenses as users
are added, and remove licenses not in use at the end of each billing
period.
{% endif %}
</p>
<input type="hidden" name="licenses" id="automatic_license_count" value="{{ seat_count }}">
</div>
<div id="license-manual-section">
<p>
{% if free_trial_months %}
Enter the number of users you would like to pay for after the Free Trial.<br>
You'll need to manually add licenses to add or invite
additional users.
{% else %}
Enter the number of users you would like to pay for.<br>
You'll need to manually add licenses to add or invite
additional users.
{% endif %}
</p>
<h4>Number of licenses (minimum {{ seat_count }})</h4>
@ -147,9 +175,15 @@
</label>
</div>
<p>
{% if free_trial_months %}
Enter the number of users you would like to pay for.<br>
We'll email you an invoice after the Free Trial.
Invoices can be paid by ACH transfer or credit card.
{% else %}
Enter the number of users you would like to pay for.<br>
We'll email you an invoice in 1-2 hours. Invoices can be paid by
ACH transfer or credit card.
{% endif %}
</p>
<h4>Number of licenses (minimum {{ min_invoiced_licenses }})</h4>
<input type="number" min="{{ min_invoiced_licenses }}" autocomplete="off"

View File

@ -87,11 +87,19 @@
</a>
{% elif realm_plan_type == 2 %}
<a href="/upgrade" class="button green">
{% if free_trial_months %}
Start {{ free_trial_months }} month Free Trial
{% else %}
Buy Standard
{% endif %}
</a>
{% else %}
<a href="/upgrade" class="button green">
{% if free_trial_months %}
Start {{ free_trial_months }} month Free Trial
{% else %}
Buy Standard
{% endif %}
</a>
{% endif %}
</div>

View File

@ -68,6 +68,7 @@ def check_html_templates(templates: Iterable[str], all_dups: bool, fix: bool) ->
'send_confirm',
'register',
'footer',
'charged_amount',
# Temporary while we have searchbox forked
'search_exit',
'search_query',

View File

@ -21,13 +21,15 @@ def apps_view(request: HttpRequest, _: str) -> HttpResponse:
def plans_view(request: HttpRequest) -> HttpResponse:
realm = get_realm_from_request(request)
realm_plan_type = 0
free_trial_months = settings.FREE_TRIAL_MONTHS
if realm is not None:
realm_plan_type = realm.plan_type
if realm.plan_type == Realm.SELF_HOSTED and settings.PRODUCTION:
return HttpResponseRedirect('https://zulipchat.com/plans')
if not request.user.is_authenticated:
return redirect_to_login(next="plans")
return render(request, "zerver/plans.html", context={"realm_plan_type": realm_plan_type})
return render(request, "zerver/plans.html",
context={"realm_plan_type": realm_plan_type, 'free_trial_months': free_trial_months})
def team_view(request: HttpRequest) -> HttpResponse:
if not settings.ZILENCER_ENABLED:

View File

@ -363,6 +363,8 @@ ARCHIVED_DATA_VACUUMING_DELAY_DAYS = 7
# are available to all realms.
BILLING_ENABLED = False
FREE_TRIAL_MONTHS = None
# Automatically catch-up soft deactivated users when running the
# `soft-deactivate-users` cron. Turn this off if the server has 10Ks of
# users, and you would like to save some disk space. Soft-deactivated

View File

@ -155,6 +155,7 @@ THUMBNAIL_IMAGES = True
SEARCH_PILLS_ENABLED = bool(os.getenv('SEARCH_PILLS_ENABLED', False))
BILLING_ENABLED = True
FREE_TRIAL_MONTHS = None
# Test Custom TOS template rendering
TERMS_OF_SERVICE = 'corporate/terms.md'