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 583 additions and 196 deletions

View File

@@ -1,6 +1,6 @@
{
"account_balance": 0,
"created": 1542524016,
"created": 1543088274,
"currency": null,
"default_source": null,
"delinquent": false,

View File

@@ -1,6 +1,6 @@
{
"account_balance": 0,
"created": 1542524016,
"created": 1543088274,
"currency": "usd",
"default_source": null,
"delinquent": false,
@@ -28,12 +28,12 @@
{
"application_fee_percent": null,
"billing": "send_invoice",
"billing_cycle_anchor": 1542524017,
"billing_cycle_anchor": 1543088275,
"cancel_at_period_end": false,
"canceled_at": null,
"created": 1542524017,
"current_period_end": 1574060017,
"current_period_start": 1542524017,
"created": 1543088275,
"current_period_end": 1574624275,
"current_period_start": 1543088275,
"customer": "cus_NORMALIZED0001",
"days_until_due": 30,
"default_source": null,
@@ -43,7 +43,7 @@
"items": {
"data": [
{
"created": 1542524017,
"created": 1543088275,
"id": "si_NORMALIZED0001",
"metadata": {},
"object": "subscription_item",
@@ -102,7 +102,7 @@
"usage_type": "licensed"
},
"quantity": 123,
"start": 1542524017,
"start": 1543088275,
"status": "active",
"tax_percent": 0.0,
"trial_end": null,

View File

@@ -1,52 +1,8 @@
{
"account_balance": 0,
"created": 1542524016,
"created": 1543088274,
"currency": "usd",
"default_source": {
"ach_credit_transfer": {
"account_number": "test_c61c954f0ca8",
"bank_name": "TEST BANK",
"fingerprint": "NORMALIZED000001",
"refund_account_holder_name": null,
"refund_account_holder_type": null,
"refund_account_number": null,
"refund_routing_number": null,
"routing_number": "110000000",
"swift_code": "TSTEZ122"
},
"amount": null,
"client_secret": "src_client_secret_DzjdyQ9DrS7xsDJprt8u1VGU",
"created": 1542524018,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"flow": "receiver",
"id": "src_1DXkA2Gh0CmXqmnwvjBfz4VX",
"livemode": false,
"metadata": {},
"object": "source",
"owner": {
"address": null,
"email": "amount_0@stripe.com",
"name": null,
"phone": null,
"verified_address": null,
"verified_email": null,
"verified_name": null,
"verified_phone": null
},
"receiver": {
"address": "110000000-test_c61c954f0ca8",
"amount_charged": 0,
"amount_received": 0,
"amount_returned": 0,
"refund_attributes_method": "email",
"refund_attributes_status": "missing"
},
"statement_descriptor": null,
"status": "pending",
"type": "ach_credit_transfer",
"usage": "reusable"
},
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
@@ -61,56 +17,10 @@
"object": "customer",
"shipping": null,
"sources": {
"data": [
{
"ach_credit_transfer": {
"account_number": "test_c61c954f0ca8",
"bank_name": "TEST BANK",
"fingerprint": "NORMALIZED000001",
"refund_account_holder_name": null,
"refund_account_holder_type": null,
"refund_account_number": null,
"refund_routing_number": null,
"routing_number": "110000000",
"swift_code": "TSTEZ122"
},
"amount": null,
"client_secret": "src_client_secret_DzjdyQ9DrS7xsDJprt8u1VGU",
"created": 1542524018,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"flow": "receiver",
"id": "src_1DXkA2Gh0CmXqmnwvjBfz4VX",
"livemode": false,
"metadata": {},
"object": "source",
"owner": {
"address": null,
"email": "amount_0@stripe.com",
"name": null,
"phone": null,
"verified_address": null,
"verified_email": null,
"verified_name": null,
"verified_phone": null
},
"receiver": {
"address": "110000000-test_c61c954f0ca8",
"amount_charged": 0,
"amount_received": 0,
"amount_returned": 0,
"refund_attributes_method": "email",
"refund_attributes_status": "missing"
},
"statement_descriptor": null,
"status": "pending",
"type": "ach_credit_transfer",
"usage": "reusable"
}
],
"data": [],
"has_more": false,
"object": "list",
"total_count": 1,
"total_count": 0,
"url": "/v1/customers/cus_NORMALIZED0001/sources"
},
"subscriptions": {
@@ -118,12 +28,12 @@
{
"application_fee_percent": null,
"billing": "send_invoice",
"billing_cycle_anchor": 1542524017,
"billing_cycle_anchor": 1543088275,
"cancel_at_period_end": false,
"canceled_at": null,
"created": 1542524017,
"current_period_end": 1574060017,
"current_period_start": 1542524017,
"created": 1543088275,
"current_period_end": 1574624275,
"current_period_start": 1543088275,
"customer": "cus_NORMALIZED0001",
"days_until_due": 30,
"default_source": null,
@@ -133,7 +43,7 @@
"items": {
"data": [
{
"created": 1542524017,
"created": 1543088275,
"id": "si_NORMALIZED0001",
"metadata": {},
"object": "subscription_item",
@@ -192,7 +102,7 @@
"usage_type": "licensed"
},
"quantity": 8,
"start": 1542524018,
"start": 1543088276,
"status": "active",
"tax_percent": 0.0,
"trial_end": null,

View File

@@ -0,0 +1,209 @@
{
"account_balance": 0,
"created": 1543088274,
"currency": "usd",
"default_source": {
"ach_credit_transfer": {
"account_number": "test_1ba48fb022d9",
"bank_name": "TEST BANK",
"fingerprint": "NORMALIZED000001",
"refund_account_holder_name": null,
"refund_account_holder_type": null,
"refund_account_number": null,
"refund_routing_number": null,
"routing_number": "110000000",
"swift_code": "TSTEZ122"
},
"amount": null,
"client_secret": "src_client_secret_E2BJZG7aBRFFRLMIJxGPF0b5",
"created": 1543088277,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"flow": "receiver",
"id": "src_1Da6wzGh0CmXqmnw6RH4WCG7",
"livemode": false,
"metadata": {},
"object": "source",
"owner": {
"address": null,
"email": "amount_0@stripe.com",
"name": null,
"phone": null,
"verified_address": null,
"verified_email": null,
"verified_name": null,
"verified_phone": null
},
"receiver": {
"address": "110000000-test_1ba48fb022d9",
"amount_charged": 0,
"amount_received": 0,
"amount_returned": 0,
"refund_attributes_method": "email",
"refund_attributes_status": "missing"
},
"statement_descriptor": null,
"status": "pending",
"type": "ach_credit_transfer",
"usage": "reusable"
},
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "cus_NORMALIZED0001",
"invoice_prefix": "NORMA01",
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"object": "customer",
"shipping": null,
"sources": {
"data": [
{
"ach_credit_transfer": {
"account_number": "test_1ba48fb022d9",
"bank_name": "TEST BANK",
"fingerprint": "NORMALIZED000001",
"refund_account_holder_name": null,
"refund_account_holder_type": null,
"refund_account_number": null,
"refund_routing_number": null,
"routing_number": "110000000",
"swift_code": "TSTEZ122"
},
"amount": null,
"client_secret": "src_client_secret_E2BJZG7aBRFFRLMIJxGPF0b5",
"created": 1543088277,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"flow": "receiver",
"id": "src_1Da6wzGh0CmXqmnw6RH4WCG7",
"livemode": false,
"metadata": {},
"object": "source",
"owner": {
"address": null,
"email": "amount_0@stripe.com",
"name": null,
"phone": null,
"verified_address": null,
"verified_email": null,
"verified_name": null,
"verified_phone": null
},
"receiver": {
"address": "110000000-test_1ba48fb022d9",
"amount_charged": 0,
"amount_received": 0,
"amount_returned": 0,
"refund_attributes_method": "email",
"refund_attributes_status": "missing"
},
"statement_descriptor": null,
"status": "pending",
"type": "ach_credit_transfer",
"usage": "reusable"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_NORMALIZED0001/sources"
},
"subscriptions": {
"data": [
{
"application_fee_percent": null,
"billing": "send_invoice",
"billing_cycle_anchor": 1543088275,
"cancel_at_period_end": false,
"canceled_at": null,
"created": 1543088275,
"current_period_end": 1574624275,
"current_period_start": 1543088275,
"customer": "cus_NORMALIZED0001",
"days_until_due": 30,
"default_source": null,
"discount": null,
"ended_at": null,
"id": "sub_NORMALIZED0001",
"items": {
"data": [
{
"created": 1543088275,
"id": "si_NORMALIZED0001",
"metadata": {},
"object": "subscription_item",
"plan": {
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1539831971,
"currency": "usd",
"id": "plan_Do3xCvbzO89OsR",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {},
"nickname": "annual",
"object": "plan",
"product": "prod_Do3x494SetTDpx",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 8,
"subscription": "sub_NORMALIZED0001"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/subscription_items?subscription=sub_NORMALIZED0001"
},
"livemode": false,
"metadata": {},
"object": "subscription",
"plan": {
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1539831971,
"currency": "usd",
"id": "plan_Do3xCvbzO89OsR",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {},
"nickname": "annual",
"object": "plan",
"product": "prod_Do3x494SetTDpx",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 8,
"start": 1543088276,
"status": "active",
"tax_percent": 0.0,
"trial_end": null,
"trial_start": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
},
"tax_info": null,
"tax_info_verification": null
}

View File

@@ -13,15 +13,15 @@
"charge": null,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"date": 1542524017,
"date": 1543088275,
"default_source": null,
"description": "",
"discount": null,
"due_date": 1545116017,
"due_date": 1545680275,
"ending_balance": null,
"finalized_at": null,
"hosted_invoice_url": null,
"id": "in_1DXkA1Gh0CmXqmnwNApgkOR1",
"id": "in_1Da6wxGh0CmXqmnwfILsY1kf",
"invoice_pdf": null,
"lines": {
"data": [
@@ -35,8 +35,8 @@
"metadata": {},
"object": "line_item",
"period": {
"end": 1574060017,
"start": 1542524017
"end": 1574624275,
"start": 1543088275
},
"plan": {
"active": true,
@@ -69,7 +69,7 @@
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/in_1DXkA1Gh0CmXqmnwNApgkOR1/lines"
"url": "/v1/invoices/in_1Da6wxGh0CmXqmnwfILsY1kf/lines"
},
"livemode": false,
"metadata": {},
@@ -78,8 +78,8 @@
"object": "invoice",
"paid": false,
"payment_intent": null,
"period_end": 1542524017,
"period_start": 1542524017,
"period_end": 1543088275,
"period_start": 1543088275,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": null,
@@ -89,7 +89,7 @@
"tax": 0,
"tax_percent": 0.0,
"total": 984000,
"webhooks_delivered_at": 1542524017
"webhooks_delivered_at": 1543088275
}
],
"has_more": false,

View File

@@ -0,0 +1,169 @@
{
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"application_fee": null,
"attempt_count": 0,
"attempted": false,
"billing": "send_invoice",
"billing_reason": "upcoming",
"charge": null,
"currency": "usd",
"customer": "cus_NORMALIZED0001",
"date": 1574624275,
"default_source": null,
"description": "",
"discount": null,
"due_date": 1577216275,
"ending_balance": -856000,
"finalized_at": null,
"lines": {
"data": [
{
"amount": -984000,
"currency": "usd",
"description": "Unused time on 123 \u00d7 Zulip Cloud Premium after 24 Nov 2018",
"discountable": false,
"id": "ii_1Da6wyGh0CmXqmnwgou1s917",
"invoice_item": "ii_1Da6wyGh0CmXqmnwgou1s917",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1574624275,
"start": 1543088275
},
"plan": {
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1539831971,
"currency": "usd",
"id": "plan_Do3xCvbzO89OsR",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {},
"nickname": "annual",
"object": "plan",
"product": "prod_Do3x494SetTDpx",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"proration": true,
"quantity": 123,
"subscription": "sub_NORMALIZED0001",
"subscription_item": "si_NORMALIZED0001",
"type": "invoiceitem"
},
{
"amount": 64000,
"currency": "usd",
"description": "Remaining time on 8 \u00d7 Zulip Cloud Premium after 24 Nov 2018",
"discountable": false,
"id": "ii_1Da6wyGh0CmXqmnwKQBPI88u",
"invoice_item": "ii_1Da6wyGh0CmXqmnwKQBPI88u",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1574624275,
"start": 1543088275
},
"plan": {
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1539831971,
"currency": "usd",
"id": "plan_Do3xCvbzO89OsR",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {},
"nickname": "annual",
"object": "plan",
"product": "prod_Do3x494SetTDpx",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"proration": true,
"quantity": 8,
"subscription": "sub_NORMALIZED0001",
"subscription_item": "si_NORMALIZED0001",
"type": "invoiceitem"
},
{
"amount": 64000,
"currency": "usd",
"description": "8 user \u00d7 Zulip Cloud Premium (at $80.00 / year)",
"discountable": true,
"id": "sli_NORMALIZED0001",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1606246675,
"start": 1574624275
},
"plan": {
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1539831971,
"currency": "usd",
"id": "plan_Do3xCvbzO89OsR",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {},
"nickname": "annual",
"object": "plan",
"product": "prod_Do3x494SetTDpx",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"proration": false,
"quantity": 8,
"subscription": "sub_NORMALIZED0001",
"subscription_item": "si_NORMALIZED0001",
"type": "subscription"
}
],
"has_more": false,
"object": "list",
"total_count": 3,
"url": "/v1/invoices/upcoming/lines?customer=cus_NORMALIZED0001"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": null,
"number": "NORMALI-0002",
"object": "invoice",
"paid": false,
"payment_intent": null,
"period_end": 1574624275,
"period_start": 1543088275,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": null,
"status": "draft",
"subscription": "sub_NORMALIZED0001",
"subtotal": -856000,
"tax": 0,
"tax_percent": 0.0,
"total": -856000,
"webhooks_delivered_at": null
}

View File

@@ -1,12 +1,12 @@
{
"application_fee_percent": null,
"billing": "send_invoice",
"billing_cycle_anchor": 1542524017,
"billing_cycle_anchor": 1543088275,
"cancel_at_period_end": false,
"canceled_at": null,
"created": 1542524017,
"current_period_end": 1574060017,
"current_period_start": 1542524017,
"created": 1543088275,
"current_period_end": 1574624275,
"current_period_start": 1543088275,
"customer": "cus_NORMALIZED0001",
"days_until_due": 30,
"default_source": null,
@@ -16,7 +16,7 @@
"items": {
"data": [
{
"created": 1542524017,
"created": 1543088275,
"id": "si_NORMALIZED0001",
"metadata": {},
"object": "subscription_item",
@@ -75,7 +75,7 @@
"usage_type": "licensed"
},
"quantity": 123,
"start": 1542524017,
"start": 1543088275,
"status": "active",
"tax_percent": 0.0,
"trial_end": null,

View File

@@ -1,12 +1,12 @@
{
"application_fee_percent": null,
"billing": "send_invoice",
"billing_cycle_anchor": 1542524017,
"billing_cycle_anchor": 1543088275,
"cancel_at_period_end": false,
"canceled_at": null,
"created": 1542524017,
"current_period_end": 1574060017,
"current_period_start": 1542524017,
"created": 1543088275,
"current_period_end": 1574624275,
"current_period_start": 1543088275,
"customer": "cus_NORMALIZED0001",
"days_until_due": 30,
"default_source": null,
@@ -16,7 +16,7 @@
"items": {
"data": [
{
"created": 1542524017,
"created": 1543088275,
"id": "si_NORMALIZED0001",
"metadata": {},
"object": "subscription_item",
@@ -75,7 +75,7 @@
"usage_type": "licensed"
},
"quantity": 8,
"start": 1542524018,
"start": 1543088276,
"status": "active",
"tax_percent": 0.0,
"trial_end": null,

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,12 +18,20 @@
<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>
<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 }}">
@@ -55,8 +63,7 @@
<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.
for <b>{{ seat_count }}</b> users.
</p>
<script src="https://checkout.stripe.com/checkout.js" class="stripe-button"
data-key="{{ publishable_key }}"
@@ -80,7 +87,45 @@
});
</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 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>
We're happy to help!
Contact <a href="mailto:support@zulipchat.com">support@zulipchat.com</a>
for any billing-related questions.
</p>
</div>
</div>
</div>
</div>