billing: Add frontend for upgrading by invoice.

This commit is contained in:
Vishnu Ks 2018-11-18 01:18:14 -08:00 committed by Rishi Gupta
parent 6afbc2726f
commit 189e5e1fbd
13 changed files with 166 additions and 67 deletions

View File

@ -276,7 +276,7 @@ class StripeTest(ZulipTestCase):
user = self.example_user("hamlet")
self.login(user.email)
response = self.client_get("/upgrade/")
self.assert_in_success_response(['We can also bill by invoice'], response)
self.assert_in_success_response(['Pay annually'], response)
self.assertFalse(user.realm.has_seat_based_plan)
self.assertNotEqual(user.realm.plan_type, Realm.STANDARD)
self.assertFalse(Customer.objects.filter(realm=user.realm).exists())
@ -331,9 +331,9 @@ class StripeTest(ZulipTestCase):
# Check /billing has the correct information
response = self.client_get("/billing/")
self.assert_not_in_success_response(['We can also bill by invoice'], response)
self.assert_not_in_success_response(['Pay annually'], response)
for substring in ['Your plan will renew on', '$%s.00' % (80 * self.quantity,),
'Card ending in 4242']:
'Card ending in 4242', 'Update card']:
self.assert_in_response(substring, response)
@mock_stripe("Token.create", "Invoice.upcoming", "Customer.retrieve", "Customer.create", "Subscription.create")
@ -474,6 +474,7 @@ class StripeTest(ZulipTestCase):
self.assertEqual(response['error_description'], 'tampered seat count')
def test_upgrade_with_tampered_plan(self) -> None:
# Test with an unknown plan
self.login(self.example_email("hamlet"))
response = self.client_post("/upgrade/", {
'stripeToken': self.token,
@ -484,6 +485,16 @@ class StripeTest(ZulipTestCase):
})
self.assert_in_success_response(["Upgrade to Zulip Standard"], response)
self.assertEqual(response['error_description'], 'tampered plan')
# Test with a plan that's valid, but not if you're paying by invoice
response = self.client_post("/upgrade/", {
'invoiced_seat_count': 123,
'signed_seat_count': self.signed_seat_count,
'salt': self.salt,
'plan': Plan.CLOUD_MONTHLY,
'billing_modality': 'send_invoice',
})
self.assert_in_success_response(["Upgrade to Zulip Standard"], response)
self.assertEqual(response['error_description'], 'tampered plan')
def test_upgrade_with_insufficient_invoiced_seat_count(self) -> None:
self.login(self.example_email("hamlet"))
@ -510,6 +521,16 @@ class StripeTest(ZulipTestCase):
self.assert_in_success_response(["Upgrade to Zulip Standard",
"at least %d users" % (self.quantity,)], response)
self.assertEqual(response['error_description'], 'lowball seat count')
# Test not setting an invoiced_seat_count
response = self.client_post("/upgrade/", {
'signed_seat_count': self.signed_seat_count,
'salt': self.salt,
'plan': Plan.CLOUD_ANNUAL,
'billing_modality': 'send_invoice',
})
self.assert_in_success_response(["Upgrade to Zulip Standard",
"at least %d users" % (MIN_INVOICED_SEAT_COUNT,)], response)
self.assertEqual(response['error_description'], 'lowball seat count')
@patch("corporate.lib.stripe.billing_logger.error")
def test_upgrade_with_uncaught_exception(self, mock1: Mock) -> None:
@ -527,8 +548,8 @@ class StripeTest(ZulipTestCase):
self.assertEqual(response['error_description'], 'uncaught exception during upgrade')
@mock_stripe("Customer.create", "Subscription.create", "Subscription.save",
"Customer.retrieve", "Invoice.list")
def test_upgrade_billing_by_invoice(self, mock5: Mock, mock4: Mock, mock3: Mock,
"Customer.retrieve", "Invoice.list", "Invoice.upcoming")
def test_upgrade_billing_by_invoice(self, mock6: Mock, mock5: Mock, mock4: Mock, mock3: Mock,
mock2: Mock, mock1: Mock) -> None:
user = self.example_user("hamlet")
self.login(user.email)
@ -590,6 +611,12 @@ class StripeTest(ZulipTestCase):
event_type=RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET).values_list('extra_data', flat=True).first()),
{'quantity': self.quantity})
# Check /billing has the correct information
response = self.client_get("/billing/")
self.assert_not_in_success_response(['Pay annually', 'Update card'], response)
for substring in ['Your plan will renew on', 'Billed by invoice']:
self.assert_in_response(substring, response)
@patch("stripe.Customer.retrieve", side_effect=mock_customer_with_subscription)
def test_redirect_for_billing_home(self, mock_customer_with_subscription: Mock) -> None:
user = self.example_user("iago")
@ -612,7 +639,7 @@ class StripeTest(ZulipTestCase):
with patch("corporate.views.upcoming_invoice_total", return_value=0):
response = self.client_get("/billing/")
self.assert_not_in_success_response(['We can also bill by invoice'], response)
self.assert_not_in_success_response(['Pay annually'], response)
self.assert_in_response('Your plan will renew on', response)
def test_get_seat_count(self) -> None:

View File

@ -20,14 +20,19 @@ from corporate.lib.stripe import STRIPE_PUBLISHABLE_KEY, \
stripe_get_customer, upcoming_invoice_total, get_seat_count, \
extract_current_subscription, process_initial_upgrade, sign_string, \
unsign_string, BillingError, process_downgrade, do_replace_payment_source, \
MIN_INVOICED_SEAT_COUNT
MIN_INVOICED_SEAT_COUNT, DEFAULT_INVOICE_DAYS_UNTIL_DUE
from corporate.models import Customer, Plan
billing_logger = logging.getLogger('corporate.stripe')
def unsign_and_check_upgrade_parameters(user: UserProfile, plan_nickname: str,
signed_seat_count: str, salt: str) -> Tuple[Plan, int]:
if plan_nickname not in [Plan.CLOUD_ANNUAL, Plan.CLOUD_MONTHLY]:
signed_seat_count: str, salt: str,
billing_modality: str) -> Tuple[Plan, int]:
provided_plans = {
'charge_automatically': [Plan.CLOUD_ANNUAL, Plan.CLOUD_MONTHLY],
'send_invoice': [Plan.CLOUD_ANNUAL],
}
if plan_nickname not in provided_plans[billing_modality]:
billing_logger.warning("Tampered plan during realm upgrade. user: %s, realm: %s (%s)."
% (user.id, user.realm.id, user.realm.string_id))
raise BillingError('tampered plan', BillingError.CONTACT_SUPPORT)
@ -76,14 +81,19 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse:
if request.method == 'POST':
try:
plan, seat_count = unsign_and_check_upgrade_parameters(
user, request.POST['plan'], request.POST['signed_seat_count'], request.POST['salt'])
user, request.POST['plan'], request.POST['signed_seat_count'], request.POST['salt'],
request.POST['billing_modality'])
if request.POST['billing_modality'] == 'send_invoice':
try:
invoiced_seat_count = int(request.POST['invoiced_seat_count'])
except (KeyError, ValueError):
invoiced_seat_count = -1
min_required_seat_count = max(seat_count, MIN_INVOICED_SEAT_COUNT)
if int(request.POST['invoiced_seat_count']) < min_required_seat_count:
if invoiced_seat_count < min_required_seat_count:
raise BillingError(
'lowball seat count',
"You must invoice for at least %d users." % (min_required_seat_count,))
seat_count = int(request.POST['invoiced_seat_count'])
seat_count = invoiced_seat_count
process_initial_upgrade(user, plan, seat_count, request.POST.get('stripeToken', None))
except BillingError as e:
error_message = e.message
@ -103,6 +113,8 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse:
'seat_count': seat_count,
'signed_seat_count': signed_seat_count,
'salt': salt,
'min_seat_count_for_invoice': max(seat_count, MIN_INVOICED_SEAT_COUNT),
'default_invoice_days_until_due': DEFAULT_INVOICE_DAYS_UNTIL_DUE,
'plan': "Zulip Standard",
'nickname_monthly': Plan.CLOUD_MONTHLY,
'nickname_annual': Plan.CLOUD_ANNUAL,
@ -140,6 +152,7 @@ def billing_home(request: HttpRequest) -> HttpResponse:
if stripe_customer.account_balance < 0: # nocoverage
context.update({'account_credits': '{:,.2f}'.format(-stripe_customer.account_balance / 100.)})
billed_by_invoice = False
subscription = extract_current_subscription(stripe_customer)
if subscription:
plan_name = PLAN_NAMES[Plan.objects.get(stripe_plan_id=subscription.plan.id).nickname]
@ -148,6 +161,8 @@ def billing_home(request: HttpRequest) -> HttpResponse:
renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format(
dt=timestamp_to_datetime(subscription.current_period_end))
renewal_amount = upcoming_invoice_total(customer.stripe_customer_id)
if subscription.billing == 'send_invoice':
billed_by_invoice = True
# Can only get here by subscribing and then downgrading. We don't support downgrading
# yet, but keeping this code here since we will soon.
else: # nocoverage
@ -162,6 +177,7 @@ def billing_home(request: HttpRequest) -> HttpResponse:
'renewal_date': renewal_date,
'renewal_amount': '{:,.2f}'.format(renewal_amount / 100.),
'payment_method': payment_method_string(stripe_customer),
'billed_by_invoice': billed_by_invoice,
'publishable_key': STRIPE_PUBLISHABLE_KEY,
'stripe_email': stripe_customer.email,
})

View File

@ -152,6 +152,15 @@
pointer-events: none;
}
.invoice-button {
font-size: 17px;
font-weight: 700 !important;
}
#invoiced_seat_count {
width: 50px;
}
#error-message-box {
margin-top: 10px;
font-weight: 600;

View File

@ -40,10 +40,12 @@
</div>
<div class="tab-pane" id="payment-method" data-email="{{stripe_email}}" data-csrf="{{csrf_token}}" data-key="{{publishable_key}}">
<div id="payment-section">
<p>Your current payment method is <strong>{{ payment_method }}</strong>.</p>
<p>Current payment method: <strong>{{ payment_method }}</strong></p>
{% if not billed_by_invoice %}
<button id="update-card-button" class="stripe-button-el">
<span id="update-card-button-span">Update card</span>
</button>
{% endif %}
</div>
<div id="loading-section">
<div class="updating-card-logo">

View File

@ -18,69 +18,114 @@
<div class="page-content">
<div class="main">
<h1>{% trans %}Upgrade to {{ plan }}{% endtrans %}</h1>
{% if error_message %}
<div class="alert alert-danger" id="error-message-box">
<div class="alert alert-danger" id="upgrade-error-message-box">
{{ error_message }}
</div>
{% endif %}
<h1>{% trans %}Upgrade to {{ plan }}{% endtrans %}</h1>
<form method="post">
{{ csrf_input }}
<input type="hidden" name="seat_count" value="{{ seat_count }}">
<input type="hidden" name="signed_seat_count" value="{{ signed_seat_count }}">
<input type="hidden" name="salt" value="{{ salt }}">
<input type="hidden" name="billing_modality" value="charge_automatically">
<div class="payment-schedule">
<h3>{{ _("Payment schedule") }}</h3>
<label>
<input type="radio" name="plan" value="{{ nickname_annual }}" data-amount="{{ cloud_annual_price }}" checked />
<div class="box">
<div class="schedule-time annually">{{ _("Pay annually") }}</div>
<div class="schedule-amount">
${{ cloud_annual_price_per_month }}/user/month
<div class="schedule-amount-2">
(${{ cloud_annual_price }}/user/year)
<ul class="nav nav-tabs" id="upgrade-tabs">
<li class="active"><a data-toggle="tab" href="#autopay">Pay automatically</a></li>
<li><a data-toggle="tab" href="#invoice">Pay by invoice</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="autopay">
<form method="post">
{{ csrf_input }}
<input type="hidden" name="seat_count" value="{{ seat_count }}">
<input type="hidden" name="signed_seat_count" value="{{ signed_seat_count }}">
<input type="hidden" name="salt" value="{{ salt }}">
<input type="hidden" name="billing_modality" value="charge_automatically">
<div class="payment-schedule">
<h3>{{ _("Payment schedule") }}</h3>
<label>
<input type="radio" name="plan" value="{{ nickname_annual }}" data-amount="{{ cloud_annual_price }}" checked />
<div class="box">
<div class="schedule-time annually">{{ _("Pay annually") }}</div>
<div class="schedule-amount">
${{ cloud_annual_price_per_month }}/user/month
<div class="schedule-amount-2">
(${{ cloud_annual_price }}/user/year)
</div>
</div>
</div>
</div>
</label>
<label>
<input type="radio" name="plan" value="{{ nickname_monthly }}" data-amount="{{ cloud_monthly_price }}" />
<div class="box">
<div class="schedule-time">{{ _("Pay monthly") }}</div>
<div class="schedule-amount">${{ cloud_monthly_price }}/user/month</div>
</div>
</label>
</div>
</label>
<label>
<input type="radio" name="plan" value="{{ nickname_monthly }}" data-amount="{{ cloud_monthly_price }}" />
<div class="box">
<div class="schedule-time">{{ _("Pay monthly") }}</div>
<div class="schedule-amount">${{ cloud_monthly_price }}/user/month</div>
</div>
</label>
<p>
You&rsquo;ll initially be charged
<b>$<span id="charged_amount">{{ cloud_annual_price * seat_count }}</span></b>
for <b>{{ seat_count }}</b> users.
</p>
<script src="https://checkout.stripe.com/checkout.js" class="stripe-button"
data-key="{{ publishable_key }}"
data-image="/static/images/logo/zulip-icon-128x128.png"
data-name="Zulip"
data-description="Zulip Cloud Standard"
data-locale="auto"
data-zip-code="true"
data-billing-address="true"
data-panel-label="Make payment"
data-email="{{ email }}"
data-label="{{ _('Add card') }}"
data-allow-remember-me="false"
>
</script>
<script>
const seat_count = parseInt($('input[name=seat_count]').val());
$("input[type=radio][name=plan").change(function () {
var charged_amount = parseInt($(this).data("amount")) * seat_count;
$("#charged_amount").html(charged_amount);
});
</script>
</form>
</div>
<div class="tab-pane" id="invoice">
<form method="post">
{{ csrf_input }}
<input type="hidden" name="signed_seat_count" value="{{ signed_seat_count }}">
<input type="hidden" name="salt" value="{{ salt }}">
<input type="hidden" name="billing_modality" value="send_invoice">
<div class="payment-schedule">
<h3>{{ _("Payment schedule") }}</h3>
<label>
<input type="radio" name="plan" value="{{ nickname_annual }}" data-amount="{{ cloud_annual_price }}" checked />
<div class="box">
<div class="schedule-time annually">{{ _("Pay annually") }}</div>
<div class="schedule-amount">
${{ cloud_annual_price_per_month }}/user/month
<div class="schedule-amount-2">
(${{ cloud_annual_price }}/user/year)
</div>
</div>
</div>
</label>
</div>
<h4>Number of users</h4>
<input type="text" id="invoiced_seat_count" name="invoiced_seat_count" value=""/>
<p>
We'll send you an invoice by email. You
must invoice for at least {{ min_seat_count_for_invoice }} users.
</p>
<button type="submit" class="stripe-button-el invoice-button">Buy Standard</button>
</form>
</div>
</div>
<div class="support-link">
<p>
You&rsquo;ll initially be charged
<b>$<span id="charged_amount">{{ cloud_annual_price * seat_count }}</span></b>
for <b>{{ seat_count }}</b> users. Well automatically charge you
when new users are added, or give you credit when users are deactivated.
We're happy to help!
Contact <a href="mailto:support@zulipchat.com">support@zulipchat.com</a>
for any billing-related questions.
</p>
<script src="https://checkout.stripe.com/checkout.js" class="stripe-button"
data-key="{{ publishable_key }}"
data-image="/static/images/logo/zulip-icon-128x128.png"
data-name="Zulip"
data-description="Zulip Cloud Standard"
data-locale="auto"
data-zip-code="true"
data-billing-address="true"
data-panel-label="Make payment"
data-email="{{ email }}"
data-label="{{ _('Add card') }}"
data-allow-remember-me="false"
>
</script>
<script>
const seat_count = parseInt($('input[name=seat_count]').val());
$("input[type=radio][name=plan").change(function () {
var charged_amount = parseInt($(this).data("amount")) * seat_count;
$("#charged_amount").html(charged_amount);
});
</script>
</form>
<p>We can also bill by invoice for annual contracts over $2,000. Contact <a href="mailto:support@zulipchat.com">support@zulipchat.com</a> to pay by invoice or for any other billing questions.</p>
</div>
</div>
</div>
</div>