billing: Add coupons.

This commit is contained in:
Rishi Gupta
2018-08-22 22:45:19 -07:00
parent d229948a43
commit eec07072ee
7 changed files with 124 additions and 9 deletions

View File

@@ -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:
...

View File

@@ -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(

View File

@@ -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)

View 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)),
],
),
]

View File

@@ -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.

View File

@@ -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",

View File

@@ -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)