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 %}

diff --git a/templates/corporate/upgrade.html b/templates/corporate/upgrade.html index baec13a365..1749b42688 100644 --- a/templates/corporate/upgrade.html +++ b/templates/corporate/upgrade.html @@ -18,6 +18,12 @@

{% trans %}Upgrade to {{ plan }}{% endtrans %}

+ {% if free_trial_months %} +
+ Upgrade now to start your {{ free_trial_months }} month Free Trial of Zulip Standard. +
+ {% endif %} + {% if error_message %}
{{ error_message }} @@ -38,6 +44,12 @@ + {% if free_trial_months %} +

+ 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 }})

{% elif realm_plan_type == 2 %} + {% if free_trial_months %} + Start {{ free_trial_months }} month Free Trial + {% else %} Buy Standard + {% endif %} {% else %} + {% if free_trial_months %} + Start {{ free_trial_months }} month Free Trial + {% else %} Buy Standard + {% endif %} {% endif %}
diff --git a/tools/check-templates b/tools/check-templates index 6eb07953b4..c028672cce 100755 --- a/tools/check-templates +++ b/tools/check-templates @@ -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', diff --git a/zerver/views/portico.py b/zerver/views/portico.py index c1de237190..0c08aaa0ed 100644 --- a/zerver/views/portico.py +++ b/zerver/views/portico.py @@ -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: diff --git a/zproject/default_settings.py b/zproject/default_settings.py index 4daae03beb..5402d16376 100644 --- a/zproject/default_settings.py +++ b/zproject/default_settings.py @@ -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 diff --git a/zproject/dev_settings.py b/zproject/dev_settings.py index 63b790465b..d970286603 100644 --- a/zproject/dev_settings.py +++ b/zproject/dev_settings.py @@ -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'