mirror of
https://github.com/zulip/zulip.git
synced 2025-10-24 08:33:43 +00:00
billing: Add coupons.
This commit is contained in:
@@ -12,6 +12,7 @@ class Customer:
|
||||
id: str
|
||||
source: str
|
||||
subscriptions: SubscriptionListObject
|
||||
coupon: str
|
||||
|
||||
@staticmethod
|
||||
def retrieve(customer_id: str, expand: Optional[List[str]]) -> Customer:
|
||||
@@ -19,7 +20,7 @@ class Customer:
|
||||
|
||||
@staticmethod
|
||||
def create(description: str, email: str, metadata: Dict[str, Any],
|
||||
source: Optional[str]) -> Customer:
|
||||
source: Optional[str], coupon: Optional[str]) -> Customer:
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
@@ -63,3 +64,10 @@ class Product:
|
||||
@staticmethod
|
||||
def create(name: str, type: str, statement_descriptor: str, unit_label: str) -> Product:
|
||||
...
|
||||
|
||||
class Coupon:
|
||||
id: str
|
||||
|
||||
@staticmethod
|
||||
def create(duration: str, name: str, percent_off: int) -> Coupon:
|
||||
...
|
||||
|
||||
@@ -18,7 +18,7 @@ from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
|
||||
from zerver.lib.utils import generate_random_token
|
||||
from zerver.lib.actions import do_change_plan_type
|
||||
from zerver.models import Realm, UserProfile, RealmAuditLog
|
||||
from zilencer.models import Customer, Plan, BillingProcessor
|
||||
from zilencer.models import Customer, Plan, Coupon, BillingProcessor
|
||||
from zproject.settings import get_secret
|
||||
|
||||
STRIPE_PUBLISHABLE_KEY = get_secret('stripe_publishable_key')
|
||||
@@ -32,6 +32,7 @@ billing_logger = logging.getLogger('zilencer.stripe')
|
||||
log_to_file(billing_logger, BILLING_LOG_PATH)
|
||||
log_to_file(logging.getLogger('stripe'), BILLING_LOG_PATH)
|
||||
|
||||
## Note: this is no longer accurate, as of when we added coupons
|
||||
# To generate the fixture data in stripe_fixtures.json:
|
||||
# * Set PRINT_STRIPE_FIXTURE_DATA to True
|
||||
# * ./manage.py setup_stripe
|
||||
@@ -136,8 +137,12 @@ def extract_current_subscription(stripe_customer: stripe.Customer) -> Any:
|
||||
return None
|
||||
|
||||
@catch_stripe_errors
|
||||
def do_create_customer(user: UserProfile, stripe_token: Optional[str]=None) -> stripe.Customer:
|
||||
def do_create_customer(user: UserProfile, stripe_token: Optional[str]=None,
|
||||
coupon: Optional[Coupon]=None) -> stripe.Customer:
|
||||
realm = user.realm
|
||||
stripe_coupon_id = None
|
||||
if coupon is not None:
|
||||
stripe_coupon_id = coupon.stripe_coupon_id
|
||||
# We could do a better job of handling race conditions here, but if two
|
||||
# people from a realm try to upgrade at exactly the same time, the main
|
||||
# bad thing that will happen is that we will create an extra stripe
|
||||
@@ -146,7 +151,8 @@ def do_create_customer(user: UserProfile, stripe_token: Optional[str]=None) -> s
|
||||
description="%s (%s)" % (realm.string_id, realm.name),
|
||||
email=user.email,
|
||||
metadata={'realm_id': realm.id, 'realm_str': realm.string_id},
|
||||
source=stripe_token)
|
||||
source=stripe_token,
|
||||
coupon=stripe_coupon_id)
|
||||
if PRINT_STRIPE_FIXTURE_DATA:
|
||||
print(''.join(['"create_customer": ', str(stripe_customer), ','])) # nocoverage
|
||||
event_time = timestamp_to_datetime(stripe_customer.created)
|
||||
@@ -177,6 +183,12 @@ def do_replace_payment_source(user: UserProfile, stripe_token: str) -> stripe.Cu
|
||||
event_time=timezone_now())
|
||||
return updated_stripe_customer
|
||||
|
||||
@catch_stripe_errors
|
||||
def do_replace_coupon(user: UserProfile, coupon: Coupon) -> stripe.Customer:
|
||||
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
||||
stripe_customer.coupon = coupon.stripe_coupon_id
|
||||
return stripe_customer.save()
|
||||
|
||||
@catch_stripe_errors
|
||||
def do_subscribe_customer_to_plan(stripe_customer: stripe.Customer, stripe_plan_id: str,
|
||||
seat_count: int, tax_percent: float) -> None:
|
||||
@@ -244,6 +256,14 @@ def process_initial_upgrade(user: UserProfile, plan: Plan, seat_count: int, stri
|
||||
tax_percent=0)
|
||||
do_change_plan_type(user, Realm.PREMIUM)
|
||||
|
||||
def attach_discount_to_realm(user: UserProfile, percent_off: int) -> None:
|
||||
coupon = Coupon.objects.get(percent_off=percent_off)
|
||||
customer = Customer.objects.filter(realm=user.realm).first()
|
||||
if customer is None:
|
||||
do_create_customer(user, coupon=coupon)
|
||||
else:
|
||||
do_replace_coupon(user, coupon)
|
||||
|
||||
## Process RealmAuditLog
|
||||
|
||||
def do_set_subscription_quantity(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from zerver.lib.management import ZulipBaseCommand
|
||||
from zilencer.models import Plan
|
||||
from zilencer.models import Plan, Coupon
|
||||
from zproject.settings import get_secret
|
||||
|
||||
from typing import Any
|
||||
@@ -39,3 +39,15 @@ class Command(ZulipBaseCommand):
|
||||
nickname=Plan.CLOUD_ANNUAL,
|
||||
usage_type='licensed')
|
||||
Plan.objects.create(nickname=Plan.CLOUD_ANNUAL, stripe_plan_id=plan.id)
|
||||
|
||||
coupon = stripe.Coupon.create(
|
||||
duration='forever',
|
||||
name='25% discount',
|
||||
percent_off=25)
|
||||
Coupon.objects.create(percent_off=25, stripe_coupon_id=coupon.id)
|
||||
|
||||
coupon = stripe.Coupon.create(
|
||||
duration='forever',
|
||||
name='85% discount',
|
||||
percent_off=85)
|
||||
Coupon.objects.create(percent_off=85, stripe_coupon_id=coupon.id)
|
||||
|
||||
23
zilencer/migrations/0012_coupon.py
Normal file
23
zilencer/migrations/0012_coupon.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.14 on 2018-08-23 05:40
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('zilencer', '0011_customer_has_billing_relationship'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Coupon',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('percent_off', models.SmallIntegerField(unique=True)),
|
||||
('stripe_coupon_id', models.CharField(max_length=255, unique=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -56,6 +56,13 @@ class Plan(models.Model):
|
||||
|
||||
stripe_plan_id = models.CharField(max_length=255, unique=True) # type: str
|
||||
|
||||
class Coupon(models.Model):
|
||||
percent_off = models.SmallIntegerField(unique=True) # type: int
|
||||
stripe_coupon_id = models.CharField(max_length=255, unique=True) # type: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return '<Coupon: %s %s %s>' % (self.percent_off, self.stripe_coupon_id, self.id)
|
||||
|
||||
class BillingProcessor(models.Model):
|
||||
log_row = models.ForeignKey(RealmAuditLog, on_delete=models.CASCADE) # RealmAuditLog
|
||||
# Exactly one processor, the global processor, has realm=None.
|
||||
|
||||
@@ -6,7 +6,30 @@
|
||||
"default_source": "card_1Ch9gVGh0CmXqmnwv94RombT",
|
||||
"delinquent": false,
|
||||
"description": "zulip (Zulip Dev)",
|
||||
"discount": null,
|
||||
"discount": {
|
||||
"coupon": {
|
||||
"amount_off": null,
|
||||
"created": 1535002820,
|
||||
"currency": null,
|
||||
"duration": "forever",
|
||||
"duration_in_months": null,
|
||||
"id": "rncBblSZ",
|
||||
"livemode": false,
|
||||
"max_redemptions": null,
|
||||
"metadata": {},
|
||||
"name": "85% discount",
|
||||
"object": "coupon",
|
||||
"percent_off": 85.0,
|
||||
"redeem_by": null,
|
||||
"times_redeemed": 1,
|
||||
"valid": true
|
||||
},
|
||||
"customer": "cus_DT7pd3yW0w8lF1",
|
||||
"end": null,
|
||||
"object": "discount",
|
||||
"start": 1535004909,
|
||||
"subscription": null
|
||||
},
|
||||
"email": "hamlet@zulip.com",
|
||||
"id": "cus_D7OT2jf5YAtZQL",
|
||||
"invoice_prefix": "23ABC45",
|
||||
|
||||
@@ -17,11 +17,11 @@ from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.lib.timestamp import timestamp_to_datetime, datetime_to_timestamp
|
||||
from zerver.models import Realm, UserProfile, get_realm, RealmAuditLog
|
||||
from zilencer.lib.stripe import catch_stripe_errors, \
|
||||
do_subscribe_customer_to_plan, \
|
||||
do_subscribe_customer_to_plan, attach_discount_to_realm, \
|
||||
get_seat_count, extract_current_subscription, sign_string, unsign_string, \
|
||||
get_next_billing_log_entry, run_billing_processor_one_step, \
|
||||
BillingError, StripeCardError, StripeConnectionError
|
||||
from zilencer.models import Customer, Plan, BillingProcessor
|
||||
from zilencer.models import Customer, Plan, Coupon, BillingProcessor
|
||||
|
||||
fixture_data_file = open(os.path.join(os.path.dirname(__file__), 'stripe_fixtures.json'), 'r')
|
||||
fixture_data = ujson.load(fixture_data_file)
|
||||
@@ -62,12 +62,14 @@ class StripeTest(ZulipTestCase):
|
||||
# The values below should be copied from stripe_fixtures.json
|
||||
self.stripe_customer_id = 'cus_D7OT2jf5YAtZQL'
|
||||
self.customer_created = 1529990750
|
||||
self.stripe_coupon_id = "rncBblSZ"
|
||||
self.stripe_plan_id = 'plan_D7Nh2BtpTvIzYp'
|
||||
self.subscription_created = 1529990751
|
||||
self.quantity = 8
|
||||
|
||||
self.signed_seat_count, self.salt = sign_string(str(self.quantity))
|
||||
Plan.objects.create(nickname=Plan.CLOUD_ANNUAL, stripe_plan_id=self.stripe_plan_id)
|
||||
Coupon.objects.create(percent_off=85, stripe_coupon_id=self.stripe_coupon_id)
|
||||
|
||||
def get_signed_seat_count_from_response(self, response: HttpResponse) -> Optional[str]:
|
||||
match = re.search(r'name=\"signed_seat_count\" value=\"(.+)\"', response.content.decode("utf-8"))
|
||||
@@ -122,7 +124,8 @@ class StripeTest(ZulipTestCase):
|
||||
description="zulip (Zulip Dev)",
|
||||
email=user.email,
|
||||
metadata={'realm_id': user.realm.id, 'realm_str': 'zulip'},
|
||||
source=self.token)
|
||||
source=self.token,
|
||||
coupon=None)
|
||||
mock_create_subscription.assert_called_once_with(
|
||||
customer=self.stripe_customer_id,
|
||||
billing='charge_automatically',
|
||||
@@ -358,6 +361,25 @@ class StripeTest(ZulipTestCase):
|
||||
with self.assertRaises(signing.BadSignature):
|
||||
unsign_string(signed_string, "randomsalt")
|
||||
|
||||
@mock.patch("stripe.Customer.retrieve", side_effect=mock_create_customer)
|
||||
@mock.patch("stripe.Customer.create", side_effect=mock_create_customer)
|
||||
def test_attach_discount_to_realm(self, mock_create_customer: mock.Mock,
|
||||
mock_retrieve_customer: mock.Mock) -> None:
|
||||
user = self.example_user('hamlet')
|
||||
# Before customer exists
|
||||
attach_discount_to_realm(user, 85)
|
||||
mock_create_customer.assert_called_once_with(
|
||||
description=Kandra(), email=self.example_email('hamlet'), metadata=Kandra(),
|
||||
source=None, coupon=self.stripe_coupon_id)
|
||||
mock_create_customer.reset_mock()
|
||||
# For existing customer
|
||||
Coupon.objects.create(percent_off=25, stripe_coupon_id='25OFF')
|
||||
with mock.patch.object(
|
||||
stripe.Customer, 'save', autospec=True,
|
||||
side_effect=lambda stripe_customer: self.assertEqual(stripe_customer.coupon, '25OFF')):
|
||||
attach_discount_to_realm(user, 25)
|
||||
mock_create_customer.assert_not_called()
|
||||
|
||||
@mock.patch("stripe.Customer.create", side_effect=mock_create_customer)
|
||||
@mock.patch("stripe.Subscription.create", side_effect=mock_create_subscription)
|
||||
@mock.patch("stripe.Customer.retrieve", side_effect=mock_customer_with_subscription)
|
||||
|
||||
Reference in New Issue
Block a user