Browse Source

Prepare for 3DS 2

pull/287/head
Pēteris Caune 5 years ago
parent
commit
fa16bd4e42
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
9 changed files with 280 additions and 294 deletions
  1. +8
    -34
      hc/payments/models.py
  2. +5
    -1
      hc/payments/tests/test_get_client_token.py
  3. +2
    -65
      hc/payments/tests/test_payment_method.py
  4. +35
    -15
      hc/payments/tests/test_update_subscription.py
  5. +2
    -4
      hc/payments/urls.py
  6. +13
    -17
      hc/payments/views.py
  7. +6
    -1
      static/css/billing.css
  8. +79
    -30
      static/js/billing.js
  9. +130
    -127
      templates/accounts/billing.html

+ 8
- 34
hc/payments/models.py View File

@ -66,11 +66,9 @@ class Subscription(models.Model):
@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)
o = self._get_braintree_subscription()
self._pm = braintree.PaymentMethod.find(o.payment_method_token)
return self._pm
def _get_braintree_subscription(self):
@ -79,43 +77,19 @@ class Subscription(models.Model):
return self._sub
def get_client_token(self):
assert self.customer_id
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()
assert self.subscription_id
# Create payment method
result = braintree.PaymentMethod.create(
{
"customer_id": self.customer_id,
"payment_method_nonce": nonce,
"options": {"make_default": True},
}
result = braintree.Subscription.update(
self.subscription_id, {"payment_method_nonce": nonce}
)
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:
@ -141,9 +115,9 @@ class Subscription(models.Model):
if not result.is_success:
return result
def setup(self, plan_id):
def setup(self, plan_id, nonce):
result = braintree.Subscription.create(
{"payment_method_token": self.payment_method_token, "plan_id": plan_id}
{"payment_method_nonce": nonce, "plan_id": plan_id}
)
if result.is_success:


+ 5
- 1
hc/payments/tests/test_get_client_token.py View File

@ -7,10 +7,14 @@ from hc.test import BaseTestCase
class GetClientTokenTestCase(BaseTestCase):
@patch("hc.payments.models.braintree")
def test_it_works(self, mock_braintree):
sub = Subscription(user=self.alice)
sub.customer_id = "fake-customer-id"
sub.save()
mock_braintree.ClientToken.generate.return_value = "test-token"
self.client.login(username="[email protected]", password="password")
r = self.client.get("/pricing/get_client_token/")
r = self.client.get("/pricing/token/")
self.assertContains(r, "test-token", status_code=200)
# A subscription object should have been created


+ 2
- 65
hc/payments/tests/test_payment_method.py View File

@ -5,23 +5,13 @@ 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": "[email protected]"}
self.sub = Subscription(user=self.alice)
self.sub.payment_method_token = "fake-token"
self.sub.save()
Subscription.objects.create(user=self.alice)
self.client.login(username="[email protected]", password="password")
r = self.client.get("/accounts/profile/billing/payment_method/")
@ -29,65 +19,12 @@ class UpdatePaymentMethodTestCase(BaseTestCase):
@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()
Subscription.objects.create(user=self.alice)
self.client.login(username="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", password="password")
form = {"payment_method_nonce": "test-nonce"}
self.client.post("/accounts/profile/billing/payment_method/", form)
self.assertTrue(mock.Subscription.update.called)

hc/payments/tests/test_set_plan.py → hc/payments/tests/test_update_subscription.py View File


+ 2
- 4
hc/payments/urls.py View File

@ -19,9 +19,7 @@ urlpatterns = [
path(
"invoice/pdf/<slug:transaction_id>/", views.pdf_invoice, name="hc-invoice-pdf"
),
path("pricing/set_plan/", views.set_plan, name="hc-set-plan"),
path(
"pricing/get_client_token/", views.get_client_token, name="hc-get-client-token"
),
path("pricing/update/", views.update, name="hc-update-subscription"),
path("pricing/token/", views.token, name="hc-get-client-token"),
path("pricing/charge/", views.charge_webhook),
]

+ 13
- 17
hc/payments/views.py View File

@ -20,7 +20,7 @@ from hc.payments.models import Subscription
@login_required
def get_client_token(request):
def token(request):
sub = Subscription.objects.for_user(request.user)
return JsonResponse({"client_token": sub.get_client_token()})
@ -96,13 +96,21 @@ def log_and_bail(request, result):
@login_required
@require_POST
def set_plan(request):
def update(request):
plan_id = request.POST["plan_id"]
nonce = request.POST["nonce"]
if plan_id not in ("", "P20", "P80", "Y192", "Y768"):
return HttpResponseBadRequest()
sub = Subscription.objects.for_user(request.user)
if sub.plan_id == plan_id:
# If plan_id has not changed then just update the payment method:
if plan_id == sub.plan_id:
error = sub.update_payment_method(nonce)
if error:
return log_and_bail(request, error)
request.session["payment_method_status"] = "success"
return redirect("hc-billing")
# Cancel the previous plan and reset limits:
@ -116,9 +124,10 @@ def set_plan(request):
profile.save()
if plan_id == "":
request.session["set_plan_status"] = "success"
return redirect("hc-billing")
result = sub.setup(plan_id)
result = sub.setup(plan_id, nonce)
if not result.is_success:
return log_and_bail(request, result)
@ -161,19 +170,6 @@ def address(request):
@login_required
def payment_method(request):
sub = get_object_or_404(Subscription, user=request.user)
if request.method == "POST":
if "payment_method_nonce" not in request.POST:
return HttpResponseBadRequest()
nonce = request.POST["payment_method_nonce"]
error = sub.update_payment_method(nonce)
if error:
return log_and_bail(request, error)
request.session["payment_method_status"] = "success"
return redirect("hc-billing")
ctx = {"sub": sub, "pm": sub.payment_method}
return render(request, "payments/payment_method.html", ctx)


+ 6
- 1
static/css/billing.css View File

@ -12,7 +12,7 @@
}
@media (min-width: 992px) {
#change-billing-plan-modal .modal-dialog {
#change-billing-plan-modal .modal-dialog, #payment-method-modal .modal-dialog, #please-wait-modal .modal-dialog {
width: 850px;
}
}
@ -86,3 +86,8 @@
color: #777;
}
#please-wait-modal .modal-body {
text-align: center;
padding: 100px;
font-size: 18px;
}

+ 79
- 30
static/js/billing.js View File

@ -1,45 +1,92 @@
$(function () {
var clientTokenRequested = false;
function requestClientToken() {
if (!clientTokenRequested) {
clientTokenRequested = true;
$.getJSON("/pricing/get_client_token/", setupDropin);
var preloadedToken = null;
function getToken(callback) {
if (preloadedToken) {
callback(preloadedToken);
} else {
$.getJSON("/pricing/token/", function(response) {
preloadedToken = response.client_token;
callback(response.client_token);
});
}
}
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();
// Preload client token:
if ($("#billing-address").length) {
getToken(function(token){});
}
function getAmount(planId) {
return planId.substr(1);
}
function showPaymentMethodForm(planId) {
$("#plan-id").val(planId);
$("#nonce").val("");
if (planId == "") {
// Don't need a payment method when switching to the free plan
// -- can submit the form right away:
$("#update-subscription-form").submit();
return;
}
$("#payment-form-submit").prop("disabled", true);
$("#payment-method-modal").modal("show");
getToken(function(token) {
braintree.dropin.create({
authorization: token,
container: "#dropin",
threeDSecure: {
amount: getAmount(planId),
},
paypal: { flow: 'vault' },
preselectVaultedPaymentMethod: false
}, function(createErr, instance) {
$("#payment-form-submit").off().click(function() {
instance.requestPaymentMethod(function (err, payload) {
$("#payment-method-modal").modal("hide");
$("#please-wait-modal").modal("show");
$("#nonce").val(payload.nonce);
$("#update-subscription-form").submit();
});
});
$("#payment-method-modal").off("hidden.bs.modal").on("hidden.bs.modal", function() {
instance.teardown();
});
}).prop("disabled", false);
instance.on("paymentMethodRequestable", function() {
$("#payment-form-submit").prop("disabled", false);
});
instance.on("noPaymentMethodRequestable", function() {
$("#payment-form-submit").prop("disabled", true);
});
});
});
}
$("#update-payment-method").hover(requestClientToken);
$("#change-plan-btn").click(function() {
$("#change-billing-plan-modal").modal("hide");
showPaymentMethodForm(this.dataset.planId);
});
$("#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");
showPaymentMethodForm($("#old-plan-id").val());
});
$("#billing-history").load( "/accounts/profile/billing/history/" );
$("#billing-address").load( "/accounts/profile/billing/address/", function() {
$("#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() {
$("#payment-method").load("/accounts/profile/billing/payment_method/", function() {
$("#next-billing-date").text($("#nbd").val());
});
@ -94,9 +141,7 @@ $(function () {
if ($("#plan-business-plus").hasClass("selected")) {
planId = period == "monthly" ? "P80" : "Y768";
}
$("#plan-id").val(planId);
if (planId == $("#old-plan-id").val()) {
$("#change-plan-btn")
.attr("disabled", "disabled")
@ -105,10 +150,14 @@ $(function () {
} else {
var caption = "Change Billing Plan";
if (planId) {
caption += " And Pay $" + planId.substr(1) + " Now";
var amount = planId.substr(1);
caption += " And Pay $" + amount + " Now";
}
$("#change-plan-btn").removeAttr("disabled").text(caption);
$("#change-plan-btn")
.removeAttr("disabled")
.text(caption)
.attr("data-plan-id", planId);
}
}
updateChangePlanForm();


+ 130
- 127
templates/accounts/billing.html View File

@ -76,16 +76,13 @@
{% endif %}
</div>
{% if sub.subscription_id %}
<div class="panel panel-{{ payment_method_status }}">
<div class="panel-body settings-block">
<h2>Payment Method</h2>
{% if sub.payment_method_token %}
<p id="payment-method">
<span class="loading">loading…</span>
</p>
{% else %}
<p id="payment-method-missing" class="billing-empty">Not set</p>
{% endif %}
<button
id="update-payment-method"
class="btn btn-default pull-right">
@ -97,6 +94,7 @@
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="col-sm-6">
<div class="panel panel-{{ address_status }}">
@ -170,110 +168,104 @@
<div id="change-billing-plan-modal" class="modal">
<div class="modal-dialog">
{% if sub.payment_method_token and sub.address_id %}
<form method="post" class="form-horizontal" autocomplete="off" action="{% url 'hc-set-plan' %}">
{% csrf_token %}
<input type="hidden" id="old-plan-id" value="{{ sub.plan_id }}">
<input type="hidden" id="plan-id" name="plan_id" value="{{ sub.plan_id }}">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4>Change Billing Plan</h4>
</div>
<div class="modal-body">
<div class="row">
<div class="col-sm-4">
<div id="plan-hobbyist" class="panel plan {% if sub.plan_id == "" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Hobbyist</h2>
<ul>
<li>Checks: 20</li>
<li>Team members: 3</li>
<li>Log entries: 100</li>
</ul>
<h3>Free</h3>
</div>
</div>
<div class="col-sm-4">
<div id="plan-business" class="panel plan {% if sub.plan_id == "P20" or sub.plan_id == "Y192" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Business</h2>
<ul>
<li>Checks: 100</li>
<li>Team members: 10</li>
<li>Log entries: 1000</li>
</ul>
<h3>
<span id="business-price"></span>
<small>/ month</small>
</h3>
</div>
{% if sub.address_id %}
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4>Change Billing Plan</h4>
</div>
<div class="modal-body">
<div class="row">
<div class="col-sm-4">
<div id="plan-hobbyist" class="panel plan {% if sub.plan_id == "" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Hobbyist</h2>
<ul>
<li>Checks: 20</li>
<li>Team members: 3</li>
<li>Log entries: 100</li>
</ul>
<h3>Free</h3>
</div>
<div class="col-sm-4">
<div id="plan-business-plus" class="panel plan {% if sub.plan_id == "P80" or sub.plan_id == "Y768" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Business Plus</h2>
<ul>
<li>Checks: 1000</li>
<li>Team members: Unlimited</li>
<li>Log entries: 1000</li>
</ul>
<h3>
<span id="business-plus-price"></span>
<small>/ month</small>
</h3>
</div>
</div>
<div class="col-sm-4">
<div id="plan-business" class="panel plan {% if sub.plan_id == "P20" or sub.plan_id == "Y192" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Business</h2>
<ul>
<li>Checks: 100</li>
<li>Team members: 10</li>
<li>Log entries: 1000</li>
</ul>
<h3>
<span id="business-price"></span>
<small>/ month</small>
</h3>
</div>
</div>
<div class="row">
<div id="billing-periods" class="col-sm-6">
<p>Billing Period</p>
<label class="radio-container">
<input
id="billing-monthly"
type="radio"
name="billing_period"
value="monthly"
{% if sub.plan_id == "Y192" or sub.plan_id == "Y768" %}{% else %}checked{% endif %}>
<span class="radiomark"></span>
Monthly
</label>
<label class="radio-container">
<input
id="billing-annual"
type="radio"
name="billing_period"
value="annual"
{% if sub.plan_id == "Y192" or sub.plan_id == "Y768" %} checked {% endif %}>
<span class="radiomark"></span>
Annual, 20% off
</label>
<div class="col-sm-4">
<div id="plan-business-plus" class="panel plan {% if sub.plan_id == "P80" or sub.plan_id == "Y768" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Business Plus</h2>
<ul>
<li>Checks: 1000</li>
<li>Team members: Unlimited</li>
<li>Log entries: 1000</li>
</ul>
<h3>
<span id="business-plus-price"></span>
<small>/ month</small>
</h3>
</div>
</div>
</div>
<div class="text-warning">
<strong>No proration.</strong> We currently do not
support proration when changing billing plans.
Changing the plan starts a new billing cycle
and charges your payment method.
<div class="row">
<div id="billing-periods" class="col-sm-6">
<p>Billing Period</p>
<label class="radio-container">
<input
id="billing-monthly"
type="radio"
name="billing_period"
value="monthly"
{% if sub.plan_id == "Y192" or sub.plan_id == "Y768" %}{% else %}checked{% endif %}>
<span class="radiomark"></span>
Monthly
</label>
<label class="radio-container">
<input
id="billing-annual"
type="radio"
name="billing_period"
value="annual"
{% if sub.plan_id == "Y192" or sub.plan_id == "Y768" %} checked {% endif %}>
<span class="radiomark"></span>
Annual, 20% off
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button id="change-plan-btn" type="submit" class="btn btn-primary" disabled="disabled">
Change Billing Plan
</button>
<div class="text-warning">
<strong>No proration.</strong> We currently do not
support proration when changing billing plans.
Changing the plan starts a new billing cycle
and charges your payment method.
</div>
</div>
</form>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button id="change-plan-btn" type="button" class="btn btn-primary" disabled="disabled">
Change Billing Plan
</button>
</div>
</div>
{% else %}
<div class="modal-content">
<div class="modal-header">
@ -281,14 +273,6 @@
<h4>Some details are missing…</h4>
</div>
<div class="modal-body">
{% if not sub.payment_method_token %}
<div id="no-payment-method">
<h4>No payment method.</h4>
<p>Please add a payment method before changing the billing
plan.
</p>
</div>
{% endif %}
{% if not sub.address_id %}
<div id="no-billing-address">
<h4>Country not specified.</h4>
@ -315,28 +299,23 @@
<div id="payment-method-modal" class="modal pm-modal">
<div class="modal-dialog">
<form id="payment-form" method="post" action="{% url 'hc-payment-method' %}">
{% csrf_token %}
<input id="pmm-nonce" type="hidden" name="payment_method_nonce" />
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4>Payment Method</h4>
</div>
<div class="modal-body">
<div id="dropin"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Cancel
</button>
<button id="payment-form-submit" type="button" class="btn btn-primary" disabled>
Confirm Payment Method
</button>
</div>
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4>Payment Method</h4>
</div>
</form>
<div class="modal-body">
<div id="dropin"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Cancel
</button>
<button id="payment-form-submit" type="button" class="btn btn-primary" disabled>
Confirm Payment Method
</button>
</div>
</div>
</div>
</div>
@ -510,11 +489,35 @@
</div>
</div>
<div id="please-wait-modal" class="modal pm-modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4>Payment Method</h4>
</div>
<div class="modal-body">
Processing, please wait&hellip;
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" disabled>
Confirm Payment Method
</button>
</div>
</div>
</div>
</div>
<form id="update-subscription-form" method="post" action="{% url 'hc-update-subscription' %}">
{% csrf_token %}
<input id="nonce" type="hidden" name="nonce" />
<input type="hidden" id="old-plan-id" value="{{ sub.plan_id }}">
<input id="plan-id" type="hidden" name="plan_id" />
</form>
{% endblock %}
{% block scripts %}
<script src="https://js.braintreegateway.com/web/dropin/1.17.1/js/dropin.min.js"></script>
<script src="https://js.braintreegateway.com/web/dropin/1.20.0/js/dropin.min.js"></script>
{% compress js %}
<script src="{% static 'js/jquery-2.1.4.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script>


Loading…
Cancel
Save