diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py
index e76135aeff..2bb61b079a 100644
--- a/corporate/lib/stripe.py
+++ b/corporate/lib/stripe.py
@@ -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:
diff --git a/corporate/models.py b/corporate/models.py
index c517121f75..b512f7c4ff 100644
--- a/corporate/models.py
+++ b/corporate/models.py
@@ -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
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Charge.list.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Charge.list.1.json
new file mode 100644
index 0000000000..75910611cf
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Charge.list.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Customer.create.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Customer.create.1.json
new file mode 100644
index 0000000000..48d96fefd7
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Customer.create.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Customer.retrieve.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Customer.retrieve.1.json
new file mode 100644
index 0000000000..2485749ffd
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Customer.retrieve.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Customer.retrieve.2.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Customer.retrieve.2.json
new file mode 100644
index 0000000000..2485749ffd
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Customer.retrieve.2.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.create.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.create.1.json
new file mode 100644
index 0000000000..dcf2a013fc
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.create.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.create.2.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.create.2.json
new file mode 100644
index 0000000000..c67576116a
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.create.2.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.create.3.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.create.3.json
new file mode 100644
index 0000000000..636a1cb4dd
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.create.3.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.finalize_invoice.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.finalize_invoice.1.json
new file mode 100644
index 0000000000..857c0a8b08
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.finalize_invoice.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.finalize_invoice.2.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.finalize_invoice.2.json
new file mode 100644
index 0000000000..8575f0b461
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.finalize_invoice.2.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.finalize_invoice.3.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.finalize_invoice.3.json
new file mode 100644
index 0000000000..1a4b0cb4ea
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.finalize_invoice.3.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.1.json
new file mode 100644
index 0000000000..e39960ab72
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.2.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.2.json
new file mode 100644
index 0000000000..e39960ab72
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.2.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.3.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.3.json
new file mode 100644
index 0000000000..67b144d588
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.3.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.4.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.4.json
new file mode 100644
index 0000000000..67b144d588
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.4.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.5.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.5.json
new file mode 100644
index 0000000000..58547c136a
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.5.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.6.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.6.json
new file mode 100644
index 0000000000..fdee84eb0d
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Invoice.list.6.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--InvoiceItem.create.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--InvoiceItem.create.1.json
new file mode 100644
index 0000000000..f99447209e
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--InvoiceItem.create.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--InvoiceItem.create.2.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--InvoiceItem.create.2.json
new file mode 100644
index 0000000000..04af1e14a4
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--InvoiceItem.create.2.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--InvoiceItem.create.3.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--InvoiceItem.create.3.json
new file mode 100644
index 0000000000..47ee85f59a
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--InvoiceItem.create.3.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Token.create.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Token.create.1.json
new file mode 100644
index 0000000000..4edd6803f0
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_card--Token.create.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Customer.create.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Customer.create.1.json
new file mode 100644
index 0000000000..859f8d187d
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Customer.create.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Customer.retrieve.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Customer.retrieve.1.json
new file mode 100644
index 0000000000..859f8d187d
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Customer.retrieve.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Customer.retrieve.2.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Customer.retrieve.2.json
new file mode 100644
index 0000000000..859f8d187d
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Customer.retrieve.2.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.create.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.create.1.json
new file mode 100644
index 0000000000..d0c4723502
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.create.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.create.2.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.create.2.json
new file mode 100644
index 0000000000..47a832187d
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.create.2.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.finalize_invoice.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.finalize_invoice.1.json
new file mode 100644
index 0000000000..a722423d0e
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.finalize_invoice.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.finalize_invoice.2.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.finalize_invoice.2.json
new file mode 100644
index 0000000000..28c295334f
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.finalize_invoice.2.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.1.json
new file mode 100644
index 0000000000..e39960ab72
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.2.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.2.json
new file mode 100644
index 0000000000..98ed046dee
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.2.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.3.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.3.json
new file mode 100644
index 0000000000..98ed046dee
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.3.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.4.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.4.json
new file mode 100644
index 0000000000..98ed046dee
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.4.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.5.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.5.json
new file mode 100644
index 0000000000..9a56d5fcbf
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--Invoice.list.5.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--InvoiceItem.create.1.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--InvoiceItem.create.1.json
new file mode 100644
index 0000000000..f4d06581cb
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--InvoiceItem.create.1.json differ
diff --git a/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--InvoiceItem.create.2.json b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--InvoiceItem.create.2.json
new file mode 100644
index 0000000000..3e27f6a83b
Binary files /dev/null and b/corporate/tests/stripe_fixtures/free_trial_upgrade_by_invoice--InvoiceItem.create.2.json differ
diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py
index 39b59cc4a1..854580b478 100644
--- a/corporate/tests/test_stripe.py
+++ b/corporate/tests/test_stripe.py
@@ -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 Zulip Free"], 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:
diff --git a/corporate/views.py b/corporate/views.py
index c7b9690f0c..edf2cb9ea9 100644
--- a/corporate/views.py
+++ b/corporate/views.py
@@ -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
diff --git a/templates/corporate/billing.html b/templates/corporate/billing.html
index d57e63edab..bf53b58124 100644
--- a/templates/corporate/billing.html
+++ b/templates/corporate/billing.html
@@ -28,14 +28,23 @@
+ {% if free_trial %}
+
Your current plan is {{ plan_name }} Free Trial.
+ {% else %}
Your current plan is {{ plan_name }}.
+ {% endif %}
You are using {{ licenses_used }} of {{ licenses }} licenses.
{% if renewal_amount %}
- Your plan will renew on {{ renewal_date }} for
- ${{ renewal_amount }}.
+ {% if free_trial %}
+ Your plan will be upgraded to {{ plan_name }} on {{ renewal_date }} for
+ ${{ renewal_amount }}.
+ {% else %}
+ Your plan will renew on {{ renewal_date }} for
+ ${{ renewal_amount }}.
+ {% endif %}
{% else %}
- Your plan ends on {{ renewal_date }}, and does not renew.
+ Your plan ends on {{ renewal_date }}, and does not renew.
{% endif %}
+ You won't be charged during the Free Trial. You can also downgrade back to Zulip Limited
+ during the Free Trial.
+
+ {% endif %}
{{ _("Payment schedule") }}
@@ -82,21 +94,37 @@
+ {% if free_trial_months %}
+ After the Free Trial, you’ll be charged
+ $ for {{ seat_count }}
+ 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’ll initially be charged
$ for {{ seat_count }}
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.
+ {% endif %}
+
+ {% if free_trial_months %}
+ Enter the number of users you would like to pay for after the Free Trial.
+ 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.
You'll need to manually add licenses to add or invite
additional users.
+ {% endif %}
Number of licenses (minimum {{ seat_count }})
@@ -147,9 +175,15 @@
+ {% if free_trial_months %}
+ Enter the number of users you would like to pay for.
+ 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.
We'll email you an invoice in 1-2 hours. Invoices can be paid by
ACH transfer or credit card.
+ {% endif %}
Number of licenses (minimum {{ min_invoiced_licenses }})