Rework all payment pieces to unify implementation

spam
Anthony Johnson 2015-10-04 18:05:13 -07:00
parent b0c982510c
commit 443619495f
42 changed files with 1069 additions and 530 deletions

1
.gitignore vendored
View File

@ -35,3 +35,4 @@ whoosh_index
xml_output
public_cnames
public_symlinks
.rope_project/

View File

@ -836,27 +836,28 @@ 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;
}
div#payment-form input#cc-number.mastercard + div.cc-type {
background-position: 0px -46px;
}
div#payment-form input#cc-number.amex + div.cc-type {
background-position: 0px -69px;
}
div#payment-form input#cc-number.discover + div.cc-type {
background-position: 0px -92px;
/* Gold */
div.gold-subscription p.subscription-detail,
div.gold-subscription p.subscription-projects {
margin: 0em;
}
div#payment-form input#cc-expiry { width: 150px; }
div#payment-form input#cc-cvv { width: 100px; }
div.gold-subscription p.subscription-detail label {
display: inline-block;
}
div.gold-subscription p.subscription-detail-card > span {
font-family: monospace;
}
div.gold-subscription > form {
display: inline-block;
}
div.gold-subscription > form button {
margin: 1em .3em 1.5em 0em;
font-size: 1em;
}
/* Form Wizards */
div.actions.wizard-actions button.action-primary,

View File

@ -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 @@ class ListViewWithForm(ListView):
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)

View File

@ -0,0 +1,22 @@
{% if field.is_hidden %}
{{ field }}
{% else %}
<!-- Begin: {{ field.name }} -->
{{ field.errors }}
{% if 'data-bind' in field.field.widget.attrs %}
<ul
class="errorlist"
data-bind="visible: error_{{ field.name }}"
style="display: none;">
<li data-bind="text: error_{{ field.name }}"></li>
</ul>
{% endif %}
<p>
<label for="{{ field.id_for_label }}">{{ field.label }}:</label>
{{ field }}
{% if field.help_text %}
<span class="helptext">{{ field.help_text }}</span>
{% endif %}
</p>
<!-- End: {{ field.name }} -->
{% endif %}

View File

@ -1,23 +1,24 @@
import logging
import stripe
from django import forms
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
import stripe
from readthedocs.payments.forms import (StripeSubscriptionModelForm,
StripeResourceMixin)
from .models import Supporter
log = logging.getLogger(__name__)
class SupporterForm(forms.ModelForm):
class SupporterForm(StripeResourceMixin, StripeSubscriptionModelForm):
class Meta:
model = Supporter
fields = (
'last_4_digits',
'stripe_id',
'name',
'email',
'dollars',
@ -43,38 +44,34 @@ class SupporterForm(forms.ModelForm):
}),
'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):
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.api_key = settings.STRIPE_SECRET
charge = 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)

View File

@ -10,8 +10,8 @@ from .models import Supporter
class DonateProgressMixin(object):
'''Add donation progress to context data'''
def get_context_data(self):
context = super(DonateProgressMixin, self).get_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

View File

@ -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];

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,10 @@
{% block title %}{% trans "Sustainability" %}{% endblock %}
{% block extra_links %}
<link rel="stylesheet" href="{% static 'payments/css/form.css' %}" />
{% endblock %}
{% block extra_scripts %}
<script src="https://js.stripe.com/v2/" type="text/javascript"></script>
<script type="text/javascript" src="{% static 'vendor/knockout.js' %}"></script>
@ -14,7 +18,7 @@ var donate_views = require('donate/donate');
$(document).ready(function () {
var key;
//<![CDATA[
key = '{{ publishable }}';
key = '{{ stripe_publishable }}';
//]]>
var view = donate_views.DonateView.init({
@ -36,9 +40,24 @@ $(document).ready(function () {
payment is processed directly through <a href="https://stripe.com">Stripe</a>.
</p>
<form accept-charset="UTF-8" action="" method="post" id="donate-payment">
<form action="" method="post" id="donate-payment" class="payment">
{% csrf_token %}
{{ form.as_p }}
{% include "gold/cardform.html" %}
{{ form.non_field_errors }}
{% for field in form.fields_with_cc_group %}
{% if field.is_cc_group %}
<div class="subscription-card">
{% for groupfield in field.fields %}
{% include 'core/ko_form_field.html' with field=groupfield %}
{% endfor %}
</div>
{% else %}
{% include 'core/ko_form_field.html' with field=field %}
{% endif %}
{% endfor %}
{% trans "Donate" as form_submit_text %}
<input type="submit" value="{{ form_submit_text }}" data-bind="click: process_form" />
</form>
{% endblock %}

View File

@ -4,13 +4,16 @@ 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.conf import settings
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,7 +21,7 @@ from .mixins import DonateProgressMixin
log = logging.getLogger(__name__)
class DonateCreateView(SuccessMessageMixin, StripeMixin, CreateView):
class DonateCreateView(StripeMixin, CreateView):
'''Create a donation locally and in Stripe'''
form_class = SupporterForm
@ -31,10 +34,9 @@ class DonateCreateView(SuccessMessageMixin, StripeMixin, CreateView):
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):

View File

@ -0,0 +1 @@
default_app_config = 'readthedocs.gold.apps.GoldAppConfig'

16
readthedocs/gold/apps.py Normal file
View File

@ -0,0 +1,16 @@
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

View File

@ -1,20 +1,25 @@
from django import forms
from .models import LEVEL_CHOICES
from stripe.error import InvalidRequestError
from readthedocs.payments.forms import (StripeSubscriptionModelForm,
StripeResourceMixin)
from .models import LEVEL_CHOICES, GoldUser
class CardForm(forms.Form):
class GoldSubscriptionForm(StripeResourceMixin, StripeSubscriptionModelForm):
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 +27,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(),
'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(

View File

@ -0,0 +1,13 @@
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, **kwargs):
"""On Gold subscription deletion, remove the customer from Stripe"""
if instance.stripe_id is not None:
utils.delete_customer(instance.stripe_id)

View File

@ -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;

File diff suppressed because one or more lines are too long

View File

@ -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 %}
<div class="row">
<div class="span6 columns">
</div>
</div>
<div class="page-header">
<h1>Cancel Gold Subscription</h1>
</div>
Are you sure you want to cancel?
<div class="row">
<div class="span6 columns">
<form id="user_form" accept-charset="UTF-8" action="{% url "gold_cancel" %}" class="form-stacked" method="post">
{% csrf_token %}
<input type='submit' value='Yes'>
</form>
</div>
</div>
{% endblock %}

View File

@ -1,20 +0,0 @@
{% load i18n %}
<div id="payment-form">
<label for="cc-number">{% trans "Card Number" %}:</label>
<input id="cc-number" type="text" data-bind="value: cc_number, css: { 'field-error': cc_error_number() != null}">
<div class="cc-type"></div>
<p class="error" data-bind="text: cc_error_number, visible: cc_error_number() != null"></p>
<label for="cc-expiry">{% trans "Card Expiry" %}:</label>
<input id="cc-expiry" type="text" data-bind="value: cc_expiry, css: { 'field-error': cc_error_expiry() != null}">
<p class="error" data-bind="text: cc_error_expiry, visible: cc_error_expiry() != null"></p>
<label for="cc-cvv">{% trans "Security code (CVV)" %}:</label>
<input id="cc-cvv" type="text" autocomplete="off" data-bind="value: cc_cvv, css: { 'field-error': cc_error_cvv() != null}">
<p class="error" data-bind="text: cc_error_cvv, visible: cc_error_cvv() != null"></p>
<div class="error" data-bind="text: error"></div>
<input type="submit" value="Submit" data-bind="click: process_form">
</div>

View File

@ -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 %}
<script src="https://js.stripe.com/v2/" type="text/javascript"></script>
<script type="text/javascript" src="{% static 'vendor/knockout.js' %}"></script>
<script type="text/javascript" src="{% static 'gold/js/gold.js' %}"></script>
<script type="text/javascript">
var gold_views = require('gold/gold');
$(document).ready(function () {
var key;
//<![CDATA[
key = '{{ publishable }}';
//]]>
var view = gold_views.GoldView.init({
key: key,
form: $('form#gold-register')
});
});
</script>
{% endblock %}
{% block edit_content %}
<div>
<h2>Change Credit Card</h2>
<form accept-charset="UTF-8" action="{% url "gold_edit" %}" method="post" id="gold-register">
{% csrf_token %}
{% for field in form %}
{{ field }}
{% endfor %}
{% include "gold/cardform.html" %}
</form>
</div>
{% endblock %}

View File

@ -1,14 +0,0 @@
{% if form.is_bound and not form.is_valid %}
<div class="alert-message block-message error">
<div class="errors">
{% for field in form.visible_fields %}
{% for error in field.errors %}
<p>{{ field.label }}: {{ error }}</p>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
</div>
{% endif %}

View File

@ -1,6 +0,0 @@
<div class="clearfix">
{{ field.label_tag }}
<div class="input">
{{ field }}
</div>
</div>

View File

@ -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 %}
<script src="https://js.stripe.com/v1/" type="text/javascript"></script>
<script type="text/javascript">
//<![CDATA[
Stripe.publishableKey = '{{ publishable }}';
//]]>
</script>
<script type="text/javascript">
$(function() {
$("#user_form").submit(function() {
if ( $("#credit-card").is(":visible")) {
var form = this;
var card = {
number: $("#credit_card_number").val(),
expMonth: $("#expiry_month").val(),
expYear: $("#expiry_year").val(),
cvc: $("#cvv").val()
};
Stripe.createToken(card, function(status, response) {
if (status === 200) {
console.log(status, response);
$("#credit-card-errors").hide();
$("#last_4_digits").val(response.card.last4);
$("#stripe_token").val(response.id);
form.submit();
} else {
$("#stripe-error-message").text(response.error.message);
$("#credit-card-errors").show();
$("#user_submit").attr("disabled", false);
}
});
return false;
}
return true
});
$("#change-card a").click(function() {
$("#change-card").hide();
$("#credit-card").show();
$("#credit_card_number").focus();
return false;
});
});
</script>
{% endblock %}
{% block edit_content %}
<div class="row">
<div class="span6 columns">
</div>
</div>
<div class="page-header">
<h1>Give money to Read the Docs once</h1>
</div>
<div class="row">
<div class="span6 columns">
<form id="user_form" accept-charset="UTF-8" action="{% url "gold_once" %}" class="form-stacked" method="post">
{% csrf_token %}
{% include "gold/errors.html" %}
{% for field in form.visible_fields %}
{% include "gold/field.html" %}
{% endfor %}
{% include "gold/cardform.html" %}
</form>
</div>
</div>
{% endblock %}

View File

@ -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 %}
<script src="https://js.stripe.com/v2/" type="text/javascript"></script>
<script type="text/javascript" src="{% static 'vendor/knockout.js' %}"></script>
<script type="text/javascript" src="{% static 'gold/js/gold.js' %}"></script>
<script type="text/javascript">
var gold_views = require('gold/gold');
$(document).ready(function () {
var key;
//<![CDATA[
key = '{{ publishable }}';
//]]>
var view = gold_views.GoldView.init({
key: key,
form: $('form#gold-register')
});
});
</script>
{% endblock %}
{% block edit_content %}
<div>
<h2>Gold Status</h2>
{% if gold_user.subscribed %}
<p>
Thanks for supporting Read the Docs! It really means a lot to us.
</p>
<p>
Level: {{ gold_user.get_level_display }}
</p>
<p>
Card: Ends with {{ gold_user.last_4_digits }}
</p>
<h3>Projects</h3>
<p>
You can adopt {{ gold_user.num_supported_projects }} projects with your subscription. <a href="{% url "gold_projects" %}">Select Projects</a>
</p>
<h3>Changes</h3>
<p>
<a href="{% url "gold_cancel" %}">Cancel</a> or <a href="{% url "gold_edit" %}">Change</a> your subscription.
</p>
{% else %}
<p>
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.
</p>
<p>
You can make one-time donations on our <a href="https://readthedocs.org/sustainability/">sustainability</a> page.
</p>
<h3>Become a Gold Member</h3>
<form accept-charset="UTF-8" action="{% url "gold_register" %}" method="post" id="gold-register">
{% csrf_token %}
{% for field in form %}
{{ field }}
{% endfor %}
{% include "gold/cardform.html" %}
<em>All information is submitted directly to Stripe.</em>
</form>
{% endif %}
{% endblock %}

View File

@ -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 %}
<h2>Cancel Gold Subscription</h2>
<p>
{% blocktrans %}
Are you sure you want to cancel your subscription?
{% endblocktrans %}
</p>
<form method="post" action="{% url "gold_cancel" %}">
{% csrf_token %}
<input type="submit" value="{% trans "Cancel subscription" %}">
</form>
{% endblock %}

View File

@ -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 %}
<script src="https://js.stripe.com/v2/" type="text/javascript"></script>
<script type="text/javascript" src="{% static 'vendor/knockout.js' %}"></script>
<script type="text/javascript" src="{% static 'gold/js/gold.js' %}"></script>
<script type="text/javascript">
var gold_views = require('gold/gold');
$(document).ready(function () {
var key;
//<![CDATA[
key = '{{ publishable }}';
//]]>
var view = gold_views.GoldView.init({
key: key,
form: $('form#gold-register')
});
});
</script>
{% endblock %}
{% block edit_content %}
<div class="gold-subscription">
<h2>{% trans "Gold Subscription" %}</h2>
<p>
{% blocktrans %}
Thanks for supporting Read the Docs! It really means a lot to us.
{% endblocktrans %}
</p>
<p class="subscription-detail subscription-detail-level">
<label>{% trans "Level" %}:</label>
<span>{{ golduser.get_level_display }}</span>
</p>
<p class="subscription-detail subscription-detail-card">
<label>{% trans "Card" %}:</label>
<span>****-{{ golduser.last_4_digits }}</span>
</p>
<form method="get" action="{% url "gold_subscription" %}" class="subscription-update">
<button>{% trans "Update Subscription" %}</button>
</form>
<form method="get" action="{% url "gold_cancel" %}" class="subscription-cancel">
<button>{% trans "Cancel Subscription" %}</button>
</form>
<h3>{% trans "Projects" %}</h3>
<p class="subscription-projects">
{% blocktrans with projects=golduser.num_supported_projects %}
You can adopt {{ projects }} projects with your subscription.
{% endblocktrans %}
</p>
<form method="get" action="{% url "gold_projects" %}" class="subscription-projects">
<button>{% trans "Select Projects" %}</button>
</form>
</div>
{% endblock %}

View File

@ -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 %}
<link rel="stylesheet" href="{% static 'payments/css/form.css' %}" />
{% endblock %}
{% block extra_scripts %}
<script src="https://js.stripe.com/v2/" type="text/javascript"></script>
<script type="text/javascript" src="{% static 'vendor/knockout.js' %}"></script>
<script type="text/javascript" src="{% static 'gold/js/gold.js' %}"></script>
<script type="text/javascript">
var gold_views = require('gold/gold');
$(document).ready(function () {
var key;
//<![CDATA[
key = '{{ stripe_publishable }}';
//]]>
var view = gold_views.GoldView.init({
key: key,
form: $('form#gold-register')
});
});
</script>
{% endblock %}
{% block edit_content %}
<div>
<h2>Gold Status</h2>
<p>
{% 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 %}
</p>
<p>
{% blocktrans %}
You can make one-time donations on our <a href="https://readthedocs.org/sustainability/">sustainability</a> page.
{% endblocktrans %}
</p>
{% trans "Become a Gold Member" as subscription_title %}
{% if golduser %}
{% trans "Update Your Subscription" as subscription_title %}
{% endif %}
<h3>{{ subscription_title }}</h3>
<form accept-charset="UTF-8" action="" method="post" id="gold-register" class="payment">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form.fields_with_cc_group %}
{% if field.is_cc_group %}
<p
data-bind="visible: card_digits"
style="display: none;"
class="subscription-card">
<label>{% trans "Current card" %}:</label>
<span class="subscription-card-number">
****-<span data-bind="text: card_digits"></span>
</span>
</p>
<div data-bind="visible: !show_card_form()">
<a
href="#"
data-bind="click: function () { is_editing_card(true); }"
class="subscription-edit-link">
{% trans "Edit Card" %}
</a>
</div>
<div
class="subscription-card"
data-bind="visible: show_card_form"
style="display: none;">
{% for groupfield in field.fields %}
{% include 'core/ko_form_field.html' with field=groupfield %}
{% endfor %}
</div>
{% 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 %}
<input type="submit" value="{{ form_submit_text }}" data-bind="click: process_full_form" />
<em>{% trans "All information is submitted directly to Stripe." %}</em>
</form>
{% endblock %}

View File

@ -1,18 +0,0 @@
{% extends "profiles/base_profile_edit.html" %}
{% block profile-admin-gold-edit %}active{% endblock %}
{% block title %}
Thanks!
{% endblock %}
{% block edit_content %}
<div class="page-header">
<h1>Thanks</h1>
</div>
<p>
Thanks for contributing to Read the Docs!
</p>
{% endblock %}

View File

View File

@ -0,0 +1,219 @@
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, ''),
(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('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),
])

View File

@ -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),
])

View File

@ -4,15 +4,16 @@ 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>{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>{project_slug})/$'.format(
project_slug=PROJECT_SLUG_REGEX
),
views.projects_remove,
name='gold_projects_remove'),
)

View File

@ -1,146 +1,90 @@
import datetime
from django.core.urlresolvers import reverse
import stripe
from django.core.urlresolvers import reverse, reverse_lazy
from django.conf import settings
from django.contrib.messages.views import SuccessMessageMixin
from django.contrib import messages
from django.db import IntegrityError
from django.http import HttpResponseRedirect, Http404
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 CreateView, DeleteView, UpdateView, DetailView
import stripe
from .forms import CardForm, GoldProjectForm
from .models import GoldUser
from readthedocs.core.mixins import LoginRequiredMixin
from readthedocs.projects.models import Project
from readthedocs.payments.mixins import StripeMixin
from .forms import GoldSubscriptionForm, GoldProjectForm
from .models import GoldUser
stripe.api_key = settings.STRIPE_SECRET
def soon():
soon = datetime.date.today() + datetime.timedelta(days=30)
return {'month': soon.month, 'year': soon.year}
class GoldSubscriptionView(SuccessMessageMixin, StripeMixin, LoginRequiredMixin):
model = GoldUser
form_class = GoldSubscriptionForm
def get_object(self):
try:
return self.get_queryset().get(user=self.request.user)
except self.model.DoesNotExist:
return None
def get_context_data(self, **kwargs):
context = (super(GoldSubscriptionView, 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"""
kwargs['customer'] = self.request.user
return super(GoldSubscriptionView, self).get_form(data, files, **kwargs)
def get_success_url(self, **kwargs):
return reverse_lazy('gold_detail')
def get_template_names(self):
return ('gold/subscription{0}.html'
.format(self.template_name_suffix))
@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():
# Subscription Views
class DetailGoldSubscription(GoldSubscriptionView, DetailView):
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'))
else:
form = CardForm()
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(self, request, *args, **kwargs):
resp = super(DetailGoldSubscription, self).get(request, *args, **kwargs)
if self.object is None:
return HttpResponseRedirect(reverse('gold_subscription'))
return resp
@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():
customer = stripe.Customer.retrieve(user.stripe_id)
customer.card = form.cleaned_data['stripe_token']
customer.plan = form.cleaned_data['level']
customer.save()
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()
return HttpResponseRedirect(reverse('gold_thanks'))
else:
form = CardForm(initial={'level': user.level})
return render_to_response(
'gold/edit.html',
{
'form': form,
'publishable': settings.STRIPE_PUBLISHABLE,
'soon': soon(),
},
context_instance=RequestContext(request)
)
class UpdateGoldSubscription(GoldSubscriptionView, UpdateView):
success_message = _('Your subscription has been updated')
@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)
)
class DeleteGoldSubscription(GoldSubscriptionView, DeleteView):
"""Delete Gold subscription view
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)
)
On object deletion, the corresponding Stripe customer is deleted as well.
Deletion is triggered on subscription deletion, to ensure the subscription
is synced with Stripe.
"""
success_message = _('Your subscription has been cancelled')
def post(self, request, *args, **kwargs):
"""Add success message to delete post"""
resp = super(SuccessMessageMixin, self).post(request, *args, **kwargs)
success_message = self.get_success_message({})
if success_message:
messages.success(self.request, success_message)
return resp
@login_required

View File

View File

@ -0,0 +1,190 @@
"""Payment forms"""
import logging
import stripe
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 _
log = logging.getLogger(__name__)
stripe.api_key = getattr(settings, 'STRIPE_SECRET')
class StripeResourceMixin(object):
"""Stripe actions for resources, available as a Form mixin class"""
def ensure_stripe_resource(self, resource, attrs):
try:
return resource.retrieve(attrs['id'])
except (KeyError, InvalidRequestError):
try:
del attrs['id']
except KeyError:
pass
try:
return resource.create(**attrs)
except InvalidRequestError:
return None
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):
customer = self.get_customer()
return self.ensure_stripe_resource(resource=Charge,
attrs=self.get_charge_kwargs())
class StripeSubscriptionModelForm(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, '
'value: 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, '
'value: 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, '
'value: 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(StripeSubscriptionModelForm, 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 NotImplemented('Stripe validation is not implemented')
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(StripeSubscriptionModelForm, 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'))
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

View File

@ -0,0 +1,23 @@
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)

View File

@ -5,12 +5,21 @@ var ko = require('knockout'),
$ = 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 || {};
@ -23,9 +32,22 @@ function PaymentView (config) {
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);
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();
@ -45,52 +67,64 @@ function PaymentView (config) {
};
self.error(null);
self.cc_error_number(null);
self.cc_error_expiry(null);
self.cc_error_cvv(null);
self.error_cc_number(null);
self.error_cc_expiry(null);
self.error_cc_cvv(null);
if (!$.payment.validateCardNumber(card.number)) {
self.cc_error_number('Invalid card number');
console.log(card);
self.error_cc_number('Invalid card number');
return false;
}
if (!$.payment.validateCardExpiry(card.exp_month, card.exp_year)) {
self.cc_error_expiry('Invalid expiration date');
self.error_cc_expiry('Invalid expiration date');
return false;
}
if (!$.payment.validateCardCVC(card.cvc)) {
self.cc_error_cvv('Invalid security code');
self.error_cc_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();
self.submit_form(response.card.last4, response.id);
}
else {
self.error(response.error.message);
}
});
};
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#cc-number'),
cc_cvv = $('input#cc-cvv'),
cc_expiry = $('input#cc-expiry');
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 GoldView(config),
var view = new PaymentView(config),
obj = obj || $('#payment-form')[0];
ko.applyBindings(view, obj);
return view;

View File

@ -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; }

View File

@ -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)
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)
subscription.delete()
except stripe.error.StripeError as e:
pass

View File

@ -12,7 +12,6 @@ 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
@ -37,19 +36,12 @@ from readthedocs.projects.views.base import ProjectAdminMixin
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

View File

@ -214,6 +214,7 @@ INSTALLED_APPS = [
'readthedocs.privacy',
'readthedocs.gold',
'readthedocs.donate',
'readthedocs.payments',
# allauth
'allauth',

View File

@ -48,7 +48,7 @@
<li class="{% block profile-admin-social-accounts %}{% endblock %}"><a href="{% url 'socialaccount_connections' %}">{% trans "Social Accounts" %}</a></li>
<li class="{% block profile-admin-change-password %}{% endblock %}"><a href="{% url 'account_change_password' %}">{% trans "Change Password" %}</a></li>
<li class="{% block profile-admin-change-email %}{% endblock %}"><a href="{% url 'account_email' %}">{% trans "Change Email" %}</a></li>
<li class="{% block profile-admin-gold-edit %}{% endblock %}"><a href="{% url 'gold_register' %}">{% trans "Gold" %}</a></li>
<li class="{% block profile-admin-gold-edit %}{% endblock %}"><a href="{% url 'gold_detail' %}">{% trans "Gold" %}</a></li>
</ul>
<div>
<h2>{% block edit_content_header %}{% endblock %}</h2>