mirror of https://github.com/zulip/zulip.git
billing: Add frontend for upgrading by invoice.
This commit is contained in:
parent
6afbc2726f
commit
189e5e1fbd
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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’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’ll initially be charged
|
||||
<b>$<span id="charged_amount">{{ cloud_annual_price * seat_count }}</span></b>
|
||||
for <b>{{ seat_count }}</b> users. We’ll 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>
|
||||
|
|
Loading…
Reference in New Issue