mirror of https://github.com/zulip/zulip.git
billing: Add support for Zulip Standard free trial.
This commit is contained in:
parent
66a437bbf1
commit
f1b1bf5a0d
|
@ -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,14 +314,14 @@ 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:
|
||||
if not free_trial:
|
||||
stripe_charge = stripe.Charge.create(
|
||||
amount=price_per_license * licenses,
|
||||
currency='usd',
|
||||
|
@ -310,11 +331,12 @@ def process_initial_upgrade(user: UserProfile, licenses: int, automanage_license
|
|||
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="Payment (Card ending in {})".format(cast(stripe.Card, stripe_charge.source).last4),
|
||||
description=description,
|
||||
discountable=False)
|
||||
|
||||
# TODO: The correctness of this relies on user creation, deactivation, etc being
|
||||
|
@ -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,6 +371,8 @@ 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))
|
||||
|
||||
if not free_trial:
|
||||
stripe.InvoiceItem.create(
|
||||
currency='usd',
|
||||
customer=customer.stripe_customer_id,
|
||||
|
@ -363,6 +389,7 @@ def process_initial_upgrade(user: UserProfile, licenses: int, automanage_license
|
|||
else:
|
||||
billing_method = 'send_invoice'
|
||||
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
|
||||
|
||||
stripe_invoice = stripe.Invoice.create(
|
||||
auto_advance=True,
|
||||
billing=billing_method,
|
||||
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
||||
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
|
||||
|
|
|
@ -28,12 +28,21 @@
|
|||
<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 %}
|
||||
{% 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.
|
||||
{% endif %}
|
||||
|
|
|
@ -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’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’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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
Loading…
Reference in New Issue