diff --git a/.gitignore b/.gitignore index bbf9e12ab56..63ba9691694 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ whoosh_index xml_output public_cnames public_symlinks +.rope_project/ diff --git a/bower.json b/bower.json index add924b7d80..282e4f31d70 100644 --- a/bower.json +++ b/bower.json @@ -19,7 +19,7 @@ "readthedocs-client": "https://github.com/agjohnson/readthedocs-client-js.git", "sphinx-rtd-theme": "https://github.com/snide/sphinx-rtd-theme.git#0.1.9", "knockout": "~3.3.0", - "jquery.payment": "~1.2.3", + "jquery.payment": "~1.3.0", "jquery-migrate": "~1.2.1", "jquery-ui": "1.8.23" }, diff --git a/media/css/core.css b/media/css/core.css index 46640c4b722..76a3c03119f 100644 --- a/media/css/core.css +++ b/media/css/core.css @@ -836,27 +836,27 @@ ul.donate-supporters.donate-supporters-large div.supporter-name { font-size: .9em; } -div#payment-form div.cc-type { - height: 23px; - margin: 3px 0px 10px; - background: url('/static/donate/img/creditcard.png'); - background-repeat: no-repeat; -} -div#payment-form input#cc-number.visa + div.cc-type { - background-position: 0px -23px; +/* Gold */ +div.gold-subscription p.subscription-detail, +div.gold-subscription p.subscription-projects { + margin: 0em; } -div#payment-form input#cc-number.mastercard + div.cc-type { - background-position: 0px -46px; + +div.gold-subscription p.subscription-detail label { + display: inline-block; } -div#payment-form input#cc-number.amex + div.cc-type { - background-position: 0px -69px; + +div.gold-subscription p.subscription-detail-card > span { + font-family: monospace; } -div#payment-form input#cc-number.discover + div.cc-type { - background-position: 0px -92px; + +div.gold-subscription > form { + display: inline-block; } -div#payment-form input#cc-expiry { width: 150px; } -div#payment-form input#cc-cvv { width: 100px; } +div.gold-subscription > form button { + margin: 1em .3em 1.5em 0em; +} /* Form Wizards */ div.actions.wizard-actions button.action-primary, diff --git a/readthedocs/core/mixins.py b/readthedocs/core/mixins.py index 9c803dad756..a6ebdae96f1 100644 --- a/readthedocs/core/mixins.py +++ b/readthedocs/core/mixins.py @@ -2,21 +2,9 @@ Common mixin classes for views """ -from django.conf import settings - from vanilla import ListView - - -class StripeMixin(object): - - """Adds Stripe publishable key to the context data""" - - def get_context_data(self, **kwargs): - context = super(StripeMixin, self).get_context_data(**kwargs) - context.update({ - 'publishable': settings.STRIPE_PUBLISHABLE, - }) - return context +from django.contrib.auth.decorators import login_required +from django.utils.decorators import method_decorator class ListViewWithForm(ListView): @@ -27,3 +15,10 @@ def get_context_data(self, **kwargs): context = super(ListViewWithForm, self).get_context_data(**kwargs) context['form'] = self.get_form(data=None, files=None) return context + + +class LoginRequiredMixin(object): + + @method_decorator(login_required) + def dispatch(self, *args, **kwargs): + return super(LoginRequiredMixin, self).dispatch(*args, **kwargs) diff --git a/readthedocs/core/static-src/core/js/payment.js b/readthedocs/core/static-src/core/js/payment.js deleted file mode 100644 index b102bff309d..00000000000 --- a/readthedocs/core/static-src/core/js/payment.js +++ /dev/null @@ -1,99 +0,0 @@ -// Stripe payment form views - -var ko = require('knockout'), - payment = require('jquery.payment'), - $ = require('jquery'), - stripe = null; - - -// TODO stripe doesn't support loading locally very well, do they? -if (typeof(window) != 'undefined' && typeof(window.Stripe) != 'undefined') { - stripe = window.Stripe || {}; -} - -function PaymentView (config) { - var self = this, - config = config || {}; - - // Config - stripe.publishableKey = self.stripe_key = config.key; - self.form = config.form; - - // Credit card parameters - self.cc_number = ko.observable(null); - self.cc_expiry = ko.observable(null); - self.cc_cvv = ko.observable(null); - self.cc_error_number = ko.observable(null); - self.cc_error_expiry = ko.observable(null); - self.cc_error_cvv = ko.observable(null); - - // Credit card validation - self.initialize_form(); - - // Outputs - self.error = ko.observable(null); - - // Process form inputs and send API request to Stripe. Using jquery.payment - // for some validation, display field errors and some generic errors. - self.process_form = function () { - var expiry = $.payment.cardExpiryVal(self.cc_expiry()), - card = { - number: self.cc_number(), - exp_month: expiry.month, - exp_year: expiry.year, - cvc: self.cc_cvv() - }; - - self.error(null); - self.cc_error_number(null); - self.cc_error_expiry(null); - self.cc_error_cvv(null); - - if (!$.payment.validateCardNumber(card.number)) { - self.cc_error_number('Invalid card number'); - console.log(card); - return false; - } - if (!$.payment.validateCardExpiry(card.exp_month, card.exp_year)) { - self.cc_error_expiry('Invalid expiration date'); - return false; - } - if (!$.payment.validateCardCVC(card.cvc)) { - self.cc_error_cvv('Invalid security code'); - return false; - } - - stripe.createToken(card, function(status, response) { - if (status === 200) { - // Update form fields that are actually sent to - var cc_last_digits = self.form.find('#id_last_4_digits'), - token = self.form.find('#id_stripe_id,#id_stripe_token'); - cc_last_digits.val(response.card.last4); - token.val(response.id); - self.form.submit(); - } - else { - self.error(response.error.message); - } - }); - }; -} - -PaymentView.prototype.initialize_form = function () { - var cc_number = $('input#cc-number'), - cc_cvv = $('input#cc-cvv'), - cc_expiry = $('input#cc-expiry'); - - cc_number.payment('formatCardNumber'); - cc_expiry.payment('formatCardExpiry'); - cc_cvv.payment('formatCardCVC'); -}; - -PaymentView.init = function (config, obj) { - var view = new GoldView(config), - obj = obj || $('#payment-form')[0]; - ko.applyBindings(view, obj); - return view; -} - -module.exports.PaymentView = PaymentView; diff --git a/readthedocs/core/templates/core/ko_form_field.html b/readthedocs/core/templates/core/ko_form_field.html new file mode 100644 index 00000000000..111545dc3aa --- /dev/null +++ b/readthedocs/core/templates/core/ko_form_field.html @@ -0,0 +1,22 @@ +{% if field.is_hidden %} + {{ field }} +{% else %} + + {{ field.errors }} + {% if 'data-bind' in field.field.widget.attrs %} + + {% endif %} +

+ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} +

+ +{% endif %} diff --git a/readthedocs/donate/forms.py b/readthedocs/donate/forms.py index ad93e804abb..3348610f218 100644 --- a/readthedocs/donate/forms.py +++ b/readthedocs/donate/forms.py @@ -1,23 +1,32 @@ +"""Forms for RTD donations""" + import logging from django import forms from django.conf import settings from django.utils.translation import ugettext_lazy as _ -import stripe +from readthedocs.payments.forms import StripeModelForm, StripeResourceMixin +from readthedocs.payments.utils import stripe from .models import Supporter log = logging.getLogger(__name__) -class SupporterForm(forms.ModelForm): +class SupporterForm(StripeResourceMixin, StripeModelForm): + + """Donation support sign up form + + This extends the basic payment form, giving fields for credit card number, + expiry, and CVV. The proper Knockout data bindings are established on + :py:cls:`StripeModelForm` + """ class Meta: model = Supporter fields = ( 'last_4_digits', - 'stripe_id', 'name', 'email', 'dollars', @@ -43,38 +52,33 @@ class Meta: }), 'site_url': forms.TextInput(attrs={ 'data-bind': 'value: site_url, enable: urls_enabled' - }) + }), + 'last_4_digits': forms.TextInput(attrs={ + 'data-bind': 'valueInit: card_digits, value: card_digits' + }), } last_4_digits = forms.CharField(widget=forms.HiddenInput(), required=True) - stripe_id = forms.CharField(widget=forms.HiddenInput(), required=True) + name = forms.CharField(required=True) + email = forms.CharField(required=True) def __init__(self, *args, **kwargs): self.user = kwargs.pop('user') super(SupporterForm, self).__init__(*args, **kwargs) - def clean(self): - '''Call stripe for payment (not ideal here) and clean up logo < $200''' + def validate_stripe(self): + """Call stripe for payment (not ideal here) and clean up logo < $200""" dollars = self.cleaned_data['dollars'] if dollars < 200: self.cleaned_data['logo_url'] = None self.cleaned_data['site_url'] = None - try: - stripe.api_key = settings.STRIPE_SECRET - stripe.Charge.create( - amount=int(self.cleaned_data['dollars']) * 100, - currency='usd', - source=self.cleaned_data['stripe_id'], - description='Read the Docs Sustained Engineering', - receipt_email=self.cleaned_data['email'] - ) - except stripe.error.CardError, e: - stripe_error = e.json_body['error'] - log.error('Credit card error: %s', stripe_error['message']) - raise forms.ValidationError( - _('There was a problem processing your card: %(message)s'), - params=stripe_error) - return self.cleaned_data + stripe.Charge.create( + amount=int(self.cleaned_data['dollars']) * 100, + currency='usd', + source=self.cleaned_data['stripe_token'], + description='Read the Docs Sustained Engineering', + receipt_email=self.cleaned_data['email'] + ) def save(self, commit=True): supporter = super(SupporterForm, self).save(commit) diff --git a/readthedocs/donate/mixins.py b/readthedocs/donate/mixins.py index fdec627c5f1..f9eb4640fcd 100644 --- a/readthedocs/donate/mixins.py +++ b/readthedocs/donate/mixins.py @@ -1,6 +1,4 @@ -''' -Mixin classes for donation views -''' +"""Mixin classes for donation views""" from django.db.models import Avg, Sum @@ -8,10 +6,11 @@ class DonateProgressMixin(object): - '''Add donation progress to context data''' - def get_context_data(self): - context = super(DonateProgressMixin, self).get_context_data() + """Add donation progress to context data""" + + def get_context_data(self, **kwargs): + context = super(DonateProgressMixin, self).get_context_data(**kwargs) sums = (Supporter.objects .aggregate(dollars=Sum('dollars'))) avgs = (Supporter.objects diff --git a/readthedocs/donate/static-src/donate/js/donate.js b/readthedocs/donate/static-src/donate/js/donate.js index cbe6267fb53..4e1ba31bec1 100644 --- a/readthedocs/donate/static-src/donate/js/donate.js +++ b/readthedocs/donate/static-src/donate/js/donate.js @@ -1,18 +1,22 @@ // Donate payment views var jquery = require('jquery'), - payment = require('../../../../core/static-src/core/js/payment'), + payment = require('readthedocs/payments/static-src/payments/js/base'), ko = require('knockout'); function DonateView (config) { var self = this, config = config || {}; - ko.utils.extend(self, new payment.PaymentView(config)); + self.constructor.call(self, config); self.dollars = ko.observable(); self.logo_url = ko.observable(); self.site_url = ko.observable(); + self.error_dollars = ko.observable(); + self.error_logo_url = ko.observable(); + self.error_site_url = ko.observable(); + ko.computed(function () { var input_logo = $('input#id_logo_url').closest('p'), input_site = $('input#id_site_url').closest('p'); @@ -32,6 +36,8 @@ function DonateView (config) { }); } +DonateView.prototype = new payment.PaymentView(); + DonateView.init = function (config, obj) { var view = new DonateView(config), obj = obj || $('#donate-payment')[0]; diff --git a/readthedocs/donate/static/donate/img/creditcard.png b/readthedocs/donate/static/donate/img/creditcard.png index bc56007e8c7..0fe91a8bb17 100644 Binary files a/readthedocs/donate/static/donate/img/creditcard.png and b/readthedocs/donate/static/donate/img/creditcard.png differ diff --git a/readthedocs/donate/static/donate/js/donate.js b/readthedocs/donate/static/donate/js/donate.js index bbf90bc53b0..5faa93b1774 100644 --- a/readthedocs/donate/static/donate/js/donate.js +++ b/readthedocs/donate/static/donate/js/donate.js @@ -1 +1 @@ -require=function e(t,n,r){function o(i,u){if(!n[i]){if(!t[i]){var l="function"==typeof require&&require;if(!u&&l)return l(i,!0);if(a)return a(i,!0);var c=new Error("Cannot find module '"+i+"'");throw c.code="MODULE_NOT_FOUND",c}var s=n[i]={exports:{}};t[i][0].call(s.exports,function(e){var n=t[i][1][e];return o(n?n:e)},s,s.exports,e,t,n,r)}return n[i].exports}for(var a="function"==typeof require&&require,i=0;it;t++)if(t in this&&this[t]===e)return t;return-1};$.payment={},$.payment.fn={},$.fn.payment=function(){var e,t;return t=arguments[0],e=2<=arguments.length?C.call(arguments,1):[],$.payment.fn[t].apply(this,e)},r=/(\d{1,4})/g,$.payment.cards=n=[{type:"visaelectron",pattern:/^4(026|17500|405|508|844|91[37])/,format:r,length:[16],cvcLength:[3],luhn:!0},{type:"maestro",pattern:/^(5(018|0[23]|[68])|6(39|7))/,format:r,length:[12,13,14,15,16,17,18,19],cvcLength:[3],luhn:!0},{type:"forbrugsforeningen",pattern:/^600/,format:r,length:[16],cvcLength:[3],luhn:!0},{type:"dankort",pattern:/^5019/,format:r,length:[16],cvcLength:[3],luhn:!0},{type:"visa",pattern:/^4/,format:r,length:[13,16],cvcLength:[3],luhn:!0},{type:"mastercard",pattern:/^(5[0-5]|2[2-7])/,format:r,length:[16],cvcLength:[3],luhn:!0},{type:"amex",pattern:/^3[47]/,format:/(\d{1,4})(\d{1,6})?(\d{1,5})?/,length:[15],cvcLength:[3,4],luhn:!0},{type:"dinersclub",pattern:/^3[0689]/,format:/(\d{1,4})(\d{1,6})?(\d{1,4})?/,length:[14],cvcLength:[3],luhn:!0},{type:"discover",pattern:/^6([045]|22)/,format:r,length:[16],cvcLength:[3],luhn:!0},{type:"unionpay",pattern:/^(62|88)/,format:r,length:[16,17,18,19],cvcLength:[3],luhn:!1},{type:"jcb",pattern:/^35/,format:r,length:[16],cvcLength:[3],luhn:!0}],e=function(e){var t,r,o;for(e=(e+"").replace(/\D/g,""),r=0,o=n.length;o>r;r++)if(t=n[r],t.pattern.test(e))return t},t=function(e){var t,r,o;for(r=0,o=n.length;o>r;r++)if(t=n[r],t.type===e)return t},p=function(e){var t,n,r,o,a,i;for(r=!0,o=0,n=(e+"").split("").reverse(),a=0,i=n.length;i>a;a++)t=n[a],t=parseInt(t,10),(r=!r)&&(t*=2),t>9&&(t-=9),o+=t;return o%10===0},s=function(e){var t;return null!=e.prop("selectionStart")&&e.prop("selectionStart")!==e.prop("selectionEnd")?!0:null!=("undefined"!=typeof document&&null!==document&&null!=(t=document.selection)?t.createRange:void 0)&&document.selection.createRange().text?!0:!1},f=function(e){return setTimeout(function(){var t,n;return t=$(e.currentTarget),n=t.val(),n=n.replace(/\D/g,""),t.val(n)})},d=function(e){return setTimeout(function(){var t,n;return t=$(e.currentTarget),n=t.val(),n=$.payment.formatCardNumber(n),t.val(n)})},i=function(t){var n,r,o,a,i,u,l;return o=String.fromCharCode(t.which),!/^\d+$/.test(o)||(n=$(t.currentTarget),l=n.val(),r=e(l+o),a=(l.replace(/\D/g,"")+o).length,u=16,r&&(u=r.length[r.length.length-1]),a>=u||null!=n.prop("selectionStart")&&n.prop("selectionStart")!==l.length)?void 0:(i=r&&"amex"===r.type?/^(\d{4}|\d{4}\s\d{6})$/:/(?:^|\s)(\d{4})$/,i.test(l)?(t.preventDefault(),setTimeout(function(){return n.val(l+" "+o)})):i.test(l+o)?(t.preventDefault(),setTimeout(function(){return n.val(l+o+" ")})):void 0)},o=function(e){var t,n;return t=$(e.currentTarget),n=t.val(),8!==e.which||null!=t.prop("selectionStart")&&t.prop("selectionStart")!==n.length?void 0:/\d\s$/.test(n)?(e.preventDefault(),setTimeout(function(){return t.val(n.replace(/\d\s$/,""))})):/\s\d?$/.test(n)?(e.preventDefault(),setTimeout(function(){return t.val(n.replace(/\d$/,""))})):void 0},v=function(e){return setTimeout(function(){var t,n;return t=$(e.currentTarget),n=t.val(),n=$.payment.formatExpiry(n),t.val(n)})},u=function(e){var t,n,r;return n=String.fromCharCode(e.which),/^\d+$/.test(n)?(t=$(e.currentTarget),r=t.val()+n,/^\d$/.test(r)&&"0"!==r&&"1"!==r?(e.preventDefault(),setTimeout(function(){return t.val("0"+r+" / ")})):/^\d\d$/.test(r)?(e.preventDefault(),setTimeout(function(){return t.val(""+r+" / ")})):void 0):void 0},l=function(e){var t,n,r;return n=String.fromCharCode(e.which),/^\d+$/.test(n)?(t=$(e.currentTarget),r=t.val(),/^\d\d$/.test(r)?t.val(""+r+" / "):void 0):void 0},c=function(e){var t,n,r;return r=String.fromCharCode(e.which),"/"===r||" "===r?(t=$(e.currentTarget),n=t.val(),/^\d$/.test(n)&&"0"!==n?t.val("0"+n+" / "):void 0):void 0},a=function(e){var t,n;return t=$(e.currentTarget),n=t.val(),8!==e.which||null!=t.prop("selectionStart")&&t.prop("selectionStart")!==n.length?void 0:/\d\s\/\s$/.test(n)?(e.preventDefault(),setTimeout(function(){return t.val(n.replace(/\d\s\/\s$/,""))})):void 0},h=function(e){return setTimeout(function(){var t,n;return t=$(e.currentTarget),n=t.val(),n=n.replace(/\D/g,"").slice(0,4),t.val(n)})},_=function(e){var t;return e.metaKey||e.ctrlKey?!0:32===e.which?!1:0===e.which?!0:e.which<33?!0:(t=String.fromCharCode(e.which),!!/[\d\s]/.test(t))},g=function(t){var n,r,o,a;return n=$(t.currentTarget),o=String.fromCharCode(t.which),/^\d+$/.test(o)&&!s(n)?(a=(n.val()+o).replace(/\D/g,""),r=e(a),r?a.length<=r.length[r.length.length-1]:a.length<=16):void 0},y=function(e){var t,n,r;return t=$(e.currentTarget),n=String.fromCharCode(e.which),/^\d+$/.test(n)&&!s(t)?(r=t.val()+n,r=r.replace(/\D/g,""),r.length>6?!1:void 0):void 0},m=function(e){var t,n,r;return t=$(e.currentTarget),n=String.fromCharCode(e.which),/^\d+$/.test(n)&&!s(t)?(r=t.val()+n,r.length<=4):void 0},b=function(e){var t,r,o,a,i;return t=$(e.currentTarget),i=t.val(),a=$.payment.cardType(i)||"unknown",t.hasClass(a)?void 0:(r=function(){var e,t,r;for(r=[],e=0,t=n.length;t>e;e++)o=n[e],r.push(o.type);return r}(),t.removeClass("unknown"),t.removeClass(r.join(" ")),t.addClass(a),t.toggleClass("identified","unknown"!==a),t.trigger("payment.cardType",a))},$.payment.fn.formatCardCVC=function(){return this.on("keypress",_),this.on("keypress",m),this.on("paste",h),this.on("change",h),this.on("input",h),this},$.payment.fn.formatCardExpiry=function(){return this.on("keypress",_),this.on("keypress",y),this.on("keypress",u),this.on("keypress",c),this.on("keypress",l),this.on("keydown",a),this.on("change",v),this.on("input",v),this},$.payment.fn.formatCardNumber=function(){return this.on("keypress",_),this.on("keypress",g),this.on("keypress",i),this.on("keydown",o),this.on("keyup",b),this.on("paste",d),this.on("change",d),this.on("input",d),this.on("input",b),this},$.payment.fn.restrictNumeric=function(){return this.on("keypress",_),this.on("paste",f),this.on("change",f),this.on("input",f),this},$.payment.fn.cardExpiryVal=function(){return $.payment.cardExpiryVal($(this).val())},$.payment.cardExpiryVal=function(e){var t,n,r,o;return e=e.replace(/\s/g,""),o=e.split("/",2),t=o[0],r=o[1],2===(null!=r?r.length:void 0)&&/^\d+$/.test(r)&&(n=(new Date).getFullYear(),n=n.toString().slice(0,2),r=n+r),t=parseInt(t,10),r=parseInt(r,10),{month:t,year:r}},$.payment.validateCardNumber=function(t){var n,r;return t=(t+"").replace(/\s+|-/g,""),/^\d+$/.test(t)?(n=e(t),n?(r=t.length,w.call(n.length,r)>=0&&(n.luhn===!1||p(t))):!1):!1},$.payment.validateCardExpiry=function(e,t){var n,r,o;return"object"==typeof e&&"month"in e&&(o=e,e=o.month,t=o.year),e&&t?(e=$.trim(e),t=$.trim(t),/^\d+$/.test(e)&&/^\d+$/.test(t)&&e>=1&&12>=e?(2===t.length&&(t=70>t?"20"+t:"19"+t),4!==t.length?!1:(r=new Date(t,e),n=new Date,r.setMonth(r.getMonth()-1),r.setMonth(r.getMonth()+1,1),r>n)):!1):!1},$.payment.validateCardCVC=function(e,n){var r,o;return e=$.trim(e),/^\d+$/.test(e)?(r=t(n),null!=r?(o=e.length,w.call(r.cvcLength,o)>=0):e.length>=3&&e.length<=4):!1},$.payment.cardType=function(t){var n;return t?(null!=(n=e(t))?n.type:void 0)||null:null},$.payment.formatCardNumber=function(t){var n,r,o,a;return t=t.replace(/\D/g,""),(n=e(t))?(o=n.length[n.length.length-1],t=t.slice(0,o),n.format.global?null!=(a=t.match(n.format))?a.join(" "):void 0:(r=n.format.exec(t),null!=r?(r.shift(),r=$.grep(r,function(e){return e}),r.join(" ")):void 0)):t},$.payment.formatExpiry=function(e){var t,n,r,o;return(n=e.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/))?(t=n[1]||"",r=n[2]||"",o=n[3]||"",o.length>0?r=" / ":" /"===r?(t=t.substring(0,1),r=""):2===t.length||r.length>0?r=" / ":1===t.length&&"0"!==t&&"1"!==t&&(t="0"+t,r=" / "),t+r+o):""}}).call(this)},{}],2:[function(e,t,n){function r(e){var t=this,e=e||{};i.publishableKey=t.stripe_key=e.key,t.form=e.form,t.cc_number=o.observable(null),t.cc_expiry=o.observable(null),t.cc_cvv=o.observable(null),t.cc_error_number=o.observable(null),t.cc_error_expiry=o.observable(null),t.cc_error_cvv=o.observable(null),t.initialize_form(),t.error=o.observable(null),t.process_form=function(){var e=a.payment.cardExpiryVal(t.cc_expiry()),n={number:t.cc_number(),exp_month:e.month,exp_year:e.year,cvc:t.cc_cvv()};return t.error(null),t.cc_error_number(null),t.cc_error_expiry(null),t.cc_error_cvv(null),a.payment.validateCardNumber(n.number)?a.payment.validateCardExpiry(n.exp_month,n.exp_year)?a.payment.validateCardCVC(n.cvc)?void i.createToken(n,function(e,n){if(200===e){var r=t.form.find("#id_last_4_digits"),o=t.form.find("#id_stripe_id,#id_stripe_token");r.val(n.card.last4),o.val(n.id),t.form.submit()}else t.error(n.error.message)}):(t.cc_error_cvv("Invalid security code"),!1):(t.cc_error_expiry("Invalid expiration date"),!1):(t.cc_error_number("Invalid card number"),console.log(n),!1)}}var o=e("knockout"),a=(e("./../../../../../bower_components/jquery.payment/lib/jquery.payment.js"),e("jquery")),i=null;"undefined"!=typeof window&&"undefined"!=typeof window.Stripe&&(i=window.Stripe||{}),r.prototype.initialize_form=function(){var e=a("input#cc-number"),t=a("input#cc-cvv"),n=a("input#cc-expiry");e.payment("formatCardNumber"),n.payment("formatCardExpiry"),t.payment("formatCardCVC")},r.init=function(e,t){var n=new GoldView(e),t=t||a("#payment-form")[0];return o.applyBindings(n,t),n},t.exports.PaymentView=r},{"./../../../../../bower_components/jquery.payment/lib/jquery.payment.js":1,jquery:"jquery",knockout:"knockout"}],"donate/donate":[function(e,t,n){function r(e){var t=this,e=e||{};a.utils.extend(t,new o.PaymentView(e)),t.dollars=a.observable(),t.logo_url=a.observable(),t.site_url=a.observable(),a.computed(function(){var e=$("input#id_logo_url").closest("p"),n=$("input#id_site_url").closest("p");t.dollars()<400?(t.logo_url(null),t.site_url(null),e.hide(),n.hide()):(e.show(),n.show())}),t.urls_enabled=a.computed(function(){return t.dollars()>=400})}var o=(e("jquery"),e("../../../../core/static-src/core/js/payment")),a=e("knockout");r.init=function(e,t){var n=new r(e),t=t||$("#donate-payment")[0];return a.applyBindings(n,t),n},t.exports.DonateView=r},{"../../../../core/static-src/core/js/payment":2,jquery:"jquery",knockout:"knockout"}]},{},[]); \ No newline at end of file +require=function e(r,t,n){function o(i,c){if(!t[i]){if(!r[i]){var u="function"==typeof require&&require;if(!c&&u)return u(i,!0);if(a)return a(i,!0);var l=new Error("Cannot find module '"+i+"'");throw l.code="MODULE_NOT_FOUND",l}var s=t[i]={exports:{}};r[i][0].call(s.exports,function(e){var t=r[i][1][e];return o(t?t:e)},s,s.exports,e,r,t,n)}return t[i].exports}for(var a="function"==typeof require&&require,i=0;ir;r++)if(r in this&&this[r]===e)return r;return-1};e=window.jQuery||window.Zepto||window.$,e.payment={},e.payment.fn={},e.fn.payment=function(){var r,t;return t=arguments[0],r=2<=arguments.length?k.call(arguments,1):[],e.payment.fn[t].apply(this,r)},o=/(\d{1,4})/g,e.payment.cards=n=[{type:"visaelectron",pattern:/^4(026|17500|405|508|844|91[37])/,format:o,length:[16],cvcLength:[3],luhn:!0},{type:"maestro",pattern:/^(5(018|0[23]|[68])|6(39|7))/,format:o,length:[12,13,14,15,16,17,18,19],cvcLength:[3],luhn:!0},{type:"forbrugsforeningen",pattern:/^600/,format:o,length:[16],cvcLength:[3],luhn:!0},{type:"dankort",pattern:/^5019/,format:o,length:[16],cvcLength:[3],luhn:!0},{type:"visa",pattern:/^4/,format:o,length:[13,16],cvcLength:[3],luhn:!0},{type:"mastercard",pattern:/^(5[1-5]|2[2-7])/,format:o,length:[16],cvcLength:[3],luhn:!0},{type:"amex",pattern:/^3[47]/,format:/(\d{1,4})(\d{1,6})?(\d{1,5})?/,length:[15],cvcLength:[3,4],luhn:!0},{type:"dinersclub",pattern:/^3[0689]/,format:/(\d{1,4})(\d{1,6})?(\d{1,4})?/,length:[14],cvcLength:[3],luhn:!0},{type:"discover",pattern:/^6([045]|22)/,format:o,length:[16],cvcLength:[3],luhn:!0},{type:"unionpay",pattern:/^(62|88)/,format:o,length:[16,17,18,19],cvcLength:[3],luhn:!1},{type:"jcb",pattern:/^35/,format:o,length:[16],cvcLength:[3],luhn:!0}],r=function(e){var r,t,o;for(e=(e+"").replace(/\D/g,""),t=0,o=n.length;o>t;t++)if(r=n[t],r.pattern.test(e))return r},t=function(e){var r,t,o;for(t=0,o=n.length;o>t;t++)if(r=n[t],r.type===e)return r},d=function(e){var r,t,n,o,a,i;for(n=!0,o=0,t=(e+"").split("").reverse(),a=0,i=t.length;i>a;a++)r=t[a],r=parseInt(r,10),(n=!n)&&(r*=2),r>9&&(r-=9),o+=r;return o%10===0},p=function(e){var r;return null!=e.prop("selectionStart")&&e.prop("selectionStart")!==e.prop("selectionEnd")?!0:null!=("undefined"!=typeof document&&null!==document&&null!=(r=document.selection)?r.createRange:void 0)&&document.selection.createRange().text?!0:!1},C=function(e,r){var t,n,o;try{t=r.prop("selectionStart")}catch(a){n=a,t=null}return o=r.val(),r.val(e),null!==t&&r.is(":focus")?(t===o.length&&(t=e.length),r.prop("selectionStart",t),r.prop("selectionEnd",t)):void 0},y=function(e){var r,t,n,o,a,i,c,u;for(null==e&&(e=""),n="0123456789",o="0123456789",i="",t=e.split(""),c=0,u=t.length;u>c;c++)r=t[c],a=n.indexOf(r),a>-1&&(r=o[a]),i+=r;return i},m=function(r){return setTimeout(function(){var t,n;return t=e(r.currentTarget),n=t.val(),n=y(n),n=n.replace(/\D/g,""),C(n,t)})},v=function(r){return setTimeout(function(){var t,n;return t=e(r.currentTarget),n=t.val(),n=y(n),n=e.payment.formatCardNumber(n),C(n,t)})},c=function(t){var n,o,a,i,c,u,l;return a=String.fromCharCode(t.which),!/^\d+$/.test(a)||(n=e(t.currentTarget),l=n.val(),o=r(l+a),i=(l.replace(/\D/g,"")+a).length,u=16,o&&(u=o.length[o.length.length-1]),i>=u||null!=n.prop("selectionStart")&&n.prop("selectionStart")!==l.length)?void 0:(c=o&&"amex"===o.type?/^(\d{4}|\d{4}\s\d{6})$/:/(?:^|\s)(\d{4})$/,c.test(l)?(t.preventDefault(),setTimeout(function(){return n.val(l+" "+a)})):c.test(l+a)?(t.preventDefault(),setTimeout(function(){return n.val(l+a+" ")})):void 0)},a=function(r){var t,n;return t=e(r.currentTarget),n=t.val(),8!==r.which||null!=t.prop("selectionStart")&&t.prop("selectionStart")!==n.length?void 0:/\d\s$/.test(n)?(r.preventDefault(),setTimeout(function(){return t.val(n.replace(/\d\s$/,""))})):/\s\d?$/.test(n)?(r.preventDefault(),setTimeout(function(){return t.val(n.replace(/\d$/,""))})):void 0},f=function(r){return setTimeout(function(){var t,n;return t=e(r.currentTarget),n=t.val(),n=y(n),n=e.payment.formatExpiry(n),C(n,t)})},u=function(r){var t,n,o;return n=String.fromCharCode(r.which),/^\d+$/.test(n)?(t=e(r.currentTarget),o=t.val()+n,/^\d$/.test(o)&&"0"!==o&&"1"!==o?(r.preventDefault(),setTimeout(function(){return t.val("0"+o+" / ")})):/^\d\d$/.test(o)?(r.preventDefault(),setTimeout(function(){return t.val(""+o+" / ")})):void 0):void 0},l=function(r){var t,n,o;return n=String.fromCharCode(r.which),/^\d+$/.test(n)?(t=e(r.currentTarget),o=t.val(),/^\d\d$/.test(o)?t.val(""+o+" / "):void 0):void 0},s=function(r){var t,n,o;return o=String.fromCharCode(r.which),"/"===o||" "===o?(t=e(r.currentTarget),n=t.val(),/^\d$/.test(n)&&"0"!==n?t.val("0"+n+" / "):void 0):void 0},i=function(r){var t,n;return t=e(r.currentTarget),n=t.val(),8!==r.which||null!=t.prop("selectionStart")&&t.prop("selectionStart")!==n.length?void 0:/\d\s\/\s$/.test(n)?(r.preventDefault(),setTimeout(function(){return t.val(n.replace(/\d\s\/\s$/,""))})):void 0},h=function(r){return setTimeout(function(){var t,n;return t=e(r.currentTarget),n=t.val(),n=y(n),n=n.replace(/\D/g,"").slice(0,4),C(n,t)})},w=function(e){var r;return e.metaKey||e.ctrlKey?!0:32===e.which?!1:0===e.which?!0:e.which<33?!0:(r=String.fromCharCode(e.which),!!/[\d\s]/.test(r))},_=function(t){var n,o,a,i;return n=e(t.currentTarget),a=String.fromCharCode(t.which),/^\d+$/.test(a)&&!p(n)?(i=(n.val()+a).replace(/\D/g,""),o=r(i),o?i.length<=o.length[o.length.length-1]:i.length<=16):void 0},b=function(r){var t,n,o;return t=e(r.currentTarget),n=String.fromCharCode(r.which),/^\d+$/.test(n)&&!p(t)?(o=t.val()+n,o=o.replace(/\D/g,""),o.length>6?!1:void 0):void 0},g=function(r){var t,n,o;return t=e(r.currentTarget),n=String.fromCharCode(r.which),/^\d+$/.test(n)&&!p(t)?(o=t.val()+n,o.length<=4):void 0},x=function(r){var t,o,a,i,c;return t=e(r.currentTarget),c=t.val(),i=e.payment.cardType(c)||"unknown",t.hasClass(i)?void 0:(o=function(){var e,r,t;for(t=[],e=0,r=n.length;r>e;e++)a=n[e],t.push(a.type);return t}(),t.removeClass("unknown"),t.removeClass(o.join(" ")),t.addClass(i),t.toggleClass("identified","unknown"!==i),t.trigger("payment.cardType",i))},e.payment.fn.formatCardCVC=function(){return this.on("keypress",w),this.on("keypress",g),this.on("paste",h),this.on("change",h),this.on("input",h),this},e.payment.fn.formatCardExpiry=function(){return this.on("keypress",w),this.on("keypress",b),this.on("keypress",u),this.on("keypress",s),this.on("keypress",l),this.on("keydown",i),this.on("change",f),this.on("input",f),this},e.payment.fn.formatCardNumber=function(){return this.on("keypress",w),this.on("keypress",_),this.on("keypress",c),this.on("keydown",a),this.on("keyup",x),this.on("paste",v),this.on("change",v),this.on("input",v),this.on("input",x),this},e.payment.fn.restrictNumeric=function(){return this.on("keypress",w),this.on("paste",m),this.on("change",m),this.on("input",m),this},e.payment.fn.cardExpiryVal=function(){return e.payment.cardExpiryVal(e(this).val())},e.payment.cardExpiryVal=function(e){var r,t,n,o;return o=e.split(/[\s\/]+/,2),r=o[0],n=o[1],2===(null!=n?n.length:void 0)&&/^\d+$/.test(n)&&(t=(new Date).getFullYear(),t=t.toString().slice(0,2),n=t+n),r=parseInt(r,10),n=parseInt(n,10),{month:r,year:n}},e.payment.validateCardNumber=function(e){var t,n;return e=(e+"").replace(/\s+|-/g,""),/^\d+$/.test(e)?(t=r(e),t?(n=e.length,T.call(t.length,n)>=0&&(t.luhn===!1||d(e))):!1):!1},e.payment.validateCardExpiry=function(r,t){var n,o,a;return"object"==typeof r&&"month"in r&&(a=r,r=a.month,t=a.year),r&&t?(r=e.trim(r),t=e.trim(t),/^\d+$/.test(r)&&/^\d+$/.test(t)&&r>=1&&12>=r?(2===t.length&&(t=70>t?"20"+t:"19"+t),4!==t.length?!1:(o=new Date(t,r),n=new Date,o.setMonth(o.getMonth()-1),o.setMonth(o.getMonth()+1,1),o>n)):!1):!1},e.payment.validateCardCVC=function(r,n){var o,a;return r=e.trim(r),/^\d+$/.test(r)?(o=t(n),null!=o?(a=r.length,T.call(o.cvcLength,a)>=0):r.length>=3&&r.length<=4):!1},e.payment.cardType=function(e){var t;return e?(null!=(t=r(e))?t.type:void 0)||null:null},e.payment.formatCardNumber=function(t){var n,o,a,i;return t=t.replace(/\D/g,""),(n=r(t))?(a=n.length[n.length.length-1],t=t.slice(0,a),n.format.global?null!=(i=t.match(n.format))?i.join(" "):void 0:(o=n.format.exec(t),null!=o?(o.shift(),o=e.grep(o,function(e){return e}),o.join(" ")):void 0)):t},e.payment.formatExpiry=function(e){var r,t,n,o;return(t=e.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/))?(r=t[1]||"",n=t[2]||"",o=t[3]||"",o.length>0?n=" / ":" /"===n?(r=r.substring(0,1),n=""):2===r.length||n.length>0?n=" / ":1===r.length&&"0"!==r&&"1"!==r&&(r="0"+r,n=" / "),r+n+o):""}}).call(this)},{}],2:[function(e,r,t){function n(e){var r=this,e=e||{};i.publishableKey=r.stripe_key=e.key,r.form=e.form,r.cc_number=o.observable(null),r.cc_expiry=o.observable(null),r.cc_cvv=o.observable(null),r.error_cc_number=o.observable(null),r.error_cc_expiry=o.observable(null),r.error_cc_cvv=o.observable(null),r.stripe_token=o.observable(null),r.card_digits=o.observable(null),r.is_editing_card=o.observable(!1),r.show_card_form=o.computed(function(){return r.is_editing_card()||!r.card_digits()||r.cc_number()||r.cc_expiry()||r.cc_cvv()}),r.initialize_form(),r.error=o.observable(null),r.process_form=function(){var e=a.payment.cardExpiryVal(r.cc_expiry()),t={number:r.cc_number(),exp_month:e.month,exp_year:e.year,cvc:r.cc_cvv()};return r.error(null),r.error_cc_number(null),r.error_cc_expiry(null),r.error_cc_cvv(null),a.payment.validateCardNumber(t.number)?a.payment.validateCardExpiry(t.exp_month,t.exp_year)?a.payment.validateCardCVC(t.cvc)?void i.createToken(t,function(e,t){if(t.error)if("card_error"==t.error.type){var n={invalid_number:r.error_cc_number,incorrect_number:r.error_cc_number,expired_card:r.error_cc_number,card_declined:r.error_cc_number,invalid_expiry_month:r.error_cc_expiry,invalid_expiry_year:r.error_cc_expiry,invalid_cvc:r.error_cc_cvv,incorrect_cvc:r.error_cc_cvv},o=n[t.error.code]||r.error_cc_number;o(t.error.message)}else r.error_cc_number(t.error.message);else r.submit_form(t.card.last4,t.id)}):(r.error_cc_cvv("Invalid security code"),!1):(r.error_cc_expiry("Invalid expiration date"),!1):(r.error_cc_number("Invalid card number"),!1)},r.process_full_form=function(){return r.show_card_form()?void r.process_form():!0}}var o=e("knockout"),a=(e("./../../../../../bower_components/jquery.payment/lib/jquery.payment.js"),e("jquery")),i=null;"undefined"!=typeof window&&"undefined"!=typeof window.Stripe&&(i=window.Stripe||{}),o.bindingHandlers.valueInit={init:function(e,r){var t=r();o.isWriteableObservable(t)&&t(e.value)}},n.prototype.submit_form=function(e,r){this.form.find("#id_card_digits").val(e),this.form.find("#id_stripe_token").val(r),this.form.submit()},n.prototype.initialize_form=function(){var e=a("input#id_cc_number"),r=a("input#id_cc_cvv"),t=a("input#id_cc_expiry");e.payment("formatCardNumber"),t.payment("formatCardExpiry"),r.payment("formatCardCVC"),e.trigger("keyup")},n.init=function(e,r){var t=new n(e),r=r||a("#payment-form")[0];return o.applyBindings(t,r),t},r.exports.PaymentView=n},{"./../../../../../bower_components/jquery.payment/lib/jquery.payment.js":1,jquery:"jquery",knockout:"knockout"}],"donate/donate":[function(e,r,t){function n(e){var r=this,e=e||{};r.constructor.call(r,e),r.dollars=a.observable(),r.logo_url=a.observable(),r.site_url=a.observable(),r.error_dollars=a.observable(),r.error_logo_url=a.observable(),r.error_site_url=a.observable(),a.computed(function(){var e=$("input#id_logo_url").closest("p"),t=$("input#id_site_url").closest("p");r.dollars()<400?(r.logo_url(null),r.site_url(null),e.hide(),t.hide()):(e.show(),t.show())}),r.urls_enabled=a.computed(function(){return r.dollars()>=400})}var o=(e("jquery"),e("readthedocs/payments/static-src/payments/js/base")),a=e("knockout");n.prototype=new o.PaymentView,n.init=function(e,r){var t=new n(e),r=r||$("#donate-payment")[0];return a.applyBindings(t,r),t},r.exports.DonateView=n},{jquery:"jquery",knockout:"knockout","readthedocs/payments/static-src/payments/js/base":2}]},{},[]); \ No newline at end of file diff --git a/readthedocs/donate/templates/donate/create.html b/readthedocs/donate/templates/donate/create.html index 778893eb60c..31ec43001bc 100644 --- a/readthedocs/donate/templates/donate/create.html +++ b/readthedocs/donate/templates/donate/create.html @@ -5,6 +5,10 @@ {% block title %}{% trans "Sustainability" %}{% endblock %} +{% block extra_links %} + +{% endblock %} + {% block extra_scripts %} @@ -14,7 +18,7 @@ $(document).ready(function () { var key; // var view = donate_views.DonateView.init({ @@ -36,9 +40,24 @@

Donate to Read the Docs

payment is processed directly through Stripe.

- {% endblock %} diff --git a/readthedocs/donate/views.py b/readthedocs/donate/views.py index f183314c2c5..123383759c2 100644 --- a/readthedocs/donate/views.py +++ b/readthedocs/donate/views.py @@ -1,16 +1,14 @@ -''' -Donation views -''' +"""Donation views""" import logging -from django.views.generic import CreateView, ListView, TemplateView +from django.views.generic import TemplateView from django.core.urlresolvers import reverse -from django.http import HttpResponseRedirect -from django.contrib.messages.views import SuccessMessageMixin from django.utils.translation import ugettext_lazy as _ +from vanilla import CreateView, ListView + +from readthedocs.payments.mixins import StripeMixin -from readthedocs.core.mixins import StripeMixin from .models import Supporter from .forms import SupporterForm from .mixins import DonateProgressMixin @@ -18,8 +16,9 @@ log = logging.getLogger(__name__) -class DonateCreateView(SuccessMessageMixin, StripeMixin, CreateView): - '''Create a donation locally and in Stripe''' +class DonateCreateView(StripeMixin, CreateView): + + """Create a donation locally and in Stripe""" form_class = SupporterForm success_message = _('Your contribution has been received') @@ -31,10 +30,9 @@ def get_success_url(self): def get_initial(self): return {'dollars': self.request.GET.get('dollars', 50)} - def get_form_kwargs(self): - kwargs = super(DonateCreateView, self).get_form_kwargs() + def get_form(self, data=None, files=None, **kwargs): kwargs['user'] = self.request.user - return kwargs + return super(DonateCreateView, self).get_form(data, files, **kwargs) class DonateSuccessView(TemplateView): @@ -42,7 +40,8 @@ class DonateSuccessView(TemplateView): class DonateListView(DonateProgressMixin, ListView): - '''Donation list and detail view''' + + """Donation list and detail view""" template_name = 'donate/list.html' model = Supporter diff --git a/readthedocs/gold/__init__.py b/readthedocs/gold/__init__.py index e69de29bb2d..a3104b1b034 100644 --- a/readthedocs/gold/__init__.py +++ b/readthedocs/gold/__init__.py @@ -0,0 +1 @@ +default_app_config = 'readthedocs.gold.apps.GoldAppConfig' diff --git a/readthedocs/gold/apps.py b/readthedocs/gold/apps.py new file mode 100644 index 00000000000..ebedb51b9d0 --- /dev/null +++ b/readthedocs/gold/apps.py @@ -0,0 +1,18 @@ +"""Gold application config for establishing signals""" + +import logging + +from django.apps import AppConfig + +log = logging.getLogger(__name__) + + +class GoldAppConfig(AppConfig): + name = 'readthedocs.gold' + verbose_name = 'Read the Docs Gold' + + def ready(self): + if hasattr(self, 'already_run'): + return + self.already_run = True + import readthedocs.gold.signals # noqa diff --git a/readthedocs/gold/forms.py b/readthedocs/gold/forms.py index fa4f299b08b..b6930d02e3c 100644 --- a/readthedocs/gold/forms.py +++ b/readthedocs/gold/forms.py @@ -1,20 +1,34 @@ +"""Gold subscription forms""" + from django import forms -from .models import LEVEL_CHOICES +from stripe.error import InvalidRequestError +from readthedocs.payments.forms import StripeModelForm, StripeResourceMixin + +from .models import LEVEL_CHOICES, GoldUser + + +class GoldSubscriptionForm(StripeResourceMixin, StripeModelForm): + """Gold subscription payment form -class CardForm(forms.Form): + This extends the common base form for handling Stripe subscriptions. Credit + card fields for card number, expiry, and CVV are extended from + :py:cls:`StripeModelForm`, with additional methods from + :py:cls:`StripeResourceMixin` for common operations against the Stripe API. + """ + + class Meta: + model = GoldUser + fields = ['last_4_digits', 'level'] last_4_digits = forms.CharField( required=True, min_length=4, max_length=4, - widget=forms.HiddenInput() - ) - - stripe_token = forms.CharField( - required=True, - widget=forms.HiddenInput() + widget=forms.HiddenInput(attrs={ + 'data-bind': 'valueInit: card_digits, value: card_digits' + }) ) level = forms.ChoiceField( @@ -22,6 +36,44 @@ class CardForm(forms.Form): choices=LEVEL_CHOICES, ) + def clean(self): + self.instance.user = self.customer + return super(GoldSubscriptionForm, self).clean() + + def validate_stripe(self): + subscription = self.get_subscription() + self.instance.stripe_id = subscription.customer + self.instance.subscribed = True + + def get_customer_kwargs(self): + return { + 'description': self.customer.get_full_name() or self.customer.username, + 'email': self.customer.email, + 'id': self.instance.stripe_id or None + } + + def get_subscription(self): + customer = self.get_customer() + try: + # TODO get the first sub more intelligently + subscriptions = customer.subscriptions.all(limit=5) + subscription = subscriptions.data[0] + subscription.plan = self.cleaned_data['level'] + if 'stripe_token' in self.cleaned_data: + subscription.source = self.cleaned_data['stripe_token'] + subscription.save() + return subscription + except (InvalidRequestError, AttributeError, IndexError): + subscription = customer.subscriptions.create( + plan=self.cleaned_data['level'], + source=self.cleaned_data['stripe_token'] + ) + return subscription + + def clear_card_data(self): + super(GoldSubscriptionForm, self).clear_card_data() + self.data['last_4_digits'] = None + class GoldProjectForm(forms.Form): project = forms.CharField( diff --git a/readthedocs/gold/signals.py b/readthedocs/gold/signals.py new file mode 100644 index 00000000000..4c089ebb0d5 --- /dev/null +++ b/readthedocs/gold/signals.py @@ -0,0 +1,15 @@ +"""Gold model signals""" + +from django.db.models.signals import pre_delete +from django.dispatch import receiver + +from readthedocs.payments import utils + +from .models import GoldUser + + +@receiver(pre_delete, sender=GoldUser) +def delete_customer(sender, instance, **__): + """On Gold subscription deletion, remove the customer from Stripe""" + if sender == GoldUser and instance.stripe_id is not None: + utils.delete_customer(instance.stripe_id) diff --git a/readthedocs/gold/static-src/gold/js/gold.js b/readthedocs/gold/static-src/gold/js/gold.js index e06f378cd76..ce090212193 100644 --- a/readthedocs/gold/static-src/gold/js/gold.js +++ b/readthedocs/gold/static-src/gold/js/gold.js @@ -1,16 +1,20 @@ // Gold payment views var jquery = require('jquery'), - payment = require('../../../../core/static-src/core/js/payment'), + payment = require('readthedocs/payments/static-src/payments/js/base'), ko = require('knockout'); function GoldView (config) { var self = this, config = config || {}; - ko.utils.extend(self, new payment.PaymentView(config)); + self.constructor.call(self, config); + + self.last_4_digits = ko.observable(null); } +GoldView.prototype = new payment.PaymentView(); + GoldView.init = function (config, obj) { var view = new GoldView(config), obj = obj || $('#payment-form')[0]; @@ -18,4 +22,10 @@ GoldView.init = function (config, obj) { return view; } +GoldView.prototype.submit_form = function (card_digits, token) { + this.form.find('#id_last_4_digits').val(card_digits); + this.form.find('#id_stripe_token').val(token); + this.form.submit(); +}; + module.exports.GoldView = GoldView; diff --git a/readthedocs/gold/static/gold/js/gold.js b/readthedocs/gold/static/gold/js/gold.js index cbcc7ddaa07..098f292aff9 100644 --- a/readthedocs/gold/static/gold/js/gold.js +++ b/readthedocs/gold/static/gold/js/gold.js @@ -1 +1 @@ -require=function t(e,n,r){function a(i,u){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!u&&c)return c(i,!0);if(o)return o(i,!0);var l=new Error("Cannot find module '"+i+"'");throw l.code="MODULE_NOT_FOUND",l}var s=n[i]={exports:{}};e[i][0].call(s.exports,function(t){var n=e[i][1][t];return a(n?n:t)},s,s.exports,t,e,n,r)}return n[i].exports}for(var o="function"==typeof require&&require,i=0;ie;e++)if(e in this&&this[e]===t)return e;return-1};$.payment={},$.payment.fn={},$.fn.payment=function(){var t,e;return e=arguments[0],t=2<=arguments.length?b.call(arguments,1):[],$.payment.fn[e].apply(this,t)},r=/(\d{1,4})/g,$.payment.cards=n=[{type:"visaelectron",pattern:/^4(026|17500|405|508|844|91[37])/,format:r,length:[16],cvcLength:[3],luhn:!0},{type:"maestro",pattern:/^(5(018|0[23]|[68])|6(39|7))/,format:r,length:[12,13,14,15,16,17,18,19],cvcLength:[3],luhn:!0},{type:"forbrugsforeningen",pattern:/^600/,format:r,length:[16],cvcLength:[3],luhn:!0},{type:"dankort",pattern:/^5019/,format:r,length:[16],cvcLength:[3],luhn:!0},{type:"visa",pattern:/^4/,format:r,length:[13,16],cvcLength:[3],luhn:!0},{type:"mastercard",pattern:/^(5[0-5]|2[2-7])/,format:r,length:[16],cvcLength:[3],luhn:!0},{type:"amex",pattern:/^3[47]/,format:/(\d{1,4})(\d{1,6})?(\d{1,5})?/,length:[15],cvcLength:[3,4],luhn:!0},{type:"dinersclub",pattern:/^3[0689]/,format:/(\d{1,4})(\d{1,6})?(\d{1,4})?/,length:[14],cvcLength:[3],luhn:!0},{type:"discover",pattern:/^6([045]|22)/,format:r,length:[16],cvcLength:[3],luhn:!0},{type:"unionpay",pattern:/^(62|88)/,format:r,length:[16,17,18,19],cvcLength:[3],luhn:!1},{type:"jcb",pattern:/^35/,format:r,length:[16],cvcLength:[3],luhn:!0}],t=function(t){var e,r,a;for(t=(t+"").replace(/\D/g,""),r=0,a=n.length;a>r;r++)if(e=n[r],e.pattern.test(t))return e},e=function(t){var e,r,a;for(r=0,a=n.length;a>r;r++)if(e=n[r],e.type===t)return e},p=function(t){var e,n,r,a,o,i;for(r=!0,a=0,n=(t+"").split("").reverse(),o=0,i=n.length;i>o;o++)e=n[o],e=parseInt(e,10),(r=!r)&&(e*=2),e>9&&(e-=9),a+=e;return a%10===0},s=function(t){var e;return null!=t.prop("selectionStart")&&t.prop("selectionStart")!==t.prop("selectionEnd")?!0:null!=("undefined"!=typeof document&&null!==document&&null!=(e=document.selection)?e.createRange:void 0)&&document.selection.createRange().text?!0:!1},d=function(t){return setTimeout(function(){var e,n;return e=$(t.currentTarget),n=e.val(),n=n.replace(/\D/g,""),e.val(n)})},v=function(t){return setTimeout(function(){var e,n;return e=$(t.currentTarget),n=e.val(),n=$.payment.formatCardNumber(n),e.val(n)})},i=function(e){var n,r,a,o,i,u,c;return a=String.fromCharCode(e.which),!/^\d+$/.test(a)||(n=$(e.currentTarget),c=n.val(),r=t(c+a),o=(c.replace(/\D/g,"")+a).length,u=16,r&&(u=r.length[r.length.length-1]),o>=u||null!=n.prop("selectionStart")&&n.prop("selectionStart")!==c.length)?void 0:(i=r&&"amex"===r.type?/^(\d{4}|\d{4}\s\d{6})$/:/(?:^|\s)(\d{4})$/,i.test(c)?(e.preventDefault(),setTimeout(function(){return n.val(c+" "+a)})):i.test(c+a)?(e.preventDefault(),setTimeout(function(){return n.val(c+a+" ")})):void 0)},a=function(t){var e,n;return e=$(t.currentTarget),n=e.val(),8!==t.which||null!=e.prop("selectionStart")&&e.prop("selectionStart")!==n.length?void 0:/\d\s$/.test(n)?(t.preventDefault(),setTimeout(function(){return e.val(n.replace(/\d\s$/,""))})):/\s\d?$/.test(n)?(t.preventDefault(),setTimeout(function(){return e.val(n.replace(/\d$/,""))})):void 0},f=function(t){return setTimeout(function(){var e,n;return e=$(t.currentTarget),n=e.val(),n=$.payment.formatExpiry(n),e.val(n)})},u=function(t){var e,n,r;return n=String.fromCharCode(t.which),/^\d+$/.test(n)?(e=$(t.currentTarget),r=e.val()+n,/^\d$/.test(r)&&"0"!==r&&"1"!==r?(t.preventDefault(),setTimeout(function(){return e.val("0"+r+" / ")})):/^\d\d$/.test(r)?(t.preventDefault(),setTimeout(function(){return e.val(""+r+" / ")})):void 0):void 0},c=function(t){var e,n,r;return n=String.fromCharCode(t.which),/^\d+$/.test(n)?(e=$(t.currentTarget),r=e.val(),/^\d\d$/.test(r)?e.val(""+r+" / "):void 0):void 0},l=function(t){var e,n,r;return r=String.fromCharCode(t.which),"/"===r||" "===r?(e=$(t.currentTarget),n=e.val(),/^\d$/.test(n)&&"0"!==n?e.val("0"+n+" / "):void 0):void 0},o=function(t){var e,n;return e=$(t.currentTarget),n=e.val(),8!==t.which||null!=e.prop("selectionStart")&&e.prop("selectionStart")!==n.length?void 0:/\d\s\/\s$/.test(n)?(t.preventDefault(),setTimeout(function(){return e.val(n.replace(/\d\s\/\s$/,""))})):void 0},h=function(t){return setTimeout(function(){var e,n;return e=$(t.currentTarget),n=e.val(),n=n.replace(/\D/g,"").slice(0,4),e.val(n)})},C=function(t){var e;return t.metaKey||t.ctrlKey?!0:32===t.which?!1:0===t.which?!0:t.which<33?!0:(e=String.fromCharCode(t.which),!!/[\d\s]/.test(e))},g=function(e){var n,r,a,o;return n=$(e.currentTarget),a=String.fromCharCode(e.which),/^\d+$/.test(a)&&!s(n)?(o=(n.val()+a).replace(/\D/g,""),r=t(o),r?o.length<=r.length[r.length.length-1]:o.length<=16):void 0},y=function(t){var e,n,r;return e=$(t.currentTarget),n=String.fromCharCode(t.which),/^\d+$/.test(n)&&!s(e)?(r=e.val()+n,r=r.replace(/\D/g,""),r.length>6?!1:void 0):void 0},m=function(t){var e,n,r;return e=$(t.currentTarget),n=String.fromCharCode(t.which),/^\d+$/.test(n)&&!s(e)?(r=e.val()+n,r.length<=4):void 0},_=function(t){var e,r,a,o,i;return e=$(t.currentTarget),i=e.val(),o=$.payment.cardType(i)||"unknown",e.hasClass(o)?void 0:(r=function(){var t,e,r;for(r=[],t=0,e=n.length;e>t;t++)a=n[t],r.push(a.type);return r}(),e.removeClass("unknown"),e.removeClass(r.join(" ")),e.addClass(o),e.toggleClass("identified","unknown"!==o),e.trigger("payment.cardType",o))},$.payment.fn.formatCardCVC=function(){return this.on("keypress",C),this.on("keypress",m),this.on("paste",h),this.on("change",h),this.on("input",h),this},$.payment.fn.formatCardExpiry=function(){return this.on("keypress",C),this.on("keypress",y),this.on("keypress",u),this.on("keypress",l),this.on("keypress",c),this.on("keydown",o),this.on("change",f),this.on("input",f),this},$.payment.fn.formatCardNumber=function(){return this.on("keypress",C),this.on("keypress",g),this.on("keypress",i),this.on("keydown",a),this.on("keyup",_),this.on("paste",v),this.on("change",v),this.on("input",v),this.on("input",_),this},$.payment.fn.restrictNumeric=function(){return this.on("keypress",C),this.on("paste",d),this.on("change",d),this.on("input",d),this},$.payment.fn.cardExpiryVal=function(){return $.payment.cardExpiryVal($(this).val())},$.payment.cardExpiryVal=function(t){var e,n,r,a;return t=t.replace(/\s/g,""),a=t.split("/",2),e=a[0],r=a[1],2===(null!=r?r.length:void 0)&&/^\d+$/.test(r)&&(n=(new Date).getFullYear(),n=n.toString().slice(0,2),r=n+r),e=parseInt(e,10),r=parseInt(r,10),{month:e,year:r}},$.payment.validateCardNumber=function(e){var n,r;return e=(e+"").replace(/\s+|-/g,""),/^\d+$/.test(e)?(n=t(e),n?(r=e.length,w.call(n.length,r)>=0&&(n.luhn===!1||p(e))):!1):!1},$.payment.validateCardExpiry=function(t,e){var n,r,a;return"object"==typeof t&&"month"in t&&(a=t,t=a.month,e=a.year),t&&e?(t=$.trim(t),e=$.trim(e),/^\d+$/.test(t)&&/^\d+$/.test(e)&&t>=1&&12>=t?(2===e.length&&(e=70>e?"20"+e:"19"+e),4!==e.length?!1:(r=new Date(e,t),n=new Date,r.setMonth(r.getMonth()-1),r.setMonth(r.getMonth()+1,1),r>n)):!1):!1},$.payment.validateCardCVC=function(t,n){var r,a;return t=$.trim(t),/^\d+$/.test(t)?(r=e(n),null!=r?(a=t.length,w.call(r.cvcLength,a)>=0):t.length>=3&&t.length<=4):!1},$.payment.cardType=function(e){var n;return e?(null!=(n=t(e))?n.type:void 0)||null:null},$.payment.formatCardNumber=function(e){var n,r,a,o;return e=e.replace(/\D/g,""),(n=t(e))?(a=n.length[n.length.length-1],e=e.slice(0,a),n.format.global?null!=(o=e.match(n.format))?o.join(" "):void 0:(r=n.format.exec(e),null!=r?(r.shift(),r=$.grep(r,function(t){return t}),r.join(" ")):void 0)):e},$.payment.formatExpiry=function(t){var e,n,r,a;return(n=t.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/))?(e=n[1]||"",r=n[2]||"",a=n[3]||"",a.length>0?r=" / ":" /"===r?(e=e.substring(0,1),r=""):2===e.length||r.length>0?r=" / ":1===e.length&&"0"!==e&&"1"!==e&&(e="0"+e,r=" / "),e+r+a):""}}).call(this)},{}],2:[function(t,e,n){function r(t){var e=this,t=t||{};i.publishableKey=e.stripe_key=t.key,e.form=t.form,e.cc_number=a.observable(null),e.cc_expiry=a.observable(null),e.cc_cvv=a.observable(null),e.cc_error_number=a.observable(null),e.cc_error_expiry=a.observable(null),e.cc_error_cvv=a.observable(null),e.initialize_form(),e.error=a.observable(null),e.process_form=function(){var t=o.payment.cardExpiryVal(e.cc_expiry()),n={number:e.cc_number(),exp_month:t.month,exp_year:t.year,cvc:e.cc_cvv()};return e.error(null),e.cc_error_number(null),e.cc_error_expiry(null),e.cc_error_cvv(null),o.payment.validateCardNumber(n.number)?o.payment.validateCardExpiry(n.exp_month,n.exp_year)?o.payment.validateCardCVC(n.cvc)?void i.createToken(n,function(t,n){if(200===t){var r=e.form.find("#id_last_4_digits"),a=e.form.find("#id_stripe_id,#id_stripe_token");r.val(n.card.last4),a.val(n.id),e.form.submit()}else e.error(n.error.message)}):(e.cc_error_cvv("Invalid security code"),!1):(e.cc_error_expiry("Invalid expiration date"),!1):(e.cc_error_number("Invalid card number"),console.log(n),!1)}}var a=t("knockout"),o=(t("./../../../../../bower_components/jquery.payment/lib/jquery.payment.js"),t("jquery")),i=null;"undefined"!=typeof window&&"undefined"!=typeof window.Stripe&&(i=window.Stripe||{}),r.prototype.initialize_form=function(){var t=o("input#cc-number"),e=o("input#cc-cvv"),n=o("input#cc-expiry");t.payment("formatCardNumber"),n.payment("formatCardExpiry"),e.payment("formatCardCVC")},r.init=function(t,e){var n=new GoldView(t),e=e||o("#payment-form")[0];return a.applyBindings(n,e),n},e.exports.PaymentView=r},{"./../../../../../bower_components/jquery.payment/lib/jquery.payment.js":1,jquery:"jquery",knockout:"knockout"}],"gold/gold":[function(t,e,n){function r(t){var e=this,t=t||{};o.utils.extend(e,new a.PaymentView(t))}var a=(t("jquery"),t("../../../../core/static-src/core/js/payment")),o=t("knockout");r.init=function(t,e){var n=new r(t),e=e||$("#payment-form")[0];return o.applyBindings(n,e),n},e.exports.GoldView=r},{"../../../../core/static-src/core/js/payment":2,jquery:"jquery",knockout:"knockout"}]},{},[]); \ No newline at end of file +require=function e(t,r,n){function o(a,c){if(!r[a]){if(!t[a]){var u="function"==typeof require&&require;if(!c&&u)return u(a,!0);if(i)return i(a,!0);var l=new Error("Cannot find module '"+a+"'");throw l.code="MODULE_NOT_FOUND",l}var s=r[a]={exports:{}};t[a][0].call(s.exports,function(e){var r=t[a][1][e];return o(r?r:e)},s,s.exports,e,t,r,n)}return r[a].exports}for(var i="function"==typeof require&&require,a=0;at;t++)if(t in this&&this[t]===e)return t;return-1};e=window.jQuery||window.Zepto||window.$,e.payment={},e.payment.fn={},e.fn.payment=function(){var t,r;return r=arguments[0],t=2<=arguments.length?k.call(arguments,1):[],e.payment.fn[r].apply(this,t)},o=/(\d{1,4})/g,e.payment.cards=n=[{type:"visaelectron",pattern:/^4(026|17500|405|508|844|91[37])/,format:o,length:[16],cvcLength:[3],luhn:!0},{type:"maestro",pattern:/^(5(018|0[23]|[68])|6(39|7))/,format:o,length:[12,13,14,15,16,17,18,19],cvcLength:[3],luhn:!0},{type:"forbrugsforeningen",pattern:/^600/,format:o,length:[16],cvcLength:[3],luhn:!0},{type:"dankort",pattern:/^5019/,format:o,length:[16],cvcLength:[3],luhn:!0},{type:"visa",pattern:/^4/,format:o,length:[13,16],cvcLength:[3],luhn:!0},{type:"mastercard",pattern:/^(5[1-5]|2[2-7])/,format:o,length:[16],cvcLength:[3],luhn:!0},{type:"amex",pattern:/^3[47]/,format:/(\d{1,4})(\d{1,6})?(\d{1,5})?/,length:[15],cvcLength:[3,4],luhn:!0},{type:"dinersclub",pattern:/^3[0689]/,format:/(\d{1,4})(\d{1,6})?(\d{1,4})?/,length:[14],cvcLength:[3],luhn:!0},{type:"discover",pattern:/^6([045]|22)/,format:o,length:[16],cvcLength:[3],luhn:!0},{type:"unionpay",pattern:/^(62|88)/,format:o,length:[16,17,18,19],cvcLength:[3],luhn:!1},{type:"jcb",pattern:/^35/,format:o,length:[16],cvcLength:[3],luhn:!0}],t=function(e){var t,r,o;for(e=(e+"").replace(/\D/g,""),r=0,o=n.length;o>r;r++)if(t=n[r],t.pattern.test(e))return t},r=function(e){var t,r,o;for(r=0,o=n.length;o>r;r++)if(t=n[r],t.type===e)return t},d=function(e){var t,r,n,o,i,a;for(n=!0,o=0,r=(e+"").split("").reverse(),i=0,a=r.length;a>i;i++)t=r[i],t=parseInt(t,10),(n=!n)&&(t*=2),t>9&&(t-=9),o+=t;return o%10===0},p=function(e){var t;return null!=e.prop("selectionStart")&&e.prop("selectionStart")!==e.prop("selectionEnd")?!0:null!=("undefined"!=typeof document&&null!==document&&null!=(t=document.selection)?t.createRange:void 0)&&document.selection.createRange().text?!0:!1},C=function(e,t){var r,n,o;try{r=t.prop("selectionStart")}catch(i){n=i,r=null}return o=t.val(),t.val(e),null!==r&&t.is(":focus")?(r===o.length&&(r=e.length),t.prop("selectionStart",r),t.prop("selectionEnd",r)):void 0},y=function(e){var t,r,n,o,i,a,c,u;for(null==e&&(e=""),n="0123456789",o="0123456789",a="",r=e.split(""),c=0,u=r.length;u>c;c++)t=r[c],i=n.indexOf(t),i>-1&&(t=o[i]),a+=t;return a},m=function(t){return setTimeout(function(){var r,n;return r=e(t.currentTarget),n=r.val(),n=y(n),n=n.replace(/\D/g,""),C(n,r)})},v=function(t){return setTimeout(function(){var r,n;return r=e(t.currentTarget),n=r.val(),n=y(n),n=e.payment.formatCardNumber(n),C(n,r)})},c=function(r){var n,o,i,a,c,u,l;return i=String.fromCharCode(r.which),!/^\d+$/.test(i)||(n=e(r.currentTarget),l=n.val(),o=t(l+i),a=(l.replace(/\D/g,"")+i).length,u=16,o&&(u=o.length[o.length.length-1]),a>=u||null!=n.prop("selectionStart")&&n.prop("selectionStart")!==l.length)?void 0:(c=o&&"amex"===o.type?/^(\d{4}|\d{4}\s\d{6})$/:/(?:^|\s)(\d{4})$/,c.test(l)?(r.preventDefault(),setTimeout(function(){return n.val(l+" "+i)})):c.test(l+i)?(r.preventDefault(),setTimeout(function(){return n.val(l+i+" ")})):void 0)},i=function(t){var r,n;return r=e(t.currentTarget),n=r.val(),8!==t.which||null!=r.prop("selectionStart")&&r.prop("selectionStart")!==n.length?void 0:/\d\s$/.test(n)?(t.preventDefault(),setTimeout(function(){return r.val(n.replace(/\d\s$/,""))})):/\s\d?$/.test(n)?(t.preventDefault(),setTimeout(function(){return r.val(n.replace(/\d$/,""))})):void 0},f=function(t){return setTimeout(function(){var r,n;return r=e(t.currentTarget),n=r.val(),n=y(n),n=e.payment.formatExpiry(n),C(n,r)})},u=function(t){var r,n,o;return n=String.fromCharCode(t.which),/^\d+$/.test(n)?(r=e(t.currentTarget),o=r.val()+n,/^\d$/.test(o)&&"0"!==o&&"1"!==o?(t.preventDefault(),setTimeout(function(){return r.val("0"+o+" / ")})):/^\d\d$/.test(o)?(t.preventDefault(),setTimeout(function(){return r.val(""+o+" / ")})):void 0):void 0},l=function(t){var r,n,o;return n=String.fromCharCode(t.which),/^\d+$/.test(n)?(r=e(t.currentTarget),o=r.val(),/^\d\d$/.test(o)?r.val(""+o+" / "):void 0):void 0},s=function(t){var r,n,o;return o=String.fromCharCode(t.which),"/"===o||" "===o?(r=e(t.currentTarget),n=r.val(),/^\d$/.test(n)&&"0"!==n?r.val("0"+n+" / "):void 0):void 0},a=function(t){var r,n;return r=e(t.currentTarget),n=r.val(),8!==t.which||null!=r.prop("selectionStart")&&r.prop("selectionStart")!==n.length?void 0:/\d\s\/\s$/.test(n)?(t.preventDefault(),setTimeout(function(){return r.val(n.replace(/\d\s\/\s$/,""))})):void 0},h=function(t){return setTimeout(function(){var r,n;return r=e(t.currentTarget),n=r.val(),n=y(n),n=n.replace(/\D/g,"").slice(0,4),C(n,r)})},w=function(e){var t;return e.metaKey||e.ctrlKey?!0:32===e.which?!1:0===e.which?!0:e.which<33?!0:(t=String.fromCharCode(e.which),!!/[\d\s]/.test(t))},_=function(r){var n,o,i,a;return n=e(r.currentTarget),i=String.fromCharCode(r.which),/^\d+$/.test(i)&&!p(n)?(a=(n.val()+i).replace(/\D/g,""),o=t(a),o?a.length<=o.length[o.length.length-1]:a.length<=16):void 0},b=function(t){var r,n,o;return r=e(t.currentTarget),n=String.fromCharCode(t.which),/^\d+$/.test(n)&&!p(r)?(o=r.val()+n,o=o.replace(/\D/g,""),o.length>6?!1:void 0):void 0},g=function(t){var r,n,o;return r=e(t.currentTarget),n=String.fromCharCode(t.which),/^\d+$/.test(n)&&!p(r)?(o=r.val()+n,o.length<=4):void 0},x=function(t){var r,o,i,a,c;return r=e(t.currentTarget),c=r.val(),a=e.payment.cardType(c)||"unknown",r.hasClass(a)?void 0:(o=function(){var e,t,r;for(r=[],e=0,t=n.length;t>e;e++)i=n[e],r.push(i.type);return r}(),r.removeClass("unknown"),r.removeClass(o.join(" ")),r.addClass(a),r.toggleClass("identified","unknown"!==a),r.trigger("payment.cardType",a))},e.payment.fn.formatCardCVC=function(){return this.on("keypress",w),this.on("keypress",g),this.on("paste",h),this.on("change",h),this.on("input",h),this},e.payment.fn.formatCardExpiry=function(){return this.on("keypress",w),this.on("keypress",b),this.on("keypress",u),this.on("keypress",s),this.on("keypress",l),this.on("keydown",a),this.on("change",f),this.on("input",f),this},e.payment.fn.formatCardNumber=function(){return this.on("keypress",w),this.on("keypress",_),this.on("keypress",c),this.on("keydown",i),this.on("keyup",x),this.on("paste",v),this.on("change",v),this.on("input",v),this.on("input",x),this},e.payment.fn.restrictNumeric=function(){return this.on("keypress",w),this.on("paste",m),this.on("change",m),this.on("input",m),this},e.payment.fn.cardExpiryVal=function(){return e.payment.cardExpiryVal(e(this).val())},e.payment.cardExpiryVal=function(e){var t,r,n,o;return o=e.split(/[\s\/]+/,2),t=o[0],n=o[1],2===(null!=n?n.length:void 0)&&/^\d+$/.test(n)&&(r=(new Date).getFullYear(),r=r.toString().slice(0,2),n=r+n),t=parseInt(t,10),n=parseInt(n,10),{month:t,year:n}},e.payment.validateCardNumber=function(e){var r,n;return e=(e+"").replace(/\s+|-/g,""),/^\d+$/.test(e)?(r=t(e),r?(n=e.length,T.call(r.length,n)>=0&&(r.luhn===!1||d(e))):!1):!1},e.payment.validateCardExpiry=function(t,r){var n,o,i;return"object"==typeof t&&"month"in t&&(i=t,t=i.month,r=i.year),t&&r?(t=e.trim(t),r=e.trim(r),/^\d+$/.test(t)&&/^\d+$/.test(r)&&t>=1&&12>=t?(2===r.length&&(r=70>r?"20"+r:"19"+r),4!==r.length?!1:(o=new Date(r,t),n=new Date,o.setMonth(o.getMonth()-1),o.setMonth(o.getMonth()+1,1),o>n)):!1):!1},e.payment.validateCardCVC=function(t,n){var o,i;return t=e.trim(t),/^\d+$/.test(t)?(o=r(n),null!=o?(i=t.length,T.call(o.cvcLength,i)>=0):t.length>=3&&t.length<=4):!1},e.payment.cardType=function(e){var r;return e?(null!=(r=t(e))?r.type:void 0)||null:null},e.payment.formatCardNumber=function(r){var n,o,i,a;return r=r.replace(/\D/g,""),(n=t(r))?(i=n.length[n.length.length-1],r=r.slice(0,i),n.format.global?null!=(a=r.match(n.format))?a.join(" "):void 0:(o=n.format.exec(r),null!=o?(o.shift(),o=e.grep(o,function(e){return e}),o.join(" ")):void 0)):r},e.payment.formatExpiry=function(e){var t,r,n,o;return(r=e.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/))?(t=r[1]||"",n=r[2]||"",o=r[3]||"",o.length>0?n=" / ":" /"===n?(t=t.substring(0,1),n=""):2===t.length||n.length>0?n=" / ":1===t.length&&"0"!==t&&"1"!==t&&(t="0"+t,n=" / "),t+n+o):""}}).call(this)},{}],2:[function(e,t,r){function n(e){var t=this,e=e||{};a.publishableKey=t.stripe_key=e.key,t.form=e.form,t.cc_number=o.observable(null),t.cc_expiry=o.observable(null),t.cc_cvv=o.observable(null),t.error_cc_number=o.observable(null),t.error_cc_expiry=o.observable(null),t.error_cc_cvv=o.observable(null),t.stripe_token=o.observable(null),t.card_digits=o.observable(null),t.is_editing_card=o.observable(!1),t.show_card_form=o.computed(function(){return t.is_editing_card()||!t.card_digits()||t.cc_number()||t.cc_expiry()||t.cc_cvv()}),t.initialize_form(),t.error=o.observable(null),t.process_form=function(){var e=i.payment.cardExpiryVal(t.cc_expiry()),r={number:t.cc_number(),exp_month:e.month,exp_year:e.year,cvc:t.cc_cvv()};return t.error(null),t.error_cc_number(null),t.error_cc_expiry(null),t.error_cc_cvv(null),i.payment.validateCardNumber(r.number)?i.payment.validateCardExpiry(r.exp_month,r.exp_year)?i.payment.validateCardCVC(r.cvc)?void a.createToken(r,function(e,r){if(r.error)if("card_error"==r.error.type){var n={invalid_number:t.error_cc_number,incorrect_number:t.error_cc_number,expired_card:t.error_cc_number,card_declined:t.error_cc_number,invalid_expiry_month:t.error_cc_expiry,invalid_expiry_year:t.error_cc_expiry,invalid_cvc:t.error_cc_cvv,incorrect_cvc:t.error_cc_cvv},o=n[r.error.code]||t.error_cc_number;o(r.error.message)}else t.error_cc_number(r.error.message);else t.submit_form(r.card.last4,r.id)}):(t.error_cc_cvv("Invalid security code"),!1):(t.error_cc_expiry("Invalid expiration date"),!1):(t.error_cc_number("Invalid card number"),!1)},t.process_full_form=function(){return t.show_card_form()?void t.process_form():!0}}var o=e("knockout"),i=(e("./../../../../../bower_components/jquery.payment/lib/jquery.payment.js"),e("jquery")),a=null;"undefined"!=typeof window&&"undefined"!=typeof window.Stripe&&(a=window.Stripe||{}),o.bindingHandlers.valueInit={init:function(e,t){var r=t();o.isWriteableObservable(r)&&r(e.value)}},n.prototype.submit_form=function(e,t){this.form.find("#id_card_digits").val(e),this.form.find("#id_stripe_token").val(t),this.form.submit()},n.prototype.initialize_form=function(){var e=i("input#id_cc_number"),t=i("input#id_cc_cvv"),r=i("input#id_cc_expiry");e.payment("formatCardNumber"),r.payment("formatCardExpiry"),t.payment("formatCardCVC"),e.trigger("keyup")},n.init=function(e,t){var r=new n(e),t=t||i("#payment-form")[0];return o.applyBindings(r,t),r},t.exports.PaymentView=n},{"./../../../../../bower_components/jquery.payment/lib/jquery.payment.js":1,jquery:"jquery",knockout:"knockout"}],"gold/gold":[function(e,t,r){function n(e){var t=this,e=e||{};t.constructor.call(t,e),t.last_4_digits=i.observable(null)}var o=(e("jquery"),e("readthedocs/payments/static-src/payments/js/base")),i=e("knockout");n.prototype=new o.PaymentView,n.init=function(e,t){var r=new n(e),t=t||$("#payment-form")[0];return i.applyBindings(r,t),r},n.prototype.submit_form=function(e,t){this.form.find("#id_last_4_digits").val(e),this.form.find("#id_stripe_token").val(t),this.form.submit()},t.exports.GoldView=n},{jquery:"jquery",knockout:"knockout","readthedocs/payments/static-src/payments/js/base":2}]},{},[]); \ No newline at end of file diff --git a/readthedocs/gold/templates/gold/cancel.html b/readthedocs/gold/templates/gold/cancel.html deleted file mode 100644 index 21c39c6b3d6..00000000000 --- a/readthedocs/gold/templates/gold/cancel.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "profiles/base_profile_edit.html" %} - -{% block profile-admin-gold-edit %}active{% endblock %} - -{% block title %} -Cancel Gold -{% endblock %} - -{% block edit_content %} -
-
-
-
- - Are you sure you want to cancel? -
-
-
- {% csrf_token %} - - -
-
-
-{% endblock %} diff --git a/readthedocs/gold/templates/gold/cardform.html b/readthedocs/gold/templates/gold/cardform.html deleted file mode 100644 index 874c0023172..00000000000 --- a/readthedocs/gold/templates/gold/cardform.html +++ /dev/null @@ -1,20 +0,0 @@ -{% load i18n %} - -
- - -
-

- - - -

- - - -

- -
- - -
diff --git a/readthedocs/gold/templates/gold/edit.html b/readthedocs/gold/templates/gold/edit.html deleted file mode 100644 index 36356809d07..00000000000 --- a/readthedocs/gold/templates/gold/edit.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "profiles/base_profile_edit.html" %} -{% load static %} - -{% block profile-admin-gold-edit %}active{% endblock %} - -{% block title %} -Edit Gold -{% endblock %} - -{% block extra_scripts %} - - - - -{% endblock %} - -{% block edit_content %} -
-

Change Credit Card

-
- {% csrf_token %} - {% for field in form %} - {{ field }} - {% endfor %} - {% include "gold/cardform.html" %} -
-
-{% endblock %} diff --git a/readthedocs/gold/templates/gold/errors.html b/readthedocs/gold/templates/gold/errors.html deleted file mode 100644 index dbb9e3df8e3..00000000000 --- a/readthedocs/gold/templates/gold/errors.html +++ /dev/null @@ -1,14 +0,0 @@ - {% if form.is_bound and not form.is_valid %} -
-
- {% for field in form.visible_fields %} - {% for error in field.errors %} -

{{ field.label }}: {{ error }}

- {% endfor %} - {% endfor %} - {% for error in form.non_field_errors %} -

{{ error }}

- {% endfor %} -
-
- {% endif %} diff --git a/readthedocs/gold/templates/gold/field.html b/readthedocs/gold/templates/gold/field.html deleted file mode 100644 index 1e7c2581eed..00000000000 --- a/readthedocs/gold/templates/gold/field.html +++ /dev/null @@ -1,6 +0,0 @@ -
- {{ field.label_tag }} -
- {{ field }} -
-
diff --git a/readthedocs/gold/templates/gold/once.html b/readthedocs/gold/templates/gold/once.html deleted file mode 100644 index c3bb49a5c00..00000000000 --- a/readthedocs/gold/templates/gold/once.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends "profiles/base_profile_edit.html" %} - -{% block profile-admin-gold-edit %}active{% endblock %} - -{% block title %} -One-time Payment -{% endblock %} - -{% block extra_scripts %} - - - - -{% endblock %} - - -{% block edit_content %} -
-
-
-
- -
-
-
- {% csrf_token %} - {% include "gold/errors.html" %} - {% for field in form.visible_fields %} - {% include "gold/field.html" %} - {% endfor %} - {% include "gold/cardform.html" %} -
-
-
-{% endblock %} diff --git a/readthedocs/gold/templates/gold/register.html b/readthedocs/gold/templates/gold/register.html deleted file mode 100644 index 1e0f4e22134..00000000000 --- a/readthedocs/gold/templates/gold/register.html +++ /dev/null @@ -1,76 +0,0 @@ -{% extends "profiles/base_profile_edit.html" %} -{% load static %} -{% load i18n %} - -{% block profile-admin-gold-edit %}active{% endblock %} - -{% block title %} -Gold Status -{% endblock %} - -{% block extra_scripts %} - - - - -{% endblock %} - -{% block edit_content %} -
-

Gold Status

- - {% if gold_user.subscribed %} -

- Thanks for supporting Read the Docs! It really means a lot to us. -

-

- Level: {{ gold_user.get_level_display }} -

-

- Card: Ends with {{ gold_user.last_4_digits }} -

- -

Projects

-

- You can adopt {{ gold_user.num_supported_projects }} projects with your subscription. Select Projects -

- -

Changes

-

- Cancel or Change your subscription. -

- - {% else %} -

- Supporting Read the Docs lets us work more on features that people - love. Your money will go directly to maintenance and development of - the product. -

-

- You can make one-time donations on our sustainability page. -

- -

Become a Gold Member

-
- {% csrf_token %} - {% for field in form %} - {{ field }} - {% endfor %} - {% include "gold/cardform.html" %} - All information is submitted directly to Stripe. -
- {% endif %} -{% endblock %} diff --git a/readthedocs/gold/templates/gold/subscription_confirm_delete.html b/readthedocs/gold/templates/gold/subscription_confirm_delete.html new file mode 100644 index 00000000000..b341566c418 --- /dev/null +++ b/readthedocs/gold/templates/gold/subscription_confirm_delete.html @@ -0,0 +1,21 @@ +{% extends "profiles/base_profile_edit.html" %} +{% load i18n %} + +{% block profile-admin-gold-edit %}active{% endblock %} + +{% block title %}Cancel Gold{% endblock %} + +{% block edit_content %} +

Cancel Gold Subscription

+ +

+ {% blocktrans %} + Are you sure you want to cancel your subscription? + {% endblocktrans %} +

+ +
+ {% csrf_token %} + +
+{% endblock %} diff --git a/readthedocs/gold/templates/gold/subscription_detail.html b/readthedocs/gold/templates/gold/subscription_detail.html new file mode 100644 index 00000000000..a25d3391a84 --- /dev/null +++ b/readthedocs/gold/templates/gold/subscription_detail.html @@ -0,0 +1,68 @@ +{% extends "profiles/base_profile_edit.html" %} +{% load static %} +{% load i18n %} + +{% block profile-admin-gold-edit %}active{% endblock %} + +{% block title %}{% trans "Gold Subscription" %}{% endblock %} + +{% block extra_scripts %} + + + + +{% endblock %} + +{% block edit_content %} +
+

{% trans "Gold Subscription" %}

+ +

+ {% blocktrans %} + Thanks for supporting Read the Docs! It really means a lot to us. + {% endblocktrans %} +

+ +

+ + {{ golduser.get_level_display }} +

+ +

+ + ****-{{ golduser.last_4_digits }} +

+ +
+ +
+ +
+ +
+ +

{% trans "Projects" %}

+

+ {% blocktrans with projects=golduser.num_supported_projects %} + You can adopt {{ projects }} projects with your subscription. + {% endblocktrans %} +

+ +
+ +
+
+{% endblock %} diff --git a/readthedocs/gold/templates/gold/subscription_form.html b/readthedocs/gold/templates/gold/subscription_form.html new file mode 100644 index 00000000000..e04ac036cf0 --- /dev/null +++ b/readthedocs/gold/templates/gold/subscription_form.html @@ -0,0 +1,104 @@ +{% extends "profiles/base_profile_edit.html" %} +{% load static %} +{% load i18n %} + +{% block profile-admin-gold-edit %}active{% endblock %} + +{% block title %} +Gold Status +{% endblock %} + +{% block extra_links %} + +{% endblock %} + +{% block extra_scripts %} + + + + +{% endblock %} + +{% block edit_content %} +
+

Gold Status

+ +

+ {% blocktrans %} + Supporting Read the Docs lets us work more on features that people love. + Your money will go directly to maintenance and development of the + product. + {% endblocktrans %} +

+

+ {% blocktrans %} + You can make one-time donations on our sustainability page. + {% endblocktrans %} +

+ + {% trans "Become a Gold Member" as subscription_title %} + {% if golduser %} + {% trans "Update Your Subscription" as subscription_title %} + {% endif %} +

{{ subscription_title }}

+ +
+ {% csrf_token %} + + {{ form.non_field_errors }} + + {% for field in form.fields_with_cc_group %} + {% if field.is_cc_group %} + + + + + {% else %} + {% include 'core/ko_form_field.html' with field=field %} + {% endif %} + {% endfor %} + + {% trans "Sign Up" as form_submit_text %} + {% if golduser %} + {% trans "Update Subscription" as form_submit_text %} + {% endif %} + + + {% trans "All information is submitted directly to Stripe." %} +
+{% endblock %} diff --git a/readthedocs/gold/templates/gold/thanks.html b/readthedocs/gold/templates/gold/thanks.html deleted file mode 100644 index cd5b41614e5..00000000000 --- a/readthedocs/gold/templates/gold/thanks.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "profiles/base_profile_edit.html" %} - -{% block profile-admin-gold-edit %}active{% endblock %} - -{% block title %} -Thanks! -{% endblock %} - -{% block edit_content %} - - -

-Thanks for contributing to Read the Docs! -

- -{% endblock %} diff --git a/readthedocs/gold/tests/__init__.py b/readthedocs/gold/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/readthedocs/gold/tests/test_forms.py b/readthedocs/gold/tests/test_forms.py new file mode 100644 index 00000000000..2c00a3bf050 --- /dev/null +++ b/readthedocs/gold/tests/test_forms.py @@ -0,0 +1,224 @@ +import mock +import django_dynamic_fixture as fixture +from django.test import TestCase +from django.contrib.auth.models import User + +from readthedocs.projects.models import Project + +from ..models import GoldUser +from ..forms import GoldSubscriptionForm + + +class GoldSubscriptionFormTests(TestCase): + + def setUp(self): + self.owner = fixture.get(User) + self.user = fixture.get(User) + self.project = fixture.get(Project, users=[self.user]) + + # Mocking + self.patches = {} + self.mocks = {} + self.patches['requestor'] = mock.patch('stripe.api_requestor.APIRequestor') + + for patch in self.patches: + self.mocks[patch] = self.patches[patch].start() + + self.mocks['request'] = self.mocks['requestor'].return_value + self.mock_request([({}, 'reskey')]) + + def mock_request(self, resp): + self.mocks['request'].request = mock.Mock(side_effect=resp) + + def test_add_subscription(self): + """Valid subscription form""" + subscription_list = { + 'object': 'list', + 'data': [], + 'has_more': False, + 'total_count': 1, + 'url': '/v1/customers/cus_12345/subscriptions', + } + customer_obj = { + 'id': 'cus_12345', + 'description': self.user.get_full_name(), + 'email': self.user.email, + 'subscriptions': subscription_list + } + subscription_obj = { + 'id': 'sub_12345', + 'object': 'subscription', + 'customer': 'cus_12345', + 'plan': { + 'id': 'v1-org-5', + 'object': 'plan', + 'amount': 1000, + 'currency': 'usd', + 'name': 'Test', + } + } + self.mock_request([ + (customer_obj, ''), + (subscription_list, ''), + (subscription_obj, ''), + ]) + + # Create user and subscription + subscription_form = GoldSubscriptionForm( + {'level': 'v1-org-5', + 'last_4_digits': '0000', + 'stripe_token': 'GARYBUSEY'}, + customer=self.user + ) + self.assertTrue(subscription_form.is_valid()) + subscription = subscription_form.save() + + self.assertEqual(subscription.level, 'v1-org-5') + self.assertEqual(subscription.stripe_id, 'cus_12345') + self.assertIsNotNone(self.user.gold) + self.assertEqual(self.user.gold.first().level, 'v1-org-5') + + self.mocks['request'].request.assert_has_calls([ + mock.call('post', + '/v1/customers', + {'description': mock.ANY, 'email': mock.ANY}, + mock.ANY), + mock.call('get', + '/v1/customers/cus_12345/subscriptions', + mock.ANY, + mock.ANY), + mock.call('post', + '/v1/customers/cus_12345/subscriptions', + {'source': mock.ANY, 'plan': 'v1-org-5'}, + mock.ANY), + ]) + + def test_add_subscription_update_user(self): + """Valid subscription form""" + subscription_list = { + 'object': 'list', + 'data': [], + 'has_more': False, + 'total_count': 1, + 'url': '/v1/customers/cus_12345/subscriptions', + } + customer_obj = { + 'id': 'cus_12345', + 'description': self.user.get_full_name(), + 'email': self.user.email, + 'subscriptions': subscription_list + } + subscription_obj = { + 'id': 'sub_12345', + 'object': 'subscription', + 'customer': 'cus_12345', + 'plan': { + 'id': 'v1-org-5', + 'object': 'plan', + 'amount': 1000, + 'currency': 'usd', + 'name': 'Test', + } + } + self.mock_request([ + (customer_obj, ''), + (customer_obj, ''), + (subscription_list, ''), + (subscription_obj, ''), + ]) + + # Create user and update the current gold subscription + golduser = fixture.get(GoldUser, user=self.user, stripe_id='cus_12345') + subscription_form = GoldSubscriptionForm( + {'level': 'v1-org-5', + 'last_4_digits': '0000', + 'stripe_token': 'GARYBUSEY'}, + customer=self.user, + instance=golduser + ) + self.assertTrue(subscription_form.is_valid()) + subscription = subscription_form.save() + + self.assertEqual(subscription.level, 'v1-org-5') + self.assertEqual(subscription.stripe_id, 'cus_12345') + self.assertIsNotNone(self.user.gold) + self.assertEqual(self.user.gold.first().level, 'v1-org-5') + + self.mocks['request'].request.assert_has_calls([ + mock.call('get', + '/v1/customers/cus_12345', + {}, + mock.ANY), + mock.call('post', + '/v1/customers/cus_12345', + {'description': mock.ANY, 'email': mock.ANY}, + mock.ANY), + mock.call('get', + '/v1/customers/cus_12345/subscriptions', + mock.ANY, + mock.ANY), + mock.call('post', + '/v1/customers/cus_12345/subscriptions', + {'source': mock.ANY, 'plan': 'v1-org-5'}, + mock.ANY), + ]) + + def test_update_subscription_plan(self): + """Update subcription plan""" + subscription_obj = { + 'id': 'sub_12345', + 'object': 'subscription', + 'customer': 'cus_12345', + 'plan': { + 'id': 'v1-org-5', + 'object': 'plan', + 'amount': 1000, + 'currency': 'usd', + 'name': 'Test', + } + } + subscription_list = { + 'object': 'list', + 'data': [subscription_obj], + 'has_more': False, + 'total_count': 1, + 'url': '/v1/customers/cus_12345/subscriptions', + } + customer_obj = { + 'id': 'cus_12345', + 'description': self.user.get_full_name(), + 'email': self.user.email, + 'subscriptions': subscription_list + } + self.mock_request([ + (customer_obj, ''), + (subscription_list, ''), + (subscription_obj, ''), + ]) + subscription_form = GoldSubscriptionForm( + {'level': 'v1-org-5', + 'last_4_digits': '0000', + 'stripe_token': 'GARYBUSEY'}, + customer=self.user + ) + self.assertTrue(subscription_form.is_valid()) + subscription = subscription_form.save() + + self.assertEqual(subscription.level, 'v1-org-5') + self.assertIsNotNone(self.user.gold) + self.assertEqual(self.user.gold.first().level, 'v1-org-5') + + self.mocks['request'].request.assert_has_calls([ + mock.call('post', + '/v1/customers', + {'description': mock.ANY, 'email': mock.ANY}, + mock.ANY), + mock.call('get', + '/v1/customers/cus_12345/subscriptions', + mock.ANY, + mock.ANY), + mock.call('post', + '/v1/customers/cus_12345/subscriptions/sub_12345', + {'source': mock.ANY, 'plan': 'v1-org-5'}, + mock.ANY), + ]) diff --git a/readthedocs/gold/tests/test_signals.py b/readthedocs/gold/tests/test_signals.py new file mode 100644 index 00000000000..f6b10a0d477 --- /dev/null +++ b/readthedocs/gold/tests/test_signals.py @@ -0,0 +1,46 @@ +import mock +import django_dynamic_fixture as fixture +from django.test import TestCase +from django.contrib.auth.models import User +from django.db.models.signals import pre_delete + +from readthedocs.projects.models import Project + +from ..models import GoldUser +from ..signals import delete_customer + + +class GoldSignalTests(TestCase): + + def setUp(self): + self.user = fixture.get(User) + + # Mocking + self.patches = {} + self.mocks = {} + self.patches['requestor'] = mock.patch('stripe.api_requestor.APIRequestor') + + for patch in self.patches: + self.mocks[patch] = self.patches[patch].start() + + self.mocks['request'] = self.mocks['requestor'].return_value + + def mock_request(self, resp=None): + if resp is None: + resp = ({}, '') + self.mocks['request'].request = mock.Mock(side_effect=resp) + + def test_delete_subscription(self): + subscription = fixture.get(GoldUser, user=self.user, stripe_id='cus_123') + self.assertIsNotNone(subscription) + self.mock_request([ + ({'id': 'cus_123', 'object': 'customer'}, ''), + ({'deleted': True, 'customer': 'cus_123'}, ''), + ]) + + subscription.delete() + + self.mocks['request'].request.assert_has_calls([ + mock.call('get', '/v1/customers/cus_123', {}, mock.ANY), + mock.call('delete', '/v1/customers/cus_123', {}, mock.ANY), + ]) diff --git a/readthedocs/gold/urls.py b/readthedocs/gold/urls.py index ea87bd3b6ef..8f0ca1e5a7d 100644 --- a/readthedocs/gold/urls.py +++ b/readthedocs/gold/urls.py @@ -1,18 +1,20 @@ -from django.conf.urls import url, patterns, include +"""Gold subscription URLs""" + +from django.conf.urls import url, patterns from readthedocs.gold import views from readthedocs.projects.constants import PROJECT_SLUG_REGEX -urlpatterns = patterns('', - url(r'^register/$', views.register, name='gold_register'), - url(r'^edit/$', views.edit, name='gold_edit'), - url(r'^cancel/$', views.cancel, name='gold_cancel'), - url(r'^thanks/$', views.thanks, name='gold_thanks'), - url(r'^projects/$', views.projects, name='gold_projects'), - url(r'^projects/remove/(?P{project_slug})/$'.format( - project_slug=PROJECT_SLUG_REGEX - ), - views.projects_remove, - name='gold_projects_remove'), - ) +urlpatterns = patterns( + '', + url(r'^$', views.DetailGoldSubscription.as_view(), name='gold_detail'), + url(r'^subscription/$', views.UpdateGoldSubscription.as_view(), + name='gold_subscription'), + url(r'^cancel/$', views.DeleteGoldSubscription.as_view(), name='gold_cancel'), + url(r'^projects/$', views.projects, name='gold_projects'), + url((r'^projects/remove/(?P{project_slug})/$' + .format(project_slug=PROJECT_SLUG_REGEX)), + views.projects_remove, + name='gold_projects_remove'), +) diff --git a/readthedocs/gold/views.py b/readthedocs/gold/views.py index c39b16c2710..314222abac3 100644 --- a/readthedocs/gold/views.py +++ b/readthedocs/gold/views.py @@ -1,146 +1,88 @@ -import datetime +"""Gold suscription views""" -from django.core.urlresolvers import reverse +from django.core.urlresolvers import reverse, reverse_lazy from django.conf import settings -from django.db import IntegrityError -from django.http import HttpResponseRedirect, Http404 +from django.contrib.messages.views import SuccessMessageMixin +from django.contrib import messages +from django.http import HttpResponseRedirect from django.shortcuts import render_to_response, get_object_or_404 from django.template import RequestContext from django.contrib.auth.decorators import login_required +from django.utils.translation import ugettext_lazy as _ +from vanilla import DeleteView, UpdateView, DetailView -import stripe +from readthedocs.core.mixins import LoginRequiredMixin +from readthedocs.projects.models import Project +from readthedocs.payments.mixins import StripeMixin +from readthedocs.payments.utils import stripe -from .forms import CardForm, GoldProjectForm +from .forms import GoldSubscriptionForm, GoldProjectForm from .models import GoldUser -from readthedocs.projects.models import Project -stripe.api_key = settings.STRIPE_SECRET +class GoldSubscriptionMixin(SuccessMessageMixin, StripeMixin, LoginRequiredMixin): -def soon(): - soon = datetime.date.today() + datetime.timedelta(days=30) - return {'month': soon.month, 'year': soon.year} + """Gold subscription mixin for view classes""" + model = GoldUser + form_class = GoldSubscriptionForm -@login_required -def register(request): - user = request.user - try: - gold_user = GoldUser.objects.get(user=request.user) - except GoldUser.DoesNotExist: - gold_user = None - if request.method == 'POST': - form = CardForm(request.POST) - if form.is_valid(): + def get_object(self): + try: + return self.get_queryset().get(user=self.request.user) + except self.model.DoesNotExist: + return None - customer = stripe.Customer.create( - description=user.username, - email=user.email, - card=form.cleaned_data['stripe_token'], - plan=form.cleaned_data['level'], - ) - - try: - user = GoldUser.objects.get(user=user) - except GoldUser.DoesNotExist: - user = GoldUser( - user=request.user, - ) - - user.level = form.cleaned_data['level'] - user.last_4_digits = form.cleaned_data['last_4_digits'] - user.stripe_id = customer.id - user.subscribed = True - - try: - user.save() - except IntegrityError: - form.add_error(None, user.user.username + ' is already a member') - else: - return HttpResponseRedirect(reverse('gold_thanks')) + def get_form(self, data=None, files=None, **kwargs): + """Pass in copy of POST data to avoid read only QueryDicts""" + kwargs['customer'] = self.request.user + return super(GoldSubscriptionMixin, self).get_form(data, files, **kwargs) - else: - form = CardForm() + def get_success_url(self, **__): + return reverse_lazy('gold_detail') - return render_to_response( - 'gold/register.html', - { - 'form': form, - 'gold_user': gold_user, - 'publishable': settings.STRIPE_PUBLISHABLE, - 'soon': soon(), - 'user': user, - }, - context_instance=RequestContext(request) - ) + def get_template_names(self): + return ('gold/subscription{0}.html' + .format(self.template_name_suffix)) -@login_required -def edit(request): - user = get_object_or_404(GoldUser, user=request.user) - if request.method == 'POST': - form = CardForm(request.POST) - if form.is_valid(): +# Subscription Views +class DetailGoldSubscription(GoldSubscriptionMixin, DetailView): - customer = stripe.Customer.retrieve(user.stripe_id) - customer.card = form.cleaned_data['stripe_token'] - customer.plan = form.cleaned_data['level'] - customer.save() + def get(self, request, *args, **kwargs): + """GET handling for this view - user.last_4_digits = form.cleaned_data['last_4_digits'] - user.stripe_id = customer.id - user.level = form.cleaned_data['level'] - user.subscribed = True - user.save() + If there is a gold subscription instance, then we show the normal detail + page, otherwise show the registration form + """ + resp = super(DetailGoldSubscription, self).get(request, *args, **kwargs) + if self.object is None: + return HttpResponseRedirect(reverse('gold_subscription')) + return resp - return HttpResponseRedirect(reverse('gold_thanks')) - else: - form = CardForm(initial={'level': user.level}) +class UpdateGoldSubscription(GoldSubscriptionMixin, UpdateView): + success_message = _('Your subscription has been updated') - return render_to_response( - 'gold/edit.html', - { - 'form': form, - 'publishable': settings.STRIPE_PUBLISHABLE, - 'soon': soon(), - }, - context_instance=RequestContext(request) - ) +class DeleteGoldSubscription(GoldSubscriptionMixin, DeleteView): -@login_required -def cancel(request): - user = get_object_or_404(GoldUser, user=request.user) - if request.method == 'POST': - customer = stripe.Customer.retrieve(user.stripe_id) - customer.delete() - user.subscribed = False - user.save() - return HttpResponseRedirect(reverse('gold_register')) - return render_to_response( - 'gold/cancel.html', - { - 'publishable': settings.STRIPE_PUBLISHABLE, - 'soon': soon(), - 'months': range(1, 13), - 'years': range(2011, 2036) - }, - context_instance=RequestContext(request) - ) + """Delete Gold subscription view + On object deletion, the corresponding Stripe customer is deleted as well. + Deletion is triggered on subscription deletion using a signal, ensuring the + subscription is synced with Stripe. + """ -def thanks(request): - return render_to_response( - 'gold/thanks.html', - { - 'publishable': settings.STRIPE_PUBLISHABLE, - 'soon': soon(), - 'months': range(1, 13), - 'years': range(2011, 2036) - }, - context_instance=RequestContext(request) - ) + success_message = _('Your subscription has been cancelled') + + def post(self, request, *args, **kwargs): + """Add success message to delete post""" + resp = super(DeleteGoldSubscription, self).post(request, *args, **kwargs) + success_message = self.get_success_message({}) + if success_message: + messages.success(self.request, success_message) + return resp @login_required diff --git a/readthedocs/payments/__init__.py b/readthedocs/payments/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/readthedocs/payments/forms.py b/readthedocs/payments/forms.py new file mode 100644 index 00000000000..a575b4c9fb2 --- /dev/null +++ b/readthedocs/payments/forms.py @@ -0,0 +1,192 @@ +"""Payment forms""" + +import logging + +from stripe.resource import Customer, Charge +from stripe.error import InvalidRequestError +from django import forms +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ + +from .utils import stripe + +log = logging.getLogger(__name__) + + +class StripeResourceMixin(object): + + """Stripe actions for resources, available as a Form mixin class""" + + def ensure_stripe_resource(self, resource, attrs): + try: + instance = resource.retrieve(attrs['id']) + except (KeyError, InvalidRequestError): + try: + del attrs['id'] + except KeyError: + pass + return resource.create(**attrs) + else: + for (key, val) in attrs.items(): + setattr(instance, key, val) + instance.save() + return instance + + def get_customer_kwargs(self): + raise NotImplementedError + + def get_customer(self): + return self.ensure_stripe_resource(resource=Customer, + attrs=self.get_customer_kwargs()) + + def get_subscription_kwargs(self): + raise NotImplementedError + + def get_subscription(self): + customer = self.get_customer() + return self.ensure_stripe_resource(resource=customer.subscriptions, + attrs=self.get_subscription_kwargs()) + + def get_charge_kwargs(self): + raise NotImplementedError + + def get_charge(self): + return self.ensure_stripe_resource(resource=Charge, + attrs=self.get_charge_kwargs()) + + +class StripeModelForm(forms.ModelForm): + + """Payment form base for Stripe interaction + + Use this as a base class for payment forms. It includes the necessary fields + for card input and manipulates the Knockout field data bindings correctly. + + :cvar stripe_token: Stripe token passed from Stripe.js + :cvar cc_number: Credit card number field, used only by Stripe.js + :cvar cc_expiry: Credit card expiry field, used only by Stripe.js + :cvar cc_cvv: Credit card security code field, used only by Stripe.js + """ + + # Stripe token input from Stripe.js + stripe_token = forms.CharField( + required=False, + widget=forms.HiddenInput(attrs={ + 'data-bind': 'valueInit: stripe_token', + }) + ) + + # Fields used for fetching token with javascript, listed as form fields so + # that data can survive validation errors + cc_number = forms.CharField( + label=_('Card number'), + widget=forms.TextInput(attrs={ + 'data-bind': ('valueInit: cc_number, ' + 'textInput: cc_number, ' + '''css: {'field-error': error_cc_number() != null}''') + }), + max_length=25, + required=False) + cc_expiry = forms.CharField( + label=_('Card expiration'), + widget=forms.TextInput(attrs={ + 'data-bind': ('valueInit: cc_expiry, ' + 'textInput: cc_expiry, ' + '''css: {'field-error': error_cc_expiry() != null}''') + }), + max_length=10, + required=False) + cc_cvv = forms.CharField( + label=_('Card CVV'), + widget=forms.TextInput(attrs={ + 'data-bind': ('valueInit: cc_cvv, ' + 'textInput: cc_cvv, ' + '''css: {'field-error': error_cc_cvv() != null}'''), + 'autocomplete': 'off', + }), + max_length=8, + required=False) + + def __init__(self, *args, **kwargs): + self.customer = kwargs.pop('customer', None) + super(StripeModelForm, self).__init__(*args, **kwargs) + + def validate_stripe(self): + """Run validation against Stripe + + This is what will create several objects using the Stripe API. We need + to actually create the objects, as that is what will provide us with + validation errors to throw back at the form. + + Form fields can be accessed here via ``self.cleaned_data`` as this + method is triggered from the :py:meth:`clean` method. Cleaned form data + should already exist on the form at this point. + """ + raise NotImplementedError + + def clean_stripe_token(self): + data = self.cleaned_data['stripe_token'] + if not data: + data = None + return data + + def clean(self): + """Clean form to add Stripe objects via API during validation phase + + This will handle ensuring a customer and subscription exist and will + raise any issues as validation errors. This is required because part + of Stripe's validation happens on the API call to establish a + subscription. + """ + cleaned_data = super(StripeModelForm, self).clean() + + # Form isn't valid, no need to try to associate a card now + if not self.is_valid(): + self.clear_card_data() + return + + try: + self.validate_stripe() + except stripe.error.CardError as e: + self.clear_card_data() + field_lookup = { + 'cvc': 'cc_cvv', + 'number': 'cc_number', + 'expiry': 'cc_expiry', + 'exp_month': 'cc_expiry', + 'exp_year': 'cc_expiry', + } + error_field = field_lookup.get(e.param, None) + self.add_error( + error_field, + forms.ValidationError(e.message), + ) + except stripe.error.StripeError as e: + log.error('There was a problem communicating with Stripe: %s', + str(e), exc_info=True) + raise forms.ValidationError( + _('There was a problem communicating with Stripe')) + return cleaned_data + + def clear_card_data(self): + """Clear card data on validation errors + + This requires the form was created by passing in a mutable QueryDict + instance, see :py:cls:`readthedocs.payments.mixin.StripeMixin` + """ + try: + self.data['stripe_token'] = None + except AttributeError: + raise AttributeError('Form was passed immutable QueryDict POST data') + + def fields_with_cc_group(self): + group = { + 'is_cc_group': True, + 'fields': [] + } + for field in self: + if field.name in ['cc_number', 'cc_expiry', 'cc_cvv']: + group['fields'].append(field) + else: + yield field + yield group diff --git a/readthedocs/payments/mixins.py b/readthedocs/payments/mixins.py new file mode 100644 index 00000000000..c9deb904008 --- /dev/null +++ b/readthedocs/payments/mixins.py @@ -0,0 +1,25 @@ +"""Payment view mixin classes""" + +from django.conf import settings + + +class StripeMixin(object): + + """Adds Stripe publishable key to the context data""" + + def get_context_data(self, **kwargs): + context = super(StripeMixin, self).get_context_data(**kwargs) + context['stripe_publishable'] = settings.STRIPE_PUBLISHABLE + return context + + def get_form(self, data=None, files=None, **kwargs): + """Pass in copy of POST data to avoid read only QueryDicts on form + + This is used to be able to reset some important credit card fields if + card validation fails. In this case, the Stripe token was valid, but the + card was rejected during the charge or subscription instantiation. + """ + if self.request.method == 'POST': + data = self.request.POST.copy() + cls = self.get_form_class() + return cls(data=data, files=files, **kwargs) diff --git a/readthedocs/payments/static-src/payments/js/base.js b/readthedocs/payments/static-src/payments/js/base.js new file mode 100644 index 00000000000..05c95f0fab4 --- /dev/null +++ b/readthedocs/payments/static-src/payments/js/base.js @@ -0,0 +1,150 @@ +// Stripe payment form views + +var ko = require('knockout'), + payment = require('jquery.payment'), + $ = require('jquery'), + stripe = null; + +// TODO stripe doesn't support loading locally very well, do they? +if (typeof(window) != 'undefined' && typeof(window.Stripe) != 'undefined') { + stripe = window.Stripe || {}; +} + +/* Knockout binding to set initial observable values from HTML */ +ko.bindingHandlers.valueInit = { + init: function(element, accessor) { + var value = accessor(); + if (ko.isWriteableObservable(value)) { + value(element.value); + } + } +}; + +function PaymentView (config) { + var self = this, + config = config || {}; + + // Config + stripe.publishableKey = self.stripe_key = config.key; + self.form = config.form; + + // Credit card parameters + self.cc_number = ko.observable(null); + self.cc_expiry = ko.observable(null); + self.cc_cvv = ko.observable(null); + self.error_cc_number = ko.observable(null); + self.error_cc_expiry = ko.observable(null); + self.error_cc_cvv = ko.observable(null); + + self.stripe_token = ko.observable(null); + self.card_digits = ko.observable(null); + + // Form editing + self.is_editing_card = ko.observable(false); + self.show_card_form = ko.computed(function () { + return (self.is_editing_card() || + !self.card_digits() || + self.cc_number() || + self.cc_expiry() || + self.cc_cvv()); + }); + + // Credit card validation + self.initialize_form(); + + // Outputs + self.error = ko.observable(null); + + // Process form inputs and send API request to Stripe. Using jquery.payment + // for some validation, display field errors and some generic errors. + self.process_form = function () { + var expiry = $.payment.cardExpiryVal(self.cc_expiry()), + card = { + number: self.cc_number(), + exp_month: expiry.month, + exp_year: expiry.year, + cvc: self.cc_cvv() + }; + + self.error(null); + self.error_cc_number(null); + self.error_cc_expiry(null); + self.error_cc_cvv(null); + + if (!$.payment.validateCardNumber(card.number)) { + self.error_cc_number('Invalid card number'); + return false; + } + if (!$.payment.validateCardExpiry(card.exp_month, card.exp_year)) { + self.error_cc_expiry('Invalid expiration date'); + return false; + } + if (!$.payment.validateCardCVC(card.cvc)) { + self.error_cc_cvv('Invalid security code'); + return false; + } + + stripe.createToken(card, function(status, response) { + if (response.error) { + if (response.error.type == 'card_error') { + var code_map = { + 'invalid_number': self.error_cc_number, + 'incorrect_number': self.error_cc_number, + 'expired_card': self.error_cc_number, + 'card_declined': self.error_cc_number, + 'invalid_expiry_month': self.error_cc_expiry, + 'invalid_expiry_year': self.error_cc_expiry, + 'invalid_cvc': self.error_cc_cvv, + 'incorrect_cvc': self.error_cc_cvv, + } + var fn = code_map[response.error.code] || + self.error_cc_number; + fn(response.error.message); + } + else { + self.error_cc_number(response.error.message); + } + } + else { + self.submit_form(response.card.last4, response.id); + } + }); + }; + + self.process_full_form = function () { + if (self.show_card_form()) { + self.process_form() + } + else { + return true; + } + }; + +} + +PaymentView.prototype.submit_form = function (card_digits, token) { + this.form.find('#id_card_digits').val(card_digits); + this.form.find('#id_stripe_token').val(token); + this.form.submit(); +}; + +PaymentView.prototype.initialize_form = function () { + var cc_number = $('input#id_cc_number'), + cc_cvv = $('input#id_cc_cvv'), + cc_expiry = $('input#id_cc_expiry'); + + cc_number.payment('formatCardNumber'); + cc_expiry.payment('formatCardExpiry'); + cc_cvv.payment('formatCardCVC'); + + cc_number.trigger('keyup'); +}; + +PaymentView.init = function (config, obj) { + var view = new PaymentView(config), + obj = obj || $('#payment-form')[0]; + ko.applyBindings(view, obj); + return view; +} + +module.exports.PaymentView = PaymentView; diff --git a/readthedocs/payments/static/payments/css/form.css b/readthedocs/payments/static/payments/css/form.css new file mode 100644 index 00000000000..44c7f0828fc --- /dev/null +++ b/readthedocs/payments/static/payments/css/form.css @@ -0,0 +1,26 @@ +/* Payment form CSS */ + +form.payment input#id_cc_number { + height: 23px; + margin: 3px 0px 10px; + background: url('/static/donate/img/creditcard.png'); + background-repeat: no-repeat; + background-position: -0px -128px; + padding-left: 40px; +} + +form.payment input#id_cc_number.visa { + background-position: -40px -96px; +} +form.payment input#id_cc_number.mastercard { + background-position: -80px -64px; +} +form.payment input#id_cc_number.amex { + background-position: -120px -32px; +} +form.payment input#id_cc_number.discover { + background-position: -160px -0px; +} + +form.payment input#id_cc_expiry { width: 150px; } +form.payment input#id_cc_cvv { width: 100px; } diff --git a/readthedocs/payments/utils.py b/readthedocs/payments/utils.py new file mode 100644 index 00000000000..58dcc20ec8e --- /dev/null +++ b/readthedocs/payments/utils.py @@ -0,0 +1,30 @@ +"""Payment utility functions + +These are mostly one-off functions. Define the bulk of Stripe operations on +:py:cls:`readthedocs.payments.forms.StripeResourceMixin`. +""" + +import stripe +from django.conf import settings + +stripe.api_key = getattr(settings, 'STRIPE_SECRET', None) + + +def delete_customer(customer_id): + """Delete customer from Stripe, cancelling subscriptions""" + try: + customer = stripe.Customer.retrieve(customer_id) + return customer.delete() + except stripe.error.InvalidRequestError: + pass + + +def cancel_subscription(customer_id, subscription_id): + """Cancel Stripe subscription, if it exists""" + try: + customer = stripe.Customer.retrieve(customer_id) + if hasattr(customer, 'subscriptions'): + subscription = customer.subscriptions.retrieve(subscription_id) + return subscription.delete() + except stripe.error.StripeError: + pass diff --git a/readthedocs/projects/views/private.py b/readthedocs/projects/views/private.py index 207de770e22..3224e2e4db1 100644 --- a/readthedocs/projects/views/private.py +++ b/readthedocs/projects/views/private.py @@ -8,11 +8,9 @@ from django.core.urlresolvers import reverse from django.http import (HttpResponseRedirect, HttpResponseNotAllowed, Http404, HttpResponseBadRequest) -from django.db.models import Q from django.shortcuts import get_object_or_404, render_to_response, render from django.template import RequestContext from django.views.generic import View, TemplateView, ListView -from django.utils.decorators import method_decorator from django.utils.translation import ugettext_lazy as _ from formtools.wizard.views import SessionWizardView from allauth.socialaccount.models import SocialAccount @@ -26,7 +24,6 @@ from readthedocs.builds.models import VersionAlias from readthedocs.core.utils import trigger_build from readthedocs.core.mixins import ListViewWithForm -from readthedocs.oauth import utils as oauth_utils from readthedocs.projects.forms import ( ProjectBasicsForm, ProjectExtraForm, ProjectAdvancedForm, UpdateProjectForm, SubprojectForm, @@ -37,19 +34,12 @@ from readthedocs.projects import constants, tasks from readthedocs.projects.tasks import remove_path_from_web - +from readthedocs.core.mixins import LoginRequiredMixin from readthedocs.projects.signals import project_import log = logging.getLogger(__name__) -class LoginRequiredMixin(object): - - @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super(LoginRequiredMixin, self).dispatch(*args, **kwargs) - - class PrivateViewMixin(LoginRequiredMixin): pass diff --git a/readthedocs/settings/base.py b/readthedocs/settings/base.py index 5bcc2251f3f..e740d813ccd 100644 --- a/readthedocs/settings/base.py +++ b/readthedocs/settings/base.py @@ -214,6 +214,7 @@ 'readthedocs.privacy', 'readthedocs.gold', 'readthedocs.donate', + 'readthedocs.payments', # allauth 'allauth', diff --git a/readthedocs/settings/test.py b/readthedocs/settings/test.py index 6ce85b79461..0b20fe0779e 100644 --- a/readthedocs/settings/test.py +++ b/readthedocs/settings/test.py @@ -9,7 +9,6 @@ PRODUCTION_DOMAIN = 'readthedocs.org' GROK_API_HOST = 'http://localhost:8888' - if not os.environ.get('DJANGO_SETTINGS_SKIP_LOCAL', False): try: from local_settings import * # noqa diff --git a/readthedocs/templates/profiles/base_profile_edit.html b/readthedocs/templates/profiles/base_profile_edit.html index 634cedfc5c3..8cb11a086f0 100644 --- a/readthedocs/templates/profiles/base_profile_edit.html +++ b/readthedocs/templates/profiles/base_profile_edit.html @@ -48,7 +48,7 @@

  • {% trans "Social Accounts" %}
  • {% trans "Change Password" %}
  • {% trans "Change Email" %}
  • -
  • {% trans "Gold" %}
  • +
  • {% trans "Gold" %}
  • {% block edit_content_header %}{% endblock %}