billing: Add do_change_remote_server_plan_type.

This is a part of the plumbing we need to support billing for
self-hosted customers.

With documentation changes from tabbott.
This commit is contained in:
Eeshan Garg 2021-12-01 11:31:08 -05:00 committed by Tim Abbott
parent f533097487
commit 79e9ba13e2
6 changed files with 136 additions and 1 deletions

View File

@ -32,6 +32,7 @@ from zerver.lib.send_email import FromAddress, send_email_to_billing_admins_and_
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
from zerver.lib.utils import assert_is_not_none from zerver.lib.utils import assert_is_not_none
from zerver.models import Realm, RealmAuditLog, UserProfile, get_system_bot from zerver.models import Realm, RealmAuditLog, UserProfile, get_system_bot
from zilencer.models import RemoteZulipServer, RemoteZulipServerAuditLog
from zproject.config import get_secret from zproject.config import get_secret
stripe.api_key = get_secret("stripe_secret_key") stripe.api_key = get_secret("stripe_secret_key")
@ -619,6 +620,19 @@ def ensure_realm_does_not_have_active_plan(realm: Customer) -> None:
raise UpgradeWithExistingPlanError() raise UpgradeWithExistingPlanError()
@transaction.atomic
def do_change_remote_server_plan_type(remote_server: RemoteZulipServer, plan_type: int) -> None:
old_value = remote_server.plan_type
remote_server.plan_type = plan_type
remote_server.save(update_fields=["plan_type"])
RemoteZulipServerAuditLog.objects.create(
event_type=RealmAuditLog.REMOTE_SERVER_PLAN_TYPE_CHANGED,
server=remote_server,
event_time=timezone_now(),
extra_data={"old_value": old_value, "new_value": plan_type},
)
# Only used for cloud signups # Only used for cloud signups
@catch_stripe_errors @catch_stripe_errors
def process_initial_upgrade( def process_initial_upgrade(

View File

@ -36,6 +36,7 @@ from corporate.lib.stripe import (
catch_stripe_errors, catch_stripe_errors,
compute_plan_parameters, compute_plan_parameters,
customer_has_credit_card_as_default_payment_method, customer_has_credit_card_as_default_payment_method,
do_change_remote_server_plan_type,
do_create_stripe_customer, do_create_stripe_customer,
downgrade_small_realms_behind_on_payments_as_needed, downgrade_small_realms_behind_on_payments_as_needed,
get_discount_for_realm, get_discount_for_realm,
@ -97,6 +98,7 @@ from zerver.models import (
get_realm, get_realm,
get_system_bot, get_system_bot,
) )
from zilencer.models import RemoteZulipServer, RemoteZulipServerAuditLog
CallableT = TypeVar("CallableT", bound=Callable[..., Any]) CallableT = TypeVar("CallableT", bound=Callable[..., Any])
@ -4418,6 +4420,30 @@ class BillingHelpersTest(ZulipTestCase):
realm.save() realm.save()
self.assertTrue(is_sponsored_realm(realm)) self.assertTrue(is_sponsored_realm(realm))
def test_change_remote_server_plan_type(self) -> None:
server_uuid = "demo-1234"
remote_server = RemoteZulipServer.objects.create(
uuid=server_uuid,
api_key="magic_secret_api_key",
hostname="demo.example.com",
contact_email="email@example.com",
)
self.assertEqual(remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_SELF_HOSTED)
do_change_remote_server_plan_type(remote_server, RemoteZulipServer.PLAN_TYPE_STANDARD)
remote_server = RemoteZulipServer.objects.get(uuid=server_uuid)
remote_realm_audit_log = RemoteZulipServerAuditLog.objects.filter(
event_type=RealmAuditLog.REMOTE_SERVER_PLAN_TYPE_CHANGED
).last()
assert remote_realm_audit_log is not None
expected_extra_data = {
"old_value": RemoteZulipServer.PLAN_TYPE_SELF_HOSTED,
"new_value": RemoteZulipServer.PLAN_TYPE_STANDARD,
}
self.assertEqual(remote_realm_audit_log.extra_data, str(expected_extra_data))
self.assertEqual(remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_STANDARD)
class LicenseLedgerTest(StripeTestCase): class LicenseLedgerTest(StripeTestCase):
def test_add_plan_renewal_if_needed(self) -> None: def test_add_plan_renewal_if_needed(self) -> None:

View File

@ -3883,6 +3883,11 @@ class AbstractRealmAuditLog(models.Model):
STREAM_NAME_CHANGED = 603 STREAM_NAME_CHANGED = 603
STREAM_REACTIVATED = 604 STREAM_REACTIVATED = 604
# The following values are only for RemoteZulipServerAuditLog
# Values are chosen to be 10000 greater than the value in RealmAuditLog.
REMOTE_SERVER_CREATED = 10215
REMOTE_SERVER_PLAN_TYPE_CHANGED = 10204
event_type: int = models.PositiveSmallIntegerField() event_type: int = models.PositiveSmallIntegerField()
# event_types synced from on-prem installations to Zulip Cloud when # event_types synced from on-prem installations to Zulip Cloud when

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.9 on 2021-12-01 16:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zilencer", "0018_remoterealmauditlog"),
]
operations = [
migrations.AddField(
model_name="remotezulipserver",
name="plan_type",
field=models.PositiveSmallIntegerField(default=1),
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 3.2.9 on 2021-12-06 18:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zilencer", "0019_remotezulipserver_plan_type"),
]
operations = [
migrations.CreateModel(
name="RemoteZulipServerAuditLog",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("event_time", models.DateTimeField(db_index=True)),
("backfilled", models.BooleanField(default=False)),
("extra_data", models.TextField(null=True)),
("event_type", models.PositiveSmallIntegerField()),
(
"server",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="zilencer.remotezulipserver"
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -15,17 +15,34 @@ def get_remote_server_by_uuid(uuid: str) -> "RemoteZulipServer":
class RemoteZulipServer(models.Model): class RemoteZulipServer(models.Model):
"""Each object corresponds to a single remote Zulip server that is
registered for the Mobile Push Notifications Service via
`manage.py register_server`.
"""
UUID_LENGTH = 36 UUID_LENGTH = 36
API_KEY_LENGTH = 64 API_KEY_LENGTH = 64
HOSTNAME_MAX_LENGTH = 128 HOSTNAME_MAX_LENGTH = 128
# The unique UUID (`zulip_org_id`) and API key (`zulip_org_key`)
# for this remote server registration.
uuid: str = models.CharField(max_length=UUID_LENGTH, unique=True) uuid: str = models.CharField(max_length=UUID_LENGTH, unique=True)
api_key: str = models.CharField(max_length=API_KEY_LENGTH) api_key: str = models.CharField(max_length=API_KEY_LENGTH)
# The hostname and contact details are not verified/trusted. Thus,
# they primarily exist so that we can communicate with the
# maintainer of a server about abuse problems.
hostname: str = models.CharField(max_length=HOSTNAME_MAX_LENGTH) hostname: str = models.CharField(max_length=HOSTNAME_MAX_LENGTH)
contact_email: str = models.EmailField(blank=True, null=False) contact_email: str = models.EmailField(blank=True, null=False)
last_updated: datetime.datetime = models.DateTimeField("last updated", auto_now=True) last_updated: datetime.datetime = models.DateTimeField("last updated", auto_now=True)
# Plan types for self-hosted customers
PLAN_TYPE_SELF_HOSTED = 1
PLAN_TYPE_STANDARD = 102
# The current billing plan for the remote server, similar to Realm.plan_type.
plan_type: int = models.PositiveSmallIntegerField(default=PLAN_TYPE_SELF_HOSTED)
def __str__(self) -> str: def __str__(self) -> str:
return f"<RemoteZulipServer {self.hostname} {self.uuid[0:12]}>" return f"<RemoteZulipServer {self.hostname} {self.uuid[0:12]}>"
@ -33,8 +50,9 @@ class RemoteZulipServer(models.Model):
return "zulip-server:" + self.uuid return "zulip-server:" + self.uuid
# Variant of PushDeviceToken for a remote server.
class RemotePushDeviceToken(AbstractPushDeviceToken): class RemotePushDeviceToken(AbstractPushDeviceToken):
"""Like PushDeviceToken, but for a device connected to a remote server."""
server: RemoteZulipServer = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE) server: RemoteZulipServer = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE)
# The user id on the remote server for this device device this is # The user id on the remote server for this device device this is
user_id: int = models.BigIntegerField(db_index=True) user_id: int = models.BigIntegerField(db_index=True)
@ -46,6 +64,22 @@ class RemotePushDeviceToken(AbstractPushDeviceToken):
return f"<RemotePushDeviceToken {self.server} {self.user_id}>" return f"<RemotePushDeviceToken {self.server} {self.user_id}>"
class RemoteZulipServerAuditLog(AbstractRealmAuditLog):
"""Audit data associated with a remote Zulip server (not specific to a
realm). Used primarily for tracking registration and billing
changes for self-hosted customers.
In contrast with RemoteRealmAuditLog, which has a copy of data
that is generated on the client Zulip server, this table is the
authoritative storage location for the server's history.
"""
server: RemoteZulipServer = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE)
def __str__(self) -> str:
return f"<RemoteZulipServerAuditLog: {self.server} {self.event_type} {self.event_time} {self.id}>"
class RemoteRealmAuditLog(AbstractRealmAuditLog): class RemoteRealmAuditLog(AbstractRealmAuditLog):
"""Synced audit data from a remote Zulip server, used primarily for """Synced audit data from a remote Zulip server, used primarily for
billing. See RealmAuditLog and AbstractRealmAuditLog for details. billing. See RealmAuditLog and AbstractRealmAuditLog for details.