Skip to content

Commit

Permalink
billing: Do subscription management in-house instead of with Stripe B…
Browse files Browse the repository at this point in the history
…illing.

This is a major rewrite of the billing system. It moves subscription
information off of stripe Subscriptions and into a local CustomerPlan
table.

To keep this manageable, it leaves several things unimplemented
(downgrading, etc), and a variety of other TODOs in the code. There are also
some known regressions, e.g. error-handling on /upgrade is broken.
  • Loading branch information
rishig committed Dec 22, 2018
1 parent 5633049 commit e7220fd
Show file tree
Hide file tree
Showing 107 changed files with 3,069 additions and 4,685 deletions.
19 changes: 5 additions & 14 deletions analytics/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,21 +495,12 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str:
# estimate annual subscription revenue
total_amount = 0
if settings.BILLING_ENABLED:
from corporate.lib.stripe import estimate_customer_arr
from corporate.models import Customer
stripe.api_key = get_secret('stripe_secret_key')
estimated_arr = {}
try:
for stripe_customer in stripe.Customer.list(limit=100):
# TODO: could do a select_related to get the realm.string_id, potentially
customer = Customer.objects.filter(stripe_customer_id=stripe_customer.id).first()
if customer is not None:
estimated_arr[customer.realm.string_id] = estimate_customer_arr(stripe_customer)
except stripe.error.StripeError:
pass
from corporate.lib.stripe import estimate_annual_recurring_revenue_by_realm
estimated_arrs = estimate_annual_recurring_revenue_by_realm()
for row in rows:
row['amount'] = estimated_arr.get(row['string_id'], None)
total_amount = sum(estimated_arr.values())
if row['string_id'] in estimated_arrs:
row['amount'] = estimated_arrs[row['string_id']]
total_amount += sum(estimated_arrs.values())

# augment data with realm_minutes
total_hours = 0.0
Expand Down
300 changes: 190 additions & 110 deletions corporate/lib/stripe.py

Large diffs are not rendered by default.

58 changes: 0 additions & 58 deletions corporate/management/commands/setup_stripe.py

This file was deleted.

35 changes: 35 additions & 0 deletions corporate/migrations/0003_customerplan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2018-12-22 21:05
from __future__ import unicode_literals

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('corporate', '0002_customer_default_discount'),
]

operations = [
migrations.CreateModel(
name='CustomerPlan',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('licenses', models.IntegerField()),
('automanage_licenses', models.BooleanField(default=False)),
('charge_automatically', models.BooleanField(default=False)),
('price_per_license', models.IntegerField(null=True)),
('fixed_price', models.IntegerField(null=True)),
('discount', models.DecimalField(decimal_places=4, max_digits=6, null=True)),
('billing_cycle_anchor', models.DateTimeField()),
('billing_schedule', models.SmallIntegerField()),
('billed_through', models.DateTimeField()),
('next_billing_date', models.DateTimeField(db_index=True)),
('tier', models.SmallIntegerField()),
('status', models.SmallIntegerField(default=1)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='corporate.Customer')),
],
),
]
41 changes: 38 additions & 3 deletions corporate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,52 @@
class Customer(models.Model):
realm = models.OneToOneField(Realm, on_delete=models.CASCADE) # type: Realm
stripe_customer_id = models.CharField(max_length=255, unique=True) # type: str
# Becomes True the first time a payment successfully goes through, and never
# goes back to being False
# Deprecated .. delete once everyone is migrated to new billing system
has_billing_relationship = models.BooleanField(default=False) # type: bool
default_discount = models.DecimalField(decimal_places=4, max_digits=7, null=True) # type: Optional[Decimal]

def __str__(self) -> str:
return "<Customer %s %s>" % (self.realm, self.stripe_customer_id)

class CustomerPlan(object):
class CustomerPlan(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE) # type: Customer
licenses = models.IntegerField() # type: int
automanage_licenses = models.BooleanField(default=False) # type: bool
charge_automatically = models.BooleanField(default=False) # type: bool

# Both of these are in cents. Exactly one of price_per_license or
# fixed_price should be set. fixed_price is only for manual deals, and
# can't be set via the self-serve billing system.
price_per_license = models.IntegerField(null=True) # type: Optional[int]
fixed_price = models.IntegerField(null=True) # type: Optional[int]

# A percentage, like 85
discount = models.DecimalField(decimal_places=4, max_digits=6, null=True) # type: Optional[Decimal]

billing_cycle_anchor = models.DateTimeField() # type: datetime.datetime
ANNUAL = 1
MONTHLY = 2
billing_schedule = models.SmallIntegerField() # type: int

# This is like analytic's FillState, but for billing
billed_through = models.DateTimeField() # type: datetime.datetime
next_billing_date = models.DateTimeField(db_index=True) # type: datetime.datetime

STANDARD = 1
PLUS = 2 # not available through self-serve signup
ENTERPRISE = 10
tier = models.SmallIntegerField() # type: int

ACTIVE = 1
ENDED = 2
NEVER_STARTED = 3
# You can only have 1 active subscription at a time
status = models.SmallIntegerField(default=ACTIVE) # type: int

# TODO maybe override setattr to ensure billing_cycle_anchor, etc are immutable

def get_active_plan(customer: Customer) -> Optional[CustomerPlan]:
return CustomerPlan.objects.filter(customer=customer, status=CustomerPlan.ACTIVE).first()

# Everything below here is legacy

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"amount": 64000,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"balance_transaction": "txn_NORMALIZED00000000000001",
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"description": "Upgrade to Zulip Standard, $80.0 x 8",
"destination": null,
"dispute": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "ch_NORMALIZED00000000000001",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 00,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": null,
"receipt_email": "[email protected]",
"receipt_number": null,
"refunded": false,
"refunds": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/charges/ch_NORMALIZED00000000000001/refunds"
},
"review": null,
"shipping": null,
"source": {
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea,",
"address_line1_check": "pass",
"address_line2": null,
"address_state": null,
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_NORMALIZED0001",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "NORMALIZED000001",
"funding": "credit",
"id": "card_NORMALIZED00000000000001",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
},
"source_transfer": null,
"statement_descriptor": "Zulip Standard",
"status": "succeeded",
"transfer_group": null
}
Loading

0 comments on commit e7220fd

Please sign in to comment.