mirror of https://github.com/zulip/zulip.git
billing: Add initial support for seat based plans.
The main remaining todo for correctly populating RealmAuditLog.requires_billing_update is supporting the de-seating (and corresponding re-seating) that happens after being offline for two weeks.
This commit is contained in:
parent
16334a1ba7
commit
b5753d0ddc
|
@ -212,6 +212,9 @@ def bot_owner_user_ids(user_profile: UserProfile) -> Set[int]:
|
|||
def realm_user_count(realm: Realm) -> int:
|
||||
return UserProfile.objects.filter(realm=realm, is_active=True, is_bot=False).count()
|
||||
|
||||
def activity_change_requires_seat_update(user: UserProfile) -> bool:
|
||||
return user.realm.has_seat_based_plan and not user.is_bot
|
||||
|
||||
def get_topic_history_for_stream(user_profile: UserProfile,
|
||||
recipient: Recipient,
|
||||
public_history: bool) -> List[Dict[str, Any]]:
|
||||
|
@ -508,7 +511,8 @@ def do_create_user(email: str, password: Optional[str], realm: Realm, full_name:
|
|||
|
||||
event_time = user_profile.date_joined
|
||||
RealmAuditLog.objects.create(realm=user_profile.realm, modified_user=user_profile,
|
||||
event_type='user_created', event_time=event_time)
|
||||
event_type='user_created', event_time=event_time,
|
||||
requires_billing_update=activity_change_requires_seat_update(user_profile))
|
||||
do_increment_logging_stat(user_profile.realm, COUNT_STATS['active_users_log:is_bot:day'],
|
||||
user_profile.is_bot, event_time)
|
||||
|
||||
|
@ -532,7 +536,8 @@ def do_activate_user(user_profile: UserProfile) -> None:
|
|||
|
||||
event_time = user_profile.date_joined
|
||||
RealmAuditLog.objects.create(realm=user_profile.realm, modified_user=user_profile,
|
||||
event_type='user_activated', event_time=event_time)
|
||||
event_type='user_activated', event_time=event_time,
|
||||
requires_billing_update=activity_change_requires_seat_update(user_profile))
|
||||
do_increment_logging_stat(user_profile.realm, COUNT_STATS['active_users_log:is_bot:day'],
|
||||
user_profile.is_bot, event_time)
|
||||
|
||||
|
@ -547,7 +552,8 @@ def do_reactivate_user(user_profile: UserProfile, acting_user: Optional[UserProf
|
|||
event_time = timezone_now()
|
||||
RealmAuditLog.objects.create(realm=user_profile.realm, modified_user=user_profile,
|
||||
event_type='user_reactivated', event_time=event_time,
|
||||
acting_user=acting_user)
|
||||
acting_user=acting_user,
|
||||
requires_billing_update=activity_change_requires_seat_update(user_profile))
|
||||
do_increment_logging_stat(user_profile.realm, COUNT_STATS['active_users_log:is_bot:day'],
|
||||
user_profile.is_bot, event_time)
|
||||
|
||||
|
@ -702,7 +708,8 @@ def do_deactivate_user(user_profile: UserProfile,
|
|||
event_time = timezone_now()
|
||||
RealmAuditLog.objects.create(realm=user_profile.realm, modified_user=user_profile,
|
||||
acting_user=acting_user,
|
||||
event_type='user_deactivated', event_time=event_time)
|
||||
event_type='user_deactivated', event_time=event_time,
|
||||
requires_billing_update=activity_change_requires_seat_update(user_profile))
|
||||
do_increment_logging_stat(user_profile.realm, COUNT_STATS['active_users_log:is_bot:day'],
|
||||
user_profile.is_bot, event_time, increment=-1)
|
||||
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.13 on 2018-06-28 01:09
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('zerver', '0172_add_user_type_of_custom_profile_field'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='realm',
|
||||
name='has_seat_based_plan',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='realmauditlog',
|
||||
name='requires_billing_update',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -248,6 +248,8 @@ class Realm(models.Model):
|
|||
BOT_CREATION_ADMINS_ONLY,
|
||||
]
|
||||
|
||||
has_seat_based_plan = models.BooleanField(default=False) # type: bool
|
||||
|
||||
def authentication_methods_dict(self) -> Dict[str, bool]:
|
||||
"""Returns the a mapping from authentication flags to their status,
|
||||
showing only those authentication flags that are supported on
|
||||
|
@ -1946,13 +1948,21 @@ class RealmAuditLog(models.Model):
|
|||
modified_user = models.ForeignKey(UserProfile, null=True, related_name='+', on_delete=CASCADE) # type: Optional[UserProfile]
|
||||
modified_stream = models.ForeignKey(Stream, null=True, on_delete=CASCADE) # type: Optional[Stream]
|
||||
event_last_message_id = models.IntegerField(null=True) # type: Optional[int]
|
||||
event_type = models.CharField(max_length=40) # type: str
|
||||
|
||||
event_time = models.DateTimeField(db_index=True) # type: datetime.datetime
|
||||
# If True, event_time is an overestimate of the true time. Can be used
|
||||
# by migrations when introducing a new event_type.
|
||||
backfilled = models.BooleanField(default=False) # type: bool
|
||||
requires_billing_update = models.BooleanField(default=False) # type: bool
|
||||
extra_data = models.TextField(null=True) # type: Optional[str]
|
||||
|
||||
# Partial list of event_types.
|
||||
STRIPE_START = 'stripe_start'
|
||||
CARD_ADDED = 'card_added'
|
||||
PLAN_START = 'plan_start'
|
||||
PLAN_UPDATE_QUANTITY = 'plan_update_quantity'
|
||||
event_type = models.CharField(max_length=40) # type: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.modified_user is not None:
|
||||
return "<RealmAuditLog: %s %s %s>" % (self.modified_user, self.event_type, self.event_time)
|
||||
|
|
|
@ -3,15 +3,17 @@ from functools import wraps
|
|||
import logging
|
||||
import os
|
||||
from typing import Any, Callable, Optional, TypeVar
|
||||
import ujson
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils.translation import ugettext as _
|
||||
import stripe
|
||||
|
||||
from zerver.lib.exceptions import JsonableError
|
||||
from zerver.lib.logging_util import log_to_file
|
||||
from zerver.lib.timestamp import datetime_to_timestamp
|
||||
from zerver.models import Realm, UserProfile
|
||||
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
|
||||
from zerver.models import Realm, UserProfile, RealmAuditLog
|
||||
from zilencer.models import Customer, Plan
|
||||
from zproject.settings import get_secret
|
||||
|
||||
|
@ -108,6 +110,11 @@ def do_create_customer_with_payment_source(user: UserProfile, stripe_token: str)
|
|||
source=stripe_token)
|
||||
if PRINT_STRIPE_FIXTURE_DATA:
|
||||
print(''.join(['"create_customer": ', str(stripe_customer), ','])) # nocoverage
|
||||
event_time = timestamp_to_datetime(stripe_customer.created)
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user.realm, acting_user=user, event_type=RealmAuditLog.STRIPE_START, event_time=event_time)
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user.realm, acting_user=user, event_type=RealmAuditLog.CARD_ADDED, event_time=event_time)
|
||||
return Customer.objects.create(
|
||||
realm=realm,
|
||||
stripe_customer_id=stripe_customer.id,
|
||||
|
@ -116,6 +123,8 @@ def do_create_customer_with_payment_source(user: UserProfile, stripe_token: str)
|
|||
@catch_stripe_errors
|
||||
def do_subscribe_customer_to_plan(customer: Customer, stripe_plan_id: int,
|
||||
seat_count: int, tax_percent: float) -> None:
|
||||
# TODO: check that there are no existing live Stripe subscriptions
|
||||
# (canceled subscriptions are ok)
|
||||
stripe_subscription = stripe.Subscription.create(
|
||||
customer=customer.stripe_customer_id,
|
||||
billing='charge_automatically',
|
||||
|
@ -127,3 +136,21 @@ def do_subscribe_customer_to_plan(customer: Customer, stripe_plan_id: int,
|
|||
tax_percent=tax_percent)
|
||||
if PRINT_STRIPE_FIXTURE_DATA:
|
||||
print(''.join(['"create_subscription": ', str(stripe_subscription), ','])) # nocoverage
|
||||
with transaction.atomic():
|
||||
customer.realm.has_seat_based_plan = True
|
||||
customer.realm.save(update_fields=['has_seat_based_plan'])
|
||||
RealmAuditLog.objects.create(
|
||||
realm=customer.realm,
|
||||
acting_user=customer.billing_user,
|
||||
event_type=RealmAuditLog.PLAN_START,
|
||||
event_time=timestamp_to_datetime(stripe_subscription.created),
|
||||
extra_data=ujson.dumps({'plan': stripe_plan_id, 'quantity': seat_count}))
|
||||
|
||||
current_seat_count = get_seat_count(customer.realm)
|
||||
if seat_count != current_seat_count:
|
||||
RealmAuditLog.objects.create(
|
||||
realm=customer.realm,
|
||||
event_type=RealmAuditLog.PLAN_UPDATE_QUANTITY,
|
||||
event_time=timestamp_to_datetime(stripe_subscription.created),
|
||||
requires_billing_update=True,
|
||||
extra_data=ujson.dumps({'quantity': current_seat_count}))
|
||||
|
|
|
@ -6,9 +6,11 @@ import ujson
|
|||
import stripe
|
||||
from stripe.api_resources.list_object import ListObject
|
||||
|
||||
from zerver.lib.actions import do_deactivate_user
|
||||
from zerver.lib.actions import do_deactivate_user, do_create_user, \
|
||||
do_activate_user, do_reactivate_user, activity_change_requires_seat_update
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.models import Realm, UserProfile, get_realm
|
||||
from zerver.lib.timestamp import timestamp_to_datetime
|
||||
from zerver.models import Realm, UserProfile, get_realm, RealmAuditLog
|
||||
from zilencer.lib.stripe import StripeError, catch_stripe_errors, \
|
||||
do_create_customer_with_payment_source, do_subscribe_customer_to_plan, \
|
||||
get_seat_count
|
||||
|
@ -36,7 +38,9 @@ class StripeTest(ZulipTestCase):
|
|||
self.token = 'token'
|
||||
# The values below should be copied from stripe_fixtures.json
|
||||
self.stripe_customer_id = 'cus_D7OT2jf5YAtZQL'
|
||||
self.customer_created = 1529990750
|
||||
self.stripe_plan_id = 'plan_D7Nh2BtpTvIzYp'
|
||||
self.subscription_created = 1529990751
|
||||
self.quantity = 8
|
||||
Plan.objects.create(nickname=Plan.CLOUD_ANNUAL, stripe_plan_id=self.stripe_plan_id)
|
||||
|
||||
|
@ -86,13 +90,13 @@ class StripeTest(ZulipTestCase):
|
|||
self.login(self.user.email)
|
||||
response = self.client_get("/upgrade/")
|
||||
self.assert_in_success_response(['We can also bill by invoice'], response)
|
||||
self.assertFalse(self.realm.has_seat_based_plan)
|
||||
# Click "Make payment" in Stripe Checkout
|
||||
response = self.client_post("/upgrade/", {
|
||||
'stripeToken': self.token,
|
||||
'seat_count': self.quantity,
|
||||
'plan': Plan.CLOUD_ANNUAL})
|
||||
# Check that we created a customer and subscription in stripe, and a
|
||||
# Customer object in zulip
|
||||
# Check that we created a customer and subscription in stripe
|
||||
mock_create_customer.assert_called_once_with(
|
||||
description="zulip (Zulip Dev)",
|
||||
metadata={'realm_id': self.realm.id, 'realm_str': 'zulip'},
|
||||
|
@ -106,14 +110,63 @@ class StripeTest(ZulipTestCase):
|
|||
}],
|
||||
prorate=True,
|
||||
tax_percent=0)
|
||||
# Check that we correctly populated Customer and RealmAuditLog in Zulip
|
||||
self.assertEqual(1, Customer.objects.filter(realm=self.realm,
|
||||
stripe_customer_id=self.stripe_customer_id,
|
||||
billing_user=self.user).count())
|
||||
audit_log_entries = list(RealmAuditLog.objects.filter(acting_user=self.user)
|
||||
.values_list('event_type', 'event_time').order_by('id'))
|
||||
self.assertEqual(audit_log_entries, [
|
||||
(RealmAuditLog.STRIPE_START, timestamp_to_datetime(self.customer_created)),
|
||||
(RealmAuditLog.CARD_ADDED, timestamp_to_datetime(self.customer_created)),
|
||||
(RealmAuditLog.PLAN_START, timestamp_to_datetime(self.subscription_created)),
|
||||
])
|
||||
# Check that we correctly updated Realm
|
||||
realm = get_realm("zulip")
|
||||
self.assertTrue(realm.has_seat_based_plan)
|
||||
# Check that we can no longer access /upgrade
|
||||
response = self.client_get("/upgrade/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual('/billing/', response.url)
|
||||
|
||||
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
||||
@mock.patch("zilencer.views.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
||||
@mock.patch("stripe.Customer.create", side_effect=mock_create_customer)
|
||||
@mock.patch("stripe.Subscription.create", side_effect=mock_create_subscription)
|
||||
def test_upgrade_with_outdated_seat_count(self, mock_create_subscription: mock.Mock,
|
||||
mock_create_customer: mock.Mock) -> None:
|
||||
self.login(self.user.email)
|
||||
new_seat_count = 123
|
||||
# Change the seat count while the user is going through the upgrade flow
|
||||
with mock.patch('zilencer.lib.stripe.get_seat_count', return_value=new_seat_count):
|
||||
self.client_post("/upgrade/", {'stripeToken': self.token,
|
||||
'seat_count': self.quantity,
|
||||
'plan': Plan.CLOUD_ANNUAL})
|
||||
# Check that the subscription call used the old quantity, not new_seat_count
|
||||
mock_create_subscription.assert_called_once_with(
|
||||
customer=self.stripe_customer_id,
|
||||
billing='charge_automatically',
|
||||
items=[{
|
||||
'plan': self.stripe_plan_id,
|
||||
'quantity': self.quantity,
|
||||
}],
|
||||
prorate=True,
|
||||
tax_percent=0)
|
||||
# Check that we have the PLAN_UPDATE_QUANTITY entry, and that we
|
||||
# correctly handled the requires_billing_update field
|
||||
audit_log_entries = list(RealmAuditLog.objects.order_by('-id')
|
||||
.values_list('event_type', 'event_time',
|
||||
'requires_billing_update')[:4])[::-1]
|
||||
self.assertEqual(audit_log_entries, [
|
||||
(RealmAuditLog.STRIPE_START, timestamp_to_datetime(self.customer_created), False),
|
||||
(RealmAuditLog.CARD_ADDED, timestamp_to_datetime(self.customer_created), False),
|
||||
(RealmAuditLog.PLAN_START, timestamp_to_datetime(self.subscription_created), False),
|
||||
(RealmAuditLog.PLAN_UPDATE_QUANTITY, timestamp_to_datetime(self.subscription_created), True),
|
||||
])
|
||||
self.assertEqual(ujson.loads(RealmAuditLog.objects.filter(
|
||||
event_type=RealmAuditLog.PLAN_UPDATE_QUANTITY).values_list('extra_data', flat=True).first()),
|
||||
{'quantity': new_seat_count})
|
||||
|
||||
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
||||
@mock.patch("zilencer.views.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
||||
@mock.patch("stripe.Customer.retrieve", side_effect=mock_retrieve_customer)
|
||||
|
@ -148,3 +201,38 @@ class StripeTest(ZulipTestCase):
|
|||
# Test that inactive users aren't counted
|
||||
do_deactivate_user(user2)
|
||||
self.assertEqual(get_seat_count(self.realm), initial_count)
|
||||
|
||||
class BillingUpdateTest(ZulipTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = self.example_user("hamlet")
|
||||
self.realm = self.user.realm
|
||||
|
||||
def test_activity_change_requires_seat_update(self) -> None:
|
||||
# Realm doesn't have a seat based plan
|
||||
self.assertFalse(activity_change_requires_seat_update(self.user))
|
||||
self.realm.has_seat_based_plan = True
|
||||
self.realm.save(update_fields=['has_seat_based_plan'])
|
||||
# seat based plan + user not a bot
|
||||
self.assertTrue(activity_change_requires_seat_update(self.user))
|
||||
self.user.is_bot = True
|
||||
self.user.save(update_fields=['is_bot'])
|
||||
# seat based plan but user is a bot
|
||||
self.assertFalse(activity_change_requires_seat_update(self.user))
|
||||
|
||||
def test_requires_billing_update_for_is_active_changes(self) -> None:
|
||||
count = RealmAuditLog.objects.count()
|
||||
user1 = do_create_user('user1@zulip.com', 'password', self.realm, 'full name', 'short name')
|
||||
do_deactivate_user(user1)
|
||||
do_reactivate_user(user1)
|
||||
# Not a proper use of do_activate_user, but it's fine to call it like this for this test
|
||||
do_activate_user(user1)
|
||||
self.assertEqual(count + 4,
|
||||
RealmAuditLog.objects.filter(requires_billing_update=False).count())
|
||||
|
||||
self.realm.has_seat_based_plan = True
|
||||
self.realm.save(update_fields=['has_seat_based_plan'])
|
||||
user2 = do_create_user('user2@zulip.com', 'password', self.realm, 'full name', 'short name')
|
||||
do_deactivate_user(user2)
|
||||
do_reactivate_user(user2)
|
||||
do_activate_user(user2)
|
||||
self.assertEqual(4, RealmAuditLog.objects.filter(requires_billing_update=True).count())
|
||||
|
|
Loading…
Reference in New Issue