From 01c3a139220867ce83e21253e70a6037a5f4734d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Tue, 9 Jan 2018 13:31:43 +0200 Subject: [PATCH] Billing overhaul. --- .../0003_subscription_address_id.py | 20 + hc/payments/models.py | 150 +++++- hc/payments/tests/test_address.py | 69 +++ ...est_billing.py => test_billing_history.py} | 16 +- hc/payments/tests/test_cancel_plan.py | 38 -- hc/payments/tests/test_get_client_token.py | 2 +- hc/payments/tests/test_invoice.py | 56 --- hc/payments/tests/test_payment_method.py | 94 ++++ hc/payments/tests/test_pdf_invoice.py | 6 +- .../{test_create_plan.py => test_set_plan.py} | 96 ++-- hc/payments/urls.py | 30 +- hc/payments/views.py | 216 ++++----- static/css/pricing.css | 4 - static/css/profile.css | 21 + static/js/billing.js | 46 ++ static/js/pricing.js | 51 +-- templates/accounts/badges.html | 3 + templates/accounts/billing.html | 428 ++++++++++++++++++ templates/accounts/notifications.html | 3 + templates/accounts/profile.html | 3 + templates/payments/address.html | 41 ++ templates/payments/address_plain.html | 8 + templates/payments/billing.html | 98 ---- templates/payments/billing_history.html | 42 ++ templates/payments/countries.html | 250 ++++++++++ templates/payments/payment_method.html | 14 + templates/payments/pricing.html | 392 ++++------------ 27 files changed, 1418 insertions(+), 779 deletions(-) create mode 100644 hc/payments/migrations/0003_subscription_address_id.py create mode 100644 hc/payments/tests/test_address.py rename hc/payments/tests/{test_billing.py => test_billing_history.py} (56%) delete mode 100644 hc/payments/tests/test_cancel_plan.py delete mode 100644 hc/payments/tests/test_invoice.py create mode 100644 hc/payments/tests/test_payment_method.py rename hc/payments/tests/{test_create_plan.py => test_set_plan.py} (57%) create mode 100644 static/js/billing.js create mode 100644 templates/accounts/billing.html create mode 100644 templates/payments/address.html create mode 100644 templates/payments/address_plain.html delete mode 100644 templates/payments/billing.html create mode 100644 templates/payments/billing_history.html create mode 100644 templates/payments/countries.html create mode 100644 templates/payments/payment_method.html diff --git a/hc/payments/migrations/0003_subscription_address_id.py b/hc/payments/migrations/0003_subscription_address_id.py new file mode 100644 index 00000000..1df31f9d --- /dev/null +++ b/hc/payments/migrations/0003_subscription_address_id.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2018-01-07 16:29 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0002_subscription_plan_id'), + ] + + operations = [ + migrations.AddField( + model_name='subscription', + name='address_id', + field=models.CharField(blank=True, max_length=2), + ), + ] diff --git a/hc/payments/models.py b/hc/payments/models.py index ad55a9dd..35e8104c 100644 --- a/hc/payments/models.py +++ b/hc/payments/models.py @@ -10,6 +10,11 @@ else: braintree = None +ADDRESS_KEYS = ("first_name", "last_name", "company", "street_address", + "extended_address", "locality", "region", "postal_code", + "country_code_alpha2") + + class SubscriptionManager(models.Manager): def for_user(self, user): @@ -23,6 +28,7 @@ class Subscription(models.Model): payment_method_token = models.CharField(max_length=35, blank=True) subscription_id = models.CharField(max_length=10, blank=True) plan_id = models.CharField(max_length=10, blank=True) + address_id = models.CharField(max_length=2, blank=True) objects = SubscriptionManager() @@ -46,11 +52,99 @@ class Subscription(models.Model): raise NotImplementedError("Unexpected plan: %s" % self.plan_id) - def _get_braintree_payment_method(self): + @property + def payment_method(self): + if not self.payment_method_token: + return None + if not hasattr(self, "_pm"): self._pm = braintree.PaymentMethod.find(self.payment_method_token) return self._pm + def _get_braintree_subscription(self): + if not hasattr(self, "_sub"): + self._sub = braintree.Subscription.find(self.subscription_id) + return self._sub + + def get_client_token(self): + return braintree.ClientToken.generate({ + "customer_id": self.customer_id + }) + + def update_payment_method(self, nonce): + # Create customer record if it does not exist: + if not self.customer_id: + result = braintree.Customer.create({ + "email": self.user.email + }) + if not result.is_success: + return result + + self.customer_id = result.customer.id + self.save() + + # Create payment method + result = braintree.PaymentMethod.create({ + "customer_id": self.customer_id, + "payment_method_nonce": nonce, + "options": {"make_default": True} + }) + + if not result.is_success: + return result + + self.payment_method_token = result.payment_method.token + self.save() + + # Update an existing subscription to use this payment method + if self.subscription_id: + result = braintree.Subscription.update(self.subscription_id, { + "payment_method_token": self.payment_method_token + }) + + if not result.is_success: + return result + + def update_address(self, post_data): + # Create customer record if it does not exist: + if not self.customer_id: + result = braintree.Customer.create({ + "email": self.user.email + }) + if not result.is_success: + return result + + self.customer_id = result.customer.id + self.save() + + payload = {key: str(post_data.get(key)) for key in ADDRESS_KEYS} + if self.address_id: + result = braintree.Address.update(self.customer_id, + self.address_id, + payload) + else: + payload["customer_id"] = self.customer_id + result = braintree.Address.create(payload) + if result.is_success: + self.address_id = result.address.id + self.save() + + if not result.is_success: + return result + + def setup(self, plan_id): + result = braintree.Subscription.create({ + "payment_method_token": self.payment_method_token, + "plan_id": plan_id + }) + + if result.is_success: + self.subscription_id = result.subscription.id + self.plan_id = plan_id + self.save() + + return result + def cancel(self): if self.subscription_id: braintree.Subscription.cancel(self.subscription_id) @@ -59,22 +153,42 @@ class Subscription(models.Model): self.plan_id = "" self.save() - def pm_is_credit_card(self): - return isinstance(self._get_braintree_payment_method(), - braintree.credit_card.CreditCard) + def pm_is_card(self): + pm = self.payment_method + return isinstance(pm, braintree.credit_card.CreditCard) def pm_is_paypal(self): - return isinstance(self._get_braintree_payment_method(), - braintree.paypal_account.PayPalAccount) - - def card_type(self): - o = self._get_braintree_payment_method() - return o.card_type - - def last_4(self): - o = self._get_braintree_payment_method() - return o.last_4 - - def paypal_email(self): - o = self._get_braintree_payment_method() - return o.email + pm = self.payment_method + return isinstance(pm, braintree.paypal_account.PayPalAccount) + + def next_billing_date(self): + o = self._get_braintree_subscription() + return o.next_billing_date + + @property + def address(self): + if not hasattr(self, "_address"): + try: + self._address = braintree.Address.find(self.customer_id, + self.address_id) + except braintree.exceptions.NotFoundError: + self._address = None + + return self._address + + @property + def transactions(self): + if not hasattr(self, "_tx"): + if not self.customer_id: + self._tx = [] + else: + self._tx = list(braintree.Transaction.search(braintree.TransactionSearch.customer_id == self.customer_id)) + + return self._tx + + def get_transaction(self, transaction_id): + tx = braintree.Transaction.find(transaction_id) + if tx.customer_details.id != self.customer_id: + return None + + return tx diff --git a/hc/payments/tests/test_address.py b/hc/payments/tests/test_address.py new file mode 100644 index 00000000..2901860c --- /dev/null +++ b/hc/payments/tests/test_address.py @@ -0,0 +1,69 @@ +from mock import patch + +from hc.payments.models import Subscription +from hc.test import BaseTestCase + + +class AddressTestCase(BaseTestCase): + + @patch("hc.payments.models.braintree") + def test_it_retrieves_address(self, mock): + mock.Address.find.return_value = {"company": "FooCo"} + + self.sub = Subscription(user=self.alice) + self.sub.address_id = "aa" + self.sub.save() + + self.client.login(username="alice@example.org", password="password") + r = self.client.get("/accounts/profile/billing/address/") + self.assertContains(r, "FooCo") + + @patch("hc.payments.models.braintree") + def test_it_creates_address(self, mock): + mock.Address.create.return_value.is_success = True + mock.Address.create.return_value.address.id = "bb" + + self.sub = Subscription(user=self.alice) + self.sub.customer_id = "test-customer" + self.sub.save() + + self.client.login(username="alice@example.org", password="password") + form = {"company": "BarCo"} + r = self.client.post("/accounts/profile/billing/address/", form) + + self.assertRedirects(r, "/accounts/profile/billing/") + self.sub.refresh_from_db() + self.assertEqual(self.sub.address_id, "bb") + + @patch("hc.payments.models.braintree") + def test_it_updates_address(self, mock): + mock.Address.update.return_value.is_success = True + + self.sub = Subscription(user=self.alice) + self.sub.customer_id = "test-customer" + self.sub.address_id = "aa" + self.sub.save() + + self.client.login(username="alice@example.org", password="password") + form = {"company": "BarCo"} + r = self.client.post("/accounts/profile/billing/address/", form) + + self.assertRedirects(r, "/accounts/profile/billing/") + + @patch("hc.payments.models.braintree") + def test_it_creates_customer(self, mock): + mock.Address.create.return_value.is_success = True + mock.Address.create.return_value.address.id = "bb" + + mock.Customer.create.return_value.is_success = True + mock.Customer.create.return_value.customer.id = "test-customer-id" + + self.sub = Subscription(user=self.alice) + self.sub.save() + + self.client.login(username="alice@example.org", password="password") + form = {"company": "BarCo"} + self.client.post("/accounts/profile/billing/address/", form) + + self.sub.refresh_from_db() + self.assertEqual(self.sub.customer_id, "test-customer-id") diff --git a/hc/payments/tests/test_billing.py b/hc/payments/tests/test_billing_history.py similarity index 56% rename from hc/payments/tests/test_billing.py rename to hc/payments/tests/test_billing_history.py index 906ffb82..e6c7a1ec 100644 --- a/hc/payments/tests/test_billing.py +++ b/hc/payments/tests/test_billing_history.py @@ -4,16 +4,16 @@ from hc.payments.models import Subscription from hc.test import BaseTestCase -class BillingTestCase(BaseTestCase): +class BillingHistoryTestCase(BaseTestCase): def setUp(self): - super(BillingTestCase, self).setUp() + super(BillingHistoryTestCase, self).setUp() self.sub = Subscription(user=self.alice) self.sub.subscription_id = "test-id" self.sub.customer_id = "test-customer-id" self.sub.save() - @patch("hc.payments.views.braintree") + @patch("hc.payments.models.braintree") def test_it_works(self, mock_braintree): m1 = Mock(id="abc123", amount=123) @@ -21,14 +21,6 @@ class BillingTestCase(BaseTestCase): mock_braintree.Transaction.search.return_value = [m1, m2] self.client.login(username="alice@example.org", password="password") - r = self.client.get("/billing/") + r = self.client.get("/accounts/profile/billing/history/") self.assertContains(r, "123") self.assertContains(r, "def456") - - def test_it_saves_company_details(self): - self.client.login(username="alice@example.org", password="password") - r = self.client.post("/billing/", {"bill_to": "foo\nbar"}) - - self.assertEqual(r.status_code, 302) - self.profile.refresh_from_db() - self.assertEqual(self.profile.bill_to, "foo\nbar") diff --git a/hc/payments/tests/test_cancel_plan.py b/hc/payments/tests/test_cancel_plan.py deleted file mode 100644 index 5bff942b..00000000 --- a/hc/payments/tests/test_cancel_plan.py +++ /dev/null @@ -1,38 +0,0 @@ -from mock import patch - -from hc.accounts.models import Profile -from hc.payments.models import Subscription -from hc.test import BaseTestCase - - -class CancelPlanTestCase(BaseTestCase): - - def setUp(self): - super(CancelPlanTestCase, self).setUp() - self.sub = Subscription(user=self.alice) - self.sub.subscription_id = "test-id" - self.sub.plan_id = "P5" - self.sub.save() - - self.profile.ping_log_limit = 1000 - self.profile.check_limit = 500 - self.profile.sms_limit = 50 - self.profile.save() - - @patch("hc.payments.models.braintree") - def test_it_works(self, mock_braintree): - - self.client.login(username="alice@example.org", password="password") - r = self.client.post("/pricing/cancel_plan/") - self.assertRedirects(r, "/pricing/") - - self.sub.refresh_from_db() - self.assertEqual(self.sub.subscription_id, "") - self.assertEqual(self.sub.plan_id, "") - - # User's profile should have standard limits - profile = Profile.objects.get(user=self.alice) - self.assertEqual(profile.ping_log_limit, 100) - self.assertEqual(profile.check_limit, 20) - self.assertEqual(profile.team_limit, 2) - self.assertEqual(profile.sms_limit, 0) diff --git a/hc/payments/tests/test_get_client_token.py b/hc/payments/tests/test_get_client_token.py index 34491e78..3bd11d03 100644 --- a/hc/payments/tests/test_get_client_token.py +++ b/hc/payments/tests/test_get_client_token.py @@ -6,7 +6,7 @@ from hc.test import BaseTestCase class GetClientTokenTestCase(BaseTestCase): - @patch("hc.payments.views.braintree") + @patch("hc.payments.models.braintree") def test_it_works(self, mock_braintree): mock_braintree.ClientToken.generate.return_value = "test-token" self.client.login(username="alice@example.org", password="password") diff --git a/hc/payments/tests/test_invoice.py b/hc/payments/tests/test_invoice.py deleted file mode 100644 index cb0e3dae..00000000 --- a/hc/payments/tests/test_invoice.py +++ /dev/null @@ -1,56 +0,0 @@ -from mock import Mock, patch - -from hc.payments.models import Subscription -from hc.test import BaseTestCase - - -class InvoiceTestCase(BaseTestCase): - - def setUp(self): - super(InvoiceTestCase, self).setUp() - self.sub = Subscription(user=self.alice) - self.sub.subscription_id = "test-id" - self.sub.customer_id = "test-customer-id" - self.sub.save() - - @patch("hc.payments.views.braintree") - def test_it_works(self, mock_braintree): - - tx = Mock() - tx.id = "abc123" - tx.customer_details.id = "test-customer-id" - tx.created_at = None - mock_braintree.Transaction.find.return_value = tx - - self.client.login(username="alice@example.org", password="password") - r = self.client.get("/invoice/abc123/") - self.assertContains(r, "ABC123") # tx.id in uppercase - self.assertContains(r, "alice@example.org") # bill to - - @patch("hc.payments.views.braintree") - def test_it_checks_customer_id(self, mock_braintree): - - tx = Mock() - tx.id = "abc123" - tx.customer_details.id = "test-another-customer-id" - tx.created_at = None - mock_braintree.Transaction.find.return_value = tx - - self.client.login(username="alice@example.org", password="password") - r = self.client.get("/invoice/abc123/") - self.assertEqual(r.status_code, 403) - - @patch("hc.payments.views.braintree") - def test_it_shows_company_data(self, mock_braintree): - self.profile.bill_to = "Alice and Partners" - self.profile.save() - - tx = Mock() - tx.id = "abc123" - tx.customer_details.id = "test-customer-id" - tx.created_at = None - mock_braintree.Transaction.find.return_value = tx - - self.client.login(username="alice@example.org", password="password") - r = self.client.get("/invoice/abc123/") - self.assertContains(r, "Alice and Partners") diff --git a/hc/payments/tests/test_payment_method.py b/hc/payments/tests/test_payment_method.py new file mode 100644 index 00000000..8720746a --- /dev/null +++ b/hc/payments/tests/test_payment_method.py @@ -0,0 +1,94 @@ +from mock import patch + +from hc.payments.models import Subscription +from hc.test import BaseTestCase + + +class UpdatePaymentMethodTestCase(BaseTestCase): + + def _setup_mock(self, mock): + """ Set up Braintree calls that the controller will use. """ + + mock.PaymentMethod.create.return_value.is_success = True + mock.PaymentMethod.create.return_value.payment_method.token = "fake" + + @patch("hc.payments.models.braintree") + def test_it_retrieves_paypal(self, mock): + self._setup_mock(mock) + + mock.paypal_account.PayPalAccount = dict + mock.credit_card.CreditCard = list + mock.PaymentMethod.find.return_value = {"email": "foo@example.org"} + + self.sub = Subscription(user=self.alice) + self.sub.payment_method_token = "fake-token" + self.sub.save() + + self.client.login(username="alice@example.org", password="password") + r = self.client.get("/accounts/profile/billing/payment_method/") + self.assertContains(r, "foo@example.org") + + @patch("hc.payments.models.braintree") + def test_it_retrieves_cc(self, mock): + self._setup_mock(mock) + + mock.paypal_account.PayPalAccount = list + mock.credit_card.CreditCard = dict + mock.PaymentMethod.find.return_value = {"masked_number": "1***2"} + + self.sub = Subscription(user=self.alice) + self.sub.payment_method_token = "fake-token" + self.sub.save() + + self.client.login(username="alice@example.org", password="password") + r = self.client.get("/accounts/profile/billing/payment_method/") + self.assertContains(r, "1***2") + + @patch("hc.payments.models.braintree") + def test_it_creates_payment_method(self, mock): + self._setup_mock(mock) + + self.sub = Subscription(user=self.alice) + self.sub.customer_id = "test-customer" + self.sub.save() + + self.client.login(username="alice@example.org", password="password") + form = {"payment_method_nonce": "test-nonce"} + r = self.client.post("/accounts/profile/billing/payment_method/", form) + + self.assertRedirects(r, "/accounts/profile/billing/") + + @patch("hc.payments.models.braintree") + def test_it_creates_customer(self, mock): + self._setup_mock(mock) + + mock.Customer.create.return_value.is_success = True + mock.Customer.create.return_value.customer.id = "test-customer-id" + + self.sub = Subscription(user=self.alice) + self.sub.save() + + self.client.login(username="alice@example.org", password="password") + form = {"payment_method_nonce": "test-nonce"} + self.client.post("/accounts/profile/billing/payment_method/", form) + + self.sub.refresh_from_db() + self.assertEqual(self.sub.customer_id, "test-customer-id") + + @patch("hc.payments.models.braintree") + def test_it_updates_subscription(self, mock): + self._setup_mock(mock) + + self.sub = Subscription(user=self.alice) + self.sub.customer_id = "test-customer" + self.sub.subscription_id = "fake-id" + self.sub.save() + + mock.Customer.create.return_value.is_success = True + mock.Customer.create.return_value.customer.id = "test-customer-id" + + self.client.login(username="alice@example.org", password="password") + form = {"payment_method_nonce": "test-nonce"} + self.client.post("/accounts/profile/billing/payment_method/", form) + + self.assertTrue(mock.Subscription.update.called) diff --git a/hc/payments/tests/test_pdf_invoice.py b/hc/payments/tests/test_pdf_invoice.py index 866f4996..85bc13f2 100644 --- a/hc/payments/tests/test_pdf_invoice.py +++ b/hc/payments/tests/test_pdf_invoice.py @@ -31,7 +31,7 @@ class PdfInvoiceTestCase(BaseTestCase): self.tx.subscription_details.billing_period_end_date = now() @skipIf(reportlab is None, "reportlab not installed") - @patch("hc.payments.views.braintree") + @patch("hc.payments.models.braintree") def test_it_works(self, mock_braintree): mock_braintree.Transaction.find.return_value = self.tx @@ -40,7 +40,7 @@ class PdfInvoiceTestCase(BaseTestCase): self.assertTrue(six.b("ABC123") in r.content) self.assertTrue(six.b("alice@example.org") in r.content) - @patch("hc.payments.views.braintree") + @patch("hc.payments.models.braintree") def test_it_checks_customer_id(self, mock_braintree): tx = Mock() @@ -54,7 +54,7 @@ class PdfInvoiceTestCase(BaseTestCase): self.assertEqual(r.status_code, 403) @skipIf(reportlab is None, "reportlab not installed") - @patch("hc.payments.views.braintree") + @patch("hc.payments.models.braintree") def test_it_shows_company_data(self, mock_braintree): self.profile.bill_to = "Alice and Partners" self.profile.save() diff --git a/hc/payments/tests/test_create_plan.py b/hc/payments/tests/test_set_plan.py similarity index 57% rename from hc/payments/tests/test_create_plan.py rename to hc/payments/tests/test_set_plan.py index fb255a7e..4bdb0967 100644 --- a/hc/payments/tests/test_create_plan.py +++ b/hc/payments/tests/test_set_plan.py @@ -4,26 +4,20 @@ from hc.payments.models import Subscription from hc.test import BaseTestCase -class CreatePlanTestCase(BaseTestCase): +class SetPlanTestCase(BaseTestCase): def _setup_mock(self, mock): """ Set up Braintree calls that the controller will use. """ - mock.Customer.create.return_value.is_success = True - mock.Customer.create.return_value.customer.id = "test-customer-id" - - mock.PaymentMethod.create.return_value.is_success = True - mock.PaymentMethod.create.return_value.payment_method.token = "t-token" - mock.Subscription.create.return_value.is_success = True mock.Subscription.create.return_value.subscription.id = "t-sub-id" - def run_create_plan(self, plan_id="P5"): - form = {"plan_id": plan_id, "payment_method_nonce": "test-nonce"} + def run_set_plan(self, plan_id="P5"): + form = {"plan_id": plan_id} self.client.login(username="alice@example.org", password="password") - return self.client.post("/pricing/create_plan/", form, follow=True) + return self.client.post("/pricing/set_plan/", form, follow=True) - @patch("hc.payments.views.braintree") + @patch("hc.payments.models.braintree") def test_it_works(self, mock): self._setup_mock(mock) @@ -31,13 +25,11 @@ class CreatePlanTestCase(BaseTestCase): self.profile.sms_sent = 1 self.profile.save() - r = self.run_create_plan() - self.assertRedirects(r, "/pricing/") + r = self.run_set_plan() + self.assertRedirects(r, "/accounts/profile/billing/") # Subscription should be filled out: sub = Subscription.objects.get(user=self.alice) - self.assertEqual(sub.customer_id, "test-customer-id") - self.assertEqual(sub.payment_method_token, "t-token") self.assertEqual(sub.subscription_id, "t-sub-id") self.assertEqual(sub.plan_id, "P5") @@ -52,7 +44,7 @@ class CreatePlanTestCase(BaseTestCase): # braintree.Subscription.cancel should have not been called assert not mock.Subscription.cancel.called - @patch("hc.payments.views.braintree") + @patch("hc.payments.models.braintree") def test_yearly_works(self, mock): self._setup_mock(mock) @@ -60,13 +52,11 @@ class CreatePlanTestCase(BaseTestCase): self.profile.sms_sent = 1 self.profile.save() - r = self.run_create_plan("Y48") - self.assertRedirects(r, "/pricing/") + r = self.run_set_plan("Y48") + self.assertRedirects(r, "/accounts/profile/billing/") # Subscription should be filled out: sub = Subscription.objects.get(user=self.alice) - self.assertEqual(sub.customer_id, "test-customer-id") - self.assertEqual(sub.payment_method_token, "t-token") self.assertEqual(sub.subscription_id, "t-sub-id") self.assertEqual(sub.plan_id, "Y48") @@ -81,11 +71,41 @@ class CreatePlanTestCase(BaseTestCase): # braintree.Subscription.cancel should have not been called assert not mock.Subscription.cancel.called + @patch("hc.payments.models.braintree") + def test_it_cancels(self, mock): + self._setup_mock(mock) + + self.sub = Subscription(user=self.alice) + self.sub.subscription_id = "test-id" + self.sub.plan_id = "P5" + self.sub.save() + + self.profile.sms_limit = 1 + self.profile.sms_sent = 1 + self.profile.save() + + r = self.run_set_plan("") + self.assertRedirects(r, "/accounts/profile/billing/") + + # Subscription should be cleared + sub = Subscription.objects.get(user=self.alice) + self.assertEqual(sub.subscription_id, "") + self.assertEqual(sub.plan_id, "") + + # User's profile should have standard limits + self.profile.refresh_from_db() + self.assertEqual(self.profile.ping_log_limit, 100) + self.assertEqual(self.profile.check_limit, 20) + self.assertEqual(self.profile.team_limit, 2) + self.assertEqual(self.profile.sms_limit, 0) + + assert mock.Subscription.cancel.called + def test_bad_plan_id(self): - r = self.run_create_plan(plan_id="this-is-wrong") + r = self.run_set_plan(plan_id="this-is-wrong") self.assertEqual(r.status_code, 400) - @patch("hc.payments.views.braintree") + @patch("hc.payments.models.braintree") def test_it_cancels_previous_subscription(self, mock): self._setup_mock(mock) @@ -93,39 +113,17 @@ class CreatePlanTestCase(BaseTestCase): sub.subscription_id = "prev-sub" sub.save() - r = self.run_create_plan() - self.assertRedirects(r, "/pricing/") + r = self.run_set_plan() + self.assertRedirects(r, "/accounts/profile/billing/") assert mock.Subscription.cancel.called - @patch("hc.payments.views.braintree") - def test_customer_creation_failure(self, mock): - self._setup_mock(mock) - - mock.Customer.create.return_value.is_success = False - mock.Customer.create.return_value.message = "Test Failure" - - r = self.run_create_plan() - self.assertRedirects(r, "/pricing/") - self.assertContains(r, "Test Failure") - - @patch("hc.payments.views.braintree") - def test_pm_creation_failure(self, mock): - self._setup_mock(mock) - - mock.PaymentMethod.create.return_value.is_success = False - mock.PaymentMethod.create.return_value.message = "pm failure" - - r = self.run_create_plan() - self.assertRedirects(r, "/pricing/") - self.assertContains(r, "pm failure") - - @patch("hc.payments.views.braintree") + @patch("hc.payments.models.braintree") def test_subscription_creation_failure(self, mock): self._setup_mock(mock) mock.Subscription.create.return_value.is_success = False mock.Subscription.create.return_value.message = "sub failure" - r = self.run_create_plan() - self.assertRedirects(r, "/pricing/") + r = self.run_set_plan() + self.assertRedirects(r, "/accounts/profile/billing/") self.assertContains(r, "sub failure") diff --git a/hc/payments/urls.py b/hc/payments/urls.py index e86f2d50..7256e6b2 100644 --- a/hc/payments/urls.py +++ b/hc/payments/urls.py @@ -7,29 +7,29 @@ urlpatterns = [ views.pricing, name="hc-pricing"), - url(r'^billing/$', + url(r'^accounts/profile/billing/$', views.billing, name="hc-billing"), - url(r'^invoice/([\w-]+)/$', - views.invoice, - name="hc-invoice"), + url(r'^accounts/profile/billing/history/$', + views.billing_history, + name="hc-billing-history"), + + url(r'^accounts/profile/billing/address/$', + views.address, + name="hc-billing-address"), + + url(r'^accounts/profile/billing/payment_method/$', + views.payment_method, + name="hc-payment-method"), url(r'^invoice/pdf/([\w-]+)/$', views.pdf_invoice, name="hc-invoice-pdf"), - url(r'^pricing/create_plan/$', - views.create_plan, - name="hc-create-plan"), - - url(r'^pricing/update_payment_method/$', - views.update_payment_method, - name="hc-update-payment-method"), - - url(r'^pricing/cancel_plan/$', - views.cancel_plan, - name="hc-cancel-plan"), + url(r'^pricing/set_plan/$', + views.set_plan, + name="hc-set-plan"), url(r'^pricing/get_client_token/$', views.get_client_token, diff --git a/hc/payments/views.py b/hc/payments/views.py index 69bfebbf..fba84efb 100644 --- a/hc/payments/views.py +++ b/hc/payments/views.py @@ -1,31 +1,20 @@ -from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import (HttpResponseBadRequest, HttpResponseForbidden, JsonResponse, HttpResponse) -from django.shortcuts import redirect, render +from django.shortcuts import get_object_or_404, redirect, render +from django.template.loader import render_to_string from django.views.decorators.http import require_POST -from hc.payments.forms import BillToForm +from hc.api.models import Check from hc.payments.invoices import PdfInvoice from hc.payments.models import Subscription -if settings.USE_PAYMENTS: - import braintree -else: - # hc.payments tests mock this object, so tests should - # still be able to run: - braintree = None - @login_required def get_client_token(request): sub = Subscription.objects.for_user(request.user) - client_token = braintree.ClientToken.generate({ - "customer_id": sub.customer_id - }) - - return JsonResponse({"client_token": client_token}) + return JsonResponse({"client_token": sub.get_client_token()}) def pricing(request): @@ -33,24 +22,31 @@ def pricing(request): ctx = {"page": "pricing"} return render(request, "payments/pricing_not_owner.html", ctx) - sub = None - if request.user.is_authenticated: - # Don't use Subscription.objects.for_user method here, so a - # subscription object is not created just by viewing a page. - sub = Subscription.objects.filter(user_id=request.user.id).first() + ctx = {"page": "pricing"} + return render(request, "payments/pricing.html", ctx) + - period = "monthly" - if sub and sub.plan_id.startswith("Y"): - period = "annual" +@login_required +def billing(request): + if request.team != request.profile: + request.team = request.profile + request.profile.current_team = request.profile + request.profile.save() + + # Don't use Subscription.objects.for_user method here, so a + # subscription object is not created just by viewing a page. + sub = Subscription.objects.filter(user_id=request.user.id).first() ctx = { - "page": "pricing", + "page": "billing", + "profile": request.profile, "sub": sub, - "period": period, - "first_charge": request.session.pop("first_charge", False) + "num_checks": Check.objects.filter(user=request.user).count(), + "team_size": request.profile.member_set.count() + 1, + "team_max": request.profile.team_limit + 1 } - return render(request, "payments/pricing.html", ctx) + return render(request, "accounts/billing.html", ctx) def log_and_bail(request, result): @@ -63,63 +59,35 @@ def log_and_bail(request, result): if not logged_deep_error: messages.error(request, result.message) - return redirect("hc-pricing") + return redirect("hc-billing") @login_required @require_POST -def create_plan(request): +def set_plan(request): plan_id = request.POST["plan_id"] - if plan_id not in ("P5", "P50", "Y48", "Y480"): + if plan_id not in ("", "P5", "P50", "Y48", "Y480"): return HttpResponseBadRequest() sub = Subscription.objects.for_user(request.user) + if sub.plan_id == plan_id: + return redirect("hc-billing") # Cancel the previous plan - if sub.subscription_id: - braintree.Subscription.cancel(sub.subscription_id) - sub.subscription_id = "" - sub.plan_id = "" - sub.save() - - # Create Braintree customer record - if not sub.customer_id: - result = braintree.Customer.create({ - "email": request.user.email - }) - if not result.is_success: - return log_and_bail(request, result) - - sub.customer_id = result.customer.id - sub.save() - - # Create Braintree payment method - if "payment_method_nonce" in request.POST: - result = braintree.PaymentMethod.create({ - "customer_id": sub.customer_id, - "payment_method_nonce": request.POST["payment_method_nonce"], - "options": {"make_default": True} - }) - - if not result.is_success: - return log_and_bail(request, result) - - sub.payment_method_token = result.payment_method.token - sub.save() - - # Create Braintree subscription - result = braintree.Subscription.create({ - "payment_method_token": sub.payment_method_token, - "plan_id": plan_id, - }) + sub.cancel() + if plan_id == "": + profile = request.user.profile + profile.ping_log_limit = 100 + profile.check_limit = 20 + profile.team_limit = 2 + profile.sms_limit = 0 + profile.save() + return redirect("hc-billing") + result = sub.setup(plan_id) if not result.is_success: return log_and_bail(request, result) - sub.subscription_id = result.subscription.id - sub.plan_id = plan_id - sub.save() - # Update user's profile profile = request.user.profile if plan_id in ("P5", "Y48"): @@ -138,100 +106,76 @@ def create_plan(request): profile.save() request.session["first_charge"] = True - return redirect("hc-pricing") + return redirect("hc-billing") @login_required -@require_POST -def update_payment_method(request): +def address(request): sub = Subscription.objects.for_user(request.user) + if request.method == "POST": + error = sub.update_address(request.POST) + if error: + return log_and_bail(request, error) - if not sub.customer_id or not sub.subscription_id: - return HttpResponseBadRequest() - - if "payment_method_nonce" not in request.POST: - return HttpResponseBadRequest() - - result = braintree.PaymentMethod.create({ - "customer_id": sub.customer_id, - "payment_method_nonce": request.POST["payment_method_nonce"], - "options": {"make_default": True} - }) - - if not result.is_success: - return log_and_bail(request, result) - - payment_method_token = result.payment_method.token - result = braintree.Subscription.update(sub.subscription_id, { - "payment_method_token": payment_method_token - }) - - if not result.is_success: - return log_and_bail(request, result) - - sub.payment_method_token = payment_method_token - sub.save() + return redirect("hc-billing") - return redirect("hc-pricing") + ctx = {"a": sub.address} + return render(request, "payments/address.html", ctx) @login_required -@require_POST -def cancel_plan(request): - sub = Subscription.objects.get(user=request.user) - sub.cancel() +def payment_method(request): + sub = get_object_or_404(Subscription, user=request.user) - # Revert to default limits-- - profile = request.user.profile - profile.ping_log_limit = 100 - profile.check_limit = 20 - profile.team_limit = 2 - profile.sms_limit = 0 - profile.save() - - return redirect("hc-pricing") - - -@login_required -def billing(request): if request.method == "POST": - form = BillToForm(request.POST) - if form.is_valid(): - request.user.profile.bill_to = form.cleaned_data["bill_to"] - request.user.profile.save() - return redirect("hc-billing") + if "payment_method_nonce" not in request.POST: + return HttpResponseBadRequest() - sub = Subscription.objects.get(user=request.user) + nonce = request.POST["payment_method_nonce"] + error = sub.update_payment_method(nonce) + if error: + return log_and_bail(request, error) - transactions = braintree.Transaction.search( - braintree.TransactionSearch.customer_id == sub.customer_id) + return redirect("hc-billing") - ctx = {"transactions": transactions} - return render(request, "payments/billing.html", ctx) + ctx = { + "sub": sub, + "pm": sub.payment_method + } + return render(request, "payments/payment_method.html", ctx) @login_required -def invoice(request, transaction_id): - sub = Subscription.objects.get(user=request.user) - transaction = braintree.Transaction.find(transaction_id) - if transaction.customer_details.id != sub.customer_id: - return HttpResponseForbidden() +def billing_history(request): + try: + sub = Subscription.objects.get(user=request.user) + transactions = sub.transactions + except Subscription.DoesNotExist: + transactions = [] - ctx = {"tx": transaction} - return render(request, "payments/invoice.html", ctx) + ctx = {"transactions": transactions} + return render(request, "payments/billing_history.html", ctx) @login_required def pdf_invoice(request, transaction_id): sub = Subscription.objects.get(user=request.user) - transaction = braintree.Transaction.find(transaction_id) - if transaction.customer_details.id != sub.customer_id: + transaction = sub.get_transaction(transaction_id) + if transaction is None: return HttpResponseForbidden() response = HttpResponse(content_type='application/pdf') filename = "MS-HC-%s.pdf" % transaction.id.upper() response['Content-Disposition'] = 'attachment; filename="%s"' % filename - bill_to = request.user.profile.bill_to or request.user.email + bill_to = [] + if sub.address_id: + ctx = {"a": sub.address} + bill_to = render_to_string("payments/address_plain.html", ctx) + elif request.user.profile.bill_to: + bill_to = request.user.profile.bill_to + else: + bill_to = request.user.email + PdfInvoice(response).render(transaction, bill_to) return response diff --git a/static/css/pricing.css b/static/css/pricing.css index 33069c25..e4425c42 100644 --- a/static/css/pricing.css +++ b/static/css/pricing.css @@ -64,7 +64,3 @@ margin: 10px 0; color: #AAA; } - -#payment-method-modal .modal-header { - text-align: center; -} \ No newline at end of file diff --git a/static/css/profile.css b/static/css/profile.css index 98dd4a07..bfdbc61a 100644 --- a/static/css/profile.css +++ b/static/css/profile.css @@ -16,3 +16,24 @@ padding: 8px 15px; } +span.loading { + color: #888; + font-style: italic; +} + +#billing-address-modal label { + font-weight: normal; +} + +#billing-address-modal .modal-body { + margin-left: 30px; + margin-right: 30px; +} + +.masked_number { + padding-left: 8px; +} + +.billing-empty { + color: #888; +} \ No newline at end of file diff --git a/static/js/billing.js b/static/js/billing.js new file mode 100644 index 00000000..299826fc --- /dev/null +++ b/static/js/billing.js @@ -0,0 +1,46 @@ +$(function () { + var clientTokenRequested = false; + function requestClientToken() { + if (!clientTokenRequested) { + clientTokenRequested = true; + $.getJSON("/pricing/get_client_token/", setupDropin); + } + } + + function setupDropin(data) { + braintree.dropin.create({ + authorization: data.client_token, + container: "#dropin", + paypal: { flow: 'vault' } + }, function(createErr, instance) { + $("#payment-form-submit").click(function() { + instance.requestPaymentMethod(function (requestPaymentMethodErr, payload) { + $("#pmm-nonce").val(payload.nonce); + $("#payment-form").submit(); + }); + }).prop("disabled", false); + }); + } + + $("#update-payment-method").hover(requestClientToken); + + $("#update-payment-method").click(function() { + requestClientToken(); + $("#payment-form").attr("action", this.dataset.action); + $("#payment-form-submit").text("Update Payment Method"); + $("#payment-method-modal").modal("show"); + }); + + + $("#billing-history").load( "/accounts/profile/billing/history/" ); + $("#billing-address").load( "/accounts/profile/billing/address/", function() { + $("#billing-address input").each(function(idx, obj) { + $("#" + obj.name).val(obj.value); + }); + }); + + $("#payment-method").load( "/accounts/profile/billing/payment_method/", function() { + $("#next-billing-date").text($("#nbd").val()); + }); + +}); \ No newline at end of file diff --git a/static/js/pricing.js b/static/js/pricing.js index 9edfedad..c56aadd3 100644 --- a/static/js/pricing.js +++ b/static/js/pricing.js @@ -1,48 +1,13 @@ $(function () { - var clientTokenRequested = false; - function requestClientToken() { - if (!clientTokenRequested) { - clientTokenRequested = true; - $.getJSON("/pricing/get_client_token/", setupDropin); + $("#period-controls :input").change(function() { + if (this.value == "monthly") { + $("#s-price").text("$5"); + $("#p-price").text("$50"); } - } - - function setupDropin(data) { - braintree.dropin.create({ - authorization: data.client_token, - container: "#dropin", - paypal: { flow: 'vault' } - }, function(createErr, instance) { - $("#payment-form-submit").click(function() { - instance.requestPaymentMethod(function (requestPaymentMethodErr, payload) { - $("#pmm-nonce").val(payload.nonce); - $("#payment-form").submit(); - }); - }).prop("disabled", false); - }); - } - - $(".btn-create-payment-method").hover(requestClientToken); - $(".btn-update-payment-method").hover(requestClientToken); - - $(".btn-create-payment-method").click(function() { - requestClientToken(); - $("#plan_id").val(this.dataset.planId); - $("#payment-form").attr("action", this.dataset.action); - $("#payment-form-submit").text("Set Up Subscription and Pay $" + this.dataset.planPay); - $("#payment-method-modal").modal("show"); - }); - $(".btn-update-payment-method").click(function() { - requestClientToken(); - $("#payment-form").attr("action", this.dataset.action); - $("#payment-form-submit").text("Update Payment Method"); - $("#payment-method-modal").modal("show"); - }); - - $("#period-controls :input").change(function() { - $("#monthly").toggleClass("hide", this.value != "monthly"); - $("#annual").toggleClass("hide", this.value != "annual"); + if (this.value == "annual") { + $("#s-price").text("$4"); + $("#p-price").text("$40"); + } }); - }); \ No newline at end of file diff --git a/templates/accounts/badges.html b/templates/accounts/badges.html index f9ba9340..c504f433 100644 --- a/templates/accounts/badges.html +++ b/templates/accounts/badges.html @@ -14,6 +14,9 @@
diff --git a/templates/accounts/billing.html b/templates/accounts/billing.html new file mode 100644 index 00000000..096e0c1e --- /dev/null +++ b/templates/accounts/billing.html @@ -0,0 +1,428 @@ +{% extends "base.html" %} +{% load compress staticfiles hc_extras %} + +{% block title %}Account Settings - {% site_name %}{% endblock %} + +{% block content %} +
+
+

Settings

+
+
+ +
+ + +
+ {% if messages %} +
+

+ We're sorry! There was a problem setting + up the subscription. Response from payment gateway:

+ + {% for message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + +
+
+
+
+

Billing Plan

+ + + + + + + {% if sub.plan_id %} + + + + + {% endif %} + + + + + + + + +
Current Plan + {% if sub is None or sub.plan_id == "" %} + Free + {% else %} + {% if sub.plan_id == "P5" or sub.plan_id == "Y48" %} + Standard + {% endif %} + {% if sub.plan_id == "P50" or sub.plan_id == "Y480" %} + Plus + {% endif %} + + (${{ sub.price }}/{{ sub.period }}) + {% endif %} +
Next Payment + loading… +
Checks Used + {{ num_checks }} of + {% if sub.plan_id %} + unlimited + {% else %} + {{ profile.check_limit }} + {% endif %} +
Team Size + {{ team_size }} of + {% if profile.team_limit == 500 %} + unlimited + {% else %} + {{ team_max }} + {% endif %} +
+ + +
+
+ +
+
+

Payment Method

+ {% if sub.payment_method_token %} +

+ loading… +

+ {% else %} +

Not set

+ {% endif %} + +
+
+
+
+
+
+

Billing Address

+ + {% if sub.address_id %} +
+ loading… +
+ {% else %} +

+ Not set +

+ {% endif %} + + +
+ {% if status == "info" %} + + {% endif %} +
+
+
+ +
+
+

Billing History

+
+ loading… +
+
+
+
+
+ + + + + + +{% endblock %} + +{% block scripts %} + +{% compress js %} + + + +{% endcompress %} +{% endblock %} diff --git a/templates/accounts/notifications.html b/templates/accounts/notifications.html index f6c8f58a..b1c3979c 100644 --- a/templates/accounts/notifications.html +++ b/templates/accounts/notifications.html @@ -14,6 +14,9 @@
diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html index d79be8d7..68b48867 100644 --- a/templates/accounts/profile.html +++ b/templates/accounts/profile.html @@ -22,6 +22,9 @@
diff --git a/templates/payments/address.html b/templates/payments/address.html new file mode 100644 index 00000000..b9394be7 --- /dev/null +++ b/templates/payments/address.html @@ -0,0 +1,41 @@ +{% if a.first_name or a.last_name %} +

{{ a.first_name|default:"" }} {{ a.last_name|default:"" }}

+{% endif %} + +{% if a.company %} +

{{ a.company }}

+{% endif %} + +{% if a.extended_address %} +

VAT: {{ a.extended_address }}

+{% endif %} + +{% if a.street_address %} +

{{ a.street_address }}

+{% endif %} + +{% if a.locality %} +

{{ a.locality }}

+{% endif %} + +{% if a.region %} +

{{ a.region }}

+{% endif %} + +{% if a.country_name %} +

{{ a.country_name }}

+{% endif %} + +{% if a.postal_code %} +

{{ a.postal_code }}

+{% endif %} + + + + + + + + + + diff --git a/templates/payments/address_plain.html b/templates/payments/address_plain.html new file mode 100644 index 00000000..631de3d7 --- /dev/null +++ b/templates/payments/address_plain.html @@ -0,0 +1,8 @@ +{% if a.first_name or a.last_name %}{{ a.first_name|default:"" }} {{ a.last_name|default:"" }} +{% endif %}{% if a.company %}{{ a.company }} +{% endif %}{% if a.extended_address %}VAT: {{ a.extended_address }} +{% endif %}{% if a.street_address %}{{ a.street_address }} +{% endif %}{% if a.locality %}{{ a.locality }} +{% endif %}{% if a.region %}{{ a.region }} +{% endif %}{% if a.country_name %}{{ a.country_name }} +{% endif %}{% if a.postal_code %}{{ a.postal_code }}{% endif %} \ No newline at end of file diff --git a/templates/payments/billing.html b/templates/payments/billing.html deleted file mode 100644 index 79411cab..00000000 --- a/templates/payments/billing.html +++ /dev/null @@ -1,98 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Billing History - healthchecks.io{% endblock %} - - -{% block content %} -

Billing History

-
-
- - - - - - - - - - {% for tx in transactions %} - - - - - - - - {% empty %} - - - - {% endfor%} -
DatePayment MethodAmountStatus
{{ tx.created_at }} - {% if tx.payment_instrument_type == "paypal_account" %} - Paypal from {{ tx.paypal.payer_email }} - {% endif %} - - {% if tx.payment_instrument_type == "credit_card" %} - {{ tx.credit_card.card_type }} ending in {{ tx.credit_card.last_4 }} - {% endif %} - - {% if tx.currency_iso_code == "USD" %} - ${{ tx.amount }} - {% elif tx.currency_iso_code == "EUR" %} - €{{ tx.amount }} - {% else %} - {{ tx.currency_iso_code }} {{ tx.amount }} - {% endif %} - {{ tx.status }} - PDF Invoice -
- No past transactions to display here -
-
-
-

Bill to:

-

- {% if request.user.profile.bill_to %} - {{ request.user.profile.bill_to|linebreaksbr }} - {% else %} - {{ request.user.email }} - {% endif %} -

- - -
-
- - - - -{% endblock %} diff --git a/templates/payments/billing_history.html b/templates/payments/billing_history.html new file mode 100644 index 00000000..2ae78c4d --- /dev/null +++ b/templates/payments/billing_history.html @@ -0,0 +1,42 @@ +{% if transactions %} + + + + + + + + + {% for tx in transactions %} + + + + + + + + {% endfor%} +
DatePayment MethodAmountStatus
{{ tx.created_at }} + {% if tx.payment_instrument_type == "paypal_account" %} + Paypal from {{ tx.paypal.payer_email }} + {% endif %} + + {% if tx.payment_instrument_type == "credit_card" %} + {{ tx.credit_card.card_type }} ending in {{ tx.credit_card.last_4 }} + {% endif %} + + {% if tx.currency_iso_code == "USD" %} + ${{ tx.amount }} + {% elif tx.currency_iso_code == "EUR" %} + €{{ tx.amount }} + {% else %} + {{ tx.currency_iso_code }} {{ tx.amount }} + {% endif %} + {{ tx.status }} + PDF Invoice +
+{% else %} +

+ No past transactions to display here +

+{% endif %} \ No newline at end of file diff --git a/templates/payments/countries.html b/templates/payments/countries.html new file mode 100644 index 00000000..f084393c --- /dev/null +++ b/templates/payments/countries.html @@ -0,0 +1,250 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/payments/payment_method.html b/templates/payments/payment_method.html new file mode 100644 index 00000000..1f59de24 --- /dev/null +++ b/templates/payments/payment_method.html @@ -0,0 +1,14 @@ +{% if sub.pm_is_card %} + {{ pm.masked_number }} + {% if pm.expired %} + (expired) + {% endif %} +{% endif %} + +{% if sub.pm_is_paypal %} + {{ pm.email }} +{% endif %} + +{% if sub.subscription_id %} + +{% endif %} \ No newline at end of file diff --git a/templates/payments/pricing.html b/templates/payments/pricing.html index 46b1f74e..ef8e036a 100644 --- a/templates/payments/pricing.html +++ b/templates/payments/pricing.html @@ -8,51 +8,32 @@
- {% if messages %} -
-

- We're sorry! There was a problem setting - up the subscription. Response from payment gateway:

- - {% for message in messages %} -

{{ message }}

- {% endfor %} -
- {% endif %} - - {% if sub.plan_id %} + {% if request.user.is_authenticated %}

- {% if first_charge %} - Success! You just paid ${{ sub.price }}. + Your account is currently on the + {% if sub.plan_id == "P5" %} + Monthly Standard + {% elif sub.plan_id == "P50" %} + Monthly Plus + {% elif sub.plan_id == "Y48" %} + Yearly Standard + {% elif sub.plan_id == "Y480" %} + Yearly Plus {% else %} - You are currently paying ${{ sub.price }}/{{ sub.period }} - - {% if sub.pm_is_credit_card %} - using {{ sub.card_type }} card - ending with {{ sub.last_4 }}. - {% endif %} - - {% if sub.pm_is_paypal %} - using PayPal account - {{ sub.paypal_email }}. - {% endif %} + Free + {% endif %} + plan. + {% if sub.plan_id %} + You are paying + ${{ sub.price }} / {{ sub.period }}. {% endif %}

-

Thank you for supporting healthchecks.io!

-

- See Billing History - {% if not first_charge %} - - {% endif %} + Billing Details

@@ -68,20 +49,19 @@
-
@@ -89,255 +69,84 @@
- -
- -
-
-
-

Free

-

$0/mo

-
-
    -
  • 20 Checks
  • -
  • 3 Team Members
  • -
  • 100 log entries per check
  • -
  •  
  • -
  •  
  • -
- + +
+
+
+

Free

+

$0/mo

-
- - - -
-
-
-

Standard

-

$5/mo

-
- -
    -
  • Unlimited Checks
  • -
  • 10 Team Members
  • -
  • 1000 log entries per check
  • -
  • 50 SMS alerts per month
  • -
  • Email Support
  • -
- -
-
- - - -
-
-
-

Plus

-

$50/mo

-
- -
    -
  • Unlimited Checks
  • -
  • Unlimited Team Members
  • -
  • 1000 log entries per check
  • -
  • 500 SMS alerts per month
  • -
  • Priority Email Support
  • -
- +
    +
  • 20 Checks
  • +
  • 3 Team Members
  • +
  • 100 log entries per check
  • +
  •  
  • +
  •  
  • +
+ {% if not request.user.is_authenticated %} + + {% endif %}
-
- -
- -
-
-
-

Free

-

$0/mo

-
-
    -
  • 20 Checks
  • -
  • 3 Team Members
  • -
  • 100 log entries per check
  • -
  •  
  • -
  •  
  • -
- + + + +
+
+
+

Standard

+

+ $5/mo +

-
- - - -
-
-
-

Standard

-

$4/mo

-
-
    -
  • Unlimited Checks
  • -
  • 10 Team Members
  • -
  • 1000 log entries per check
  • -
  • 50 SMS alerts per month
  • -
  • Email Support
  • -
- +
    +
  • Unlimited Checks
  • +
  • 10 Team Members
  • +
  • 1000 log entries per check
  • +
  • 50 SMS alerts per month
  • +
  • Email Support
  • +
+ {% if not request.user.is_authenticated %} + + {% endif %}
- - - -
-
-
-

Plus

-

$40/mo

-
+
+ + + +
+
+
+

Plus

+

+ $50/mo +

+
-
    -
  • Unlimited Checks
  • -
  • Unlimited Team Members
  • -
  • 1000 log entries per check
  • -
  • 500 SMS alerts per month
  • -
  • Priority Email Support
  • -
- +
    +
  • Unlimited Checks
  • +
  • Unlimited Team Members
  • +
  • 1000 log entries per check
  • +
  • 500 SMS alerts per month
  • +
  • Priority Email Support
  • +
+ {% if not request.user.is_authenticated %} + + {% endif %}
-
- +
@@ -416,38 +225,9 @@
- - {% endblock %} {% block scripts %} - {% compress js %}