Stripe gold subscriptions.

This commit is contained in:
Brian Simpson
2013-10-18 17:28:56 -04:00
parent 8db4d6721a
commit a0d39d680e
11 changed files with 477 additions and 86 deletions

View File

@@ -68,6 +68,8 @@ PAYPAL_BUTTONID_CREDDITS_BYYEAR =
STRIPE_WEBHOOK_SECRET =
STRIPE_PUBLIC_KEY =
STRIPE_SECRET_KEY =
STRIPE_MONTHLY_GOLD_PLAN =
STRIPE_YEARLY_GOLD_PLAN =
COINBASE_WEBHOOK_SECRET =
COINBASE_BUTTONID_ONETIME_1MO =

View File

@@ -246,6 +246,7 @@ def make_map():
mc('/gold', controller='forms', action="gold")
mc('/gold/creditgild/:passthrough', controller='forms', action='creditgild')
mc('/gold/thanks', controller='front', action='goldthanks')
mc('/gold/subscription', controller='forms', action='subscription')
mc('/password', controller='forms', action="password")
mc('/:action', controller='front',
@@ -311,6 +312,10 @@ def make_map():
mc('/api/distinguish/:how', controller='api', action="distinguish")
mc('/api/spendcreddits', controller='ipn', action="spendcreddits")
mc('/api/stripecharge/gold', controller='stripe', action='goldcharge')
mc('/api/modify_subscription', controller='stripe',
action='modify_subscription')
mc('/api/cancel_subscription', controller='stripe',
action='cancel_subscription')
mc('/api/stripewebhook/gold/:secret', controller='stripe',
action='goldwebhook')
mc('/api/coinbasewebhook/gold/:secret', controller='coinbase',

View File

@@ -1495,3 +1495,10 @@ class FormsController(RedditController):
giftmessage, passthrough,
comment)
).render()
@validate(VUser())
def GET_subscription(self):
user = c.user
content = GoldSubscription(user)
return BoringPage(_("reddit gold subscription"), show_sidebar=False,
content=content).render()

View File

@@ -39,15 +39,19 @@ from r2.lib.validator import (
nop,
textresponse,
validatedForm,
VByName,
VFloat,
VInt,
VLength,
VModhash,
VOneOf,
VPrintable,
VUser,
)
from r2.models import (
Account,
account_by_payingid,
account_from_stripe_customer_id,
accountid_from_paypalsubscription,
admintools,
append_random_bottlecap_phrase,
@@ -63,6 +67,7 @@ from r2.models import (
update_gold_transaction,
)
stripe.api_key = g.STRIPE_SECRET_KEY
def generate_blob(data):
passthrough = randstr(15)
@@ -602,7 +607,13 @@ class GoldPaymentController(RedditController):
msg = _('Your reddit gold payment has failed, contact '
'%(gold_email)s for details') % {'gold_email':
g.goldthanks_email}
# probably want to update gold_table here
elif event_type == 'failed_subscription':
subject = _('reddit gold subscription payment failed')
msg = _('Your reddit gold subscription payment has failed. '
'Please go to http://www.reddit.com/subscription to '
'make sure your information is correct, or contact '
'%(gold_email)s for details') % {'gold_email':
g.goldthanks_email}
elif event_type == 'refunded':
if not (existing and existing.status == 'processed'):
return
@@ -622,6 +633,26 @@ class GoldPaymentController(RedditController):
return
send_system_message(buyer, subject, msg)
def handle_stripe_error(fn):
def wrapper(cls, form, *a, **kw):
try:
return fn(cls, form, *a, **kw)
except stripe.CardError as e:
form.set_html('.status',
_('error: %(error)s') % {'error': e.message})
form.find('.stripe-submit').removeAttr('disabled').end()
except stripe.InvalidRequestError as e:
form.set_html('.status', _('invalid request'))
except stripe.APIConnectionError as e:
form.set_html('.status', _('api error'))
except stripe.AuthenticationError as e:
form.set_html('.status', _('connection error'))
except stripe.StripeError as e:
form.set_html('.status', _('error'))
g.log.error('stripe error: %s' % e)
return wrapper
class StripeController(GoldPaymentController):
name = 'stripe'
webhook_secret = g.STRIPE_WEBHOOK_SECRET
@@ -631,9 +662,22 @@ class StripeController(GoldPaymentController):
'charge.refunded': 'refunded',
'customer.created': 'noop',
'customer.card.created': 'noop',
'customer.card.deleted': 'noop',
'transfer.created': 'noop',
'transfer.paid': 'noop',
'balance.available': 'noop',
'invoice.created': 'noop',
'invoice.updated': 'noop',
'invoice.payment_succeeded': 'noop',
'invoice.payment_failed': 'failed_subscription',
'invoiceitem.deleted': 'noop',
'customer.subscription.created': 'noop',
'customer.deleted': 'noop',
'customer.updated': 'noop',
'customer.subscription.deleted': 'noop',
'customer.subscription.trial_will_end': 'noop',
'customer.subscription.updated': 'noop',
'dummy': 'noop',
}
@classmethod
@@ -641,6 +685,25 @@ class StripeController(GoldPaymentController):
event_dict = json.loads(request.body)
event = stripe.Event.construct_from(event_dict, g.STRIPE_SECRET_KEY)
status = event.type
if status == 'invoice.created':
# sent 1 hr before a subscription is charged
invoice = event.data.object
customer_id = invoice.customer
account = account_from_stripe_customer_id(customer_id)
if not account or (account and account._banned):
# there's no associated account - delete the subscription
# to cancel the charge
g.log.error('no account for stripe invoice: %s', invoice)
customer = stripe.Customer.retrieve(customer_id)
customer.delete()
elif status == 'invoice.payment_failed':
invoice = event.data.object
customer_id = invoice.customer
buyer = account_from_stripe_customer_id(customer_id)
webhook = Webhook(subscr_id=customer_id, buyer=buyer)
return status, webhook
event_type = cls.event_type_mappings.get(status)
if not event_type:
raise ValueError('Stripe: unrecognized status %s' % status)
@@ -649,26 +712,104 @@ class StripeController(GoldPaymentController):
charge = event.data.object
description = charge.description
invoice_id = charge.invoice
transaction_id = 'S%s' % charge.id
pennies = charge.amount
months, days = months_and_days_from_pennies(pennies)
try:
passthrough, buyer_name = description.split('-', 1)
except ValueError:
g.log.error('stripe_error on charge: %s', charge)
raise
webhook = Webhook(passthrough=passthrough,
transaction_id=transaction_id, pennies=pennies, months=months)
return status, webhook
if status == 'charge.failed' and invoice_id:
# we'll get an additional failure notification event of
# "invoice.payment_failed", don't double notify
return 'dummy', None
elif invoice_id:
# subscription charge - special handling
customer_id = charge.customer
buyer = account_from_stripe_customer_id(customer_id)
if not buyer:
raise ValueError('no buyer for stripe charge: %s' % charge.id)
webhook = Webhook(transaction_id=transaction_id,
subscr_id=customer_id, pennies=pennies,
months=months, goldtype='autorenew',
buyer=buyer)
return status, webhook
else:
try:
passthrough, buyer_name = description.split('-', 1)
except ValueError:
g.log.error('stripe_error on charge: %s', charge)
raise
webhook = Webhook(passthrough=passthrough,
transaction_id=transaction_id, pennies=pennies, months=months)
return status, webhook
@classmethod
@handle_stripe_error
def create_customer(cls, form, token, plan=None):
description = c.user.name
customer = stripe.Customer.create(card=token, description=description,
plan=plan)
if (customer['active_card']['address_line1_check'] == 'fail' or
customer['active_card']['address_zip_check'] == 'fail'):
form.set_html('.status',
_('error: address verification failed'))
form.find('.stripe-submit').removeAttr('disabled').end()
return None
elif customer['active_card']['cvc_check'] == 'fail':
form.set_html('.status', _('error: cvc check failed'))
form.find('.stripe-submit').removeAttr('disabled').end()
return None
else:
return customer
@classmethod
@handle_stripe_error
def charge_customer(cls, form, customer, pennies, passthrough):
charge = stripe.Charge.create(
amount=pennies,
currency="usd",
customer=customer['id'],
description='%s-%s' % (passthrough, c.user.name)
)
return charge
@classmethod
@handle_stripe_error
def set_creditcard(cls, form, user, token):
if not getattr(user, 'stripe_customer_id', None):
return
customer = stripe.Customer.retrieve(user.stripe_customer_id)
customer.card = token
customer.save()
return customer
@classmethod
@handle_stripe_error
def cancel_subscription(user):
if not getattr(user, 'stripe_customer_id', None):
return
customer = stripe.Customer.retrieve(user.stripe_customer_id)
customer.delete()
user.stripe_customer_id = None
user._commit()
subject = _('your gold subscription has been cancelled')
message = _('if you have any questions please email %(email)s')
message %= {'email': g.goldthanks_email}
send_system_message(user, subject, message)
return customer
@validatedForm(VUser(),
token=nop('stripeToken'),
passthrough=VPrintable("passthrough", max_length=50),
pennies=VInt('pennies'),
months=VInt("months"))
def POST_goldcharge(self, form, jquery, token, passthrough, pennies, months):
months=VInt("months"),
period=VOneOf("period", ("monthly", "yearly")))
def POST_goldcharge(self, form, jquery, token, passthrough, pennies, months,
period):
"""
Submit charge to stripe.
@@ -687,58 +828,63 @@ class StripeController(GoldPaymentController):
g.log.debug('POST_goldcharge: %s' % e.message)
return
penny_months, days = months_and_days_from_pennies(pennies)
if not months or months != penny_months:
form.set_html('.status', _('stop trying to trick the form'))
if period:
plan_id = (g.STRIPE_MONTHLY_GOLD_PLAN if period == 'monthly'
else g.STRIPE_YEARLY_GOLD_PLAN)
else:
plan_id = None
penny_months, days = months_and_days_from_pennies(pennies)
if not months or months != penny_months:
form.set_html('.status', _('stop trying to trick the form'))
return
customer = self.create_customer(form, token, plan=plan_id)
if not customer:
return
stripe.api_key = g.STRIPE_SECRET_KEY
if period:
c.user.stripe_customer_id = customer.id
c.user._commit()
try:
customer = stripe.Customer.create(card=token)
if (customer['active_card']['address_line1_check'] == 'fail' or
customer['active_card']['address_zip_check'] == 'fail'):
form.set_html('.status',
_('error: address verification failed'))
form.find('.stripe-submit').removeAttr('disabled').end()
return
if customer['active_card']['cvc_check'] == 'fail':
form.set_html('.status', _('error: cvc check failed'))
form.find('.stripe-submit').removeAttr('disabled').end()
return
charge = stripe.Charge.create(
amount=pennies,
currency="usd",
customer=customer['id'],
description='%s-%s' % (passthrough, c.user.name)
)
except stripe.CardError as e:
form.set_html('.status', 'error: %s' % e.message)
form.find('.stripe-submit').removeAttr('disabled').end()
except stripe.InvalidRequestError as e:
form.set_html('.status', _('invalid request'))
except stripe.APIConnectionError as e:
form.set_html('.status', _('api error'))
except stripe.AuthenticationError as e:
form.set_html('.status', _('connection error'))
except stripe.StripeError as e:
form.set_html('.status', _('error'))
g.log.error('stripe error: %s' % e)
status = _('subscription created')
subject = _('reddit gold subscription')
body = _('Your subscription is being processed and reddit gold '
'will be delivered shortly.')
else:
form.set_html('.status', _('payment submitted'))
charge = self.charge_customer(form, customer, pennies, passthrough)
if not charge:
return
# webhook usually sends near instantly, send a message in case
status = _('payment submitted')
subject = _('reddit gold payment')
msg = _('Your payment is being processed and reddit gold will be '
'delivered shortly.')
msg = append_random_bottlecap_phrase(msg)
body = _('Your payment is being processed and reddit gold '
'will be delivered shortly.')
send_system_message(c.user, subject, msg,
distinguished='gold-auto')
form.set_html('.status', status)
body = append_random_bottlecap_phrase(body)
send_system_message(c.user, subject, body, distinguished='gold-auto')
@validatedForm(VUser(),
VModhash(),
token=nop('stripeToken'))
def POST_modify_subscription(self, form, jquery, token):
customer = self.set_creditcard(form, c.user, token)
if not customer:
return
form.set_html('.status', _('your payment details have been updated'))
@validatedForm(VUser(),
VModhash(),
user=VByName('user'))
def POST_cancel_subscription(self, form, jquery, user):
if user != c.user and not c.user_is_admin:
abort(403, "Forbidden")
customer = self.cancel_subscription(user)
if not customer:
return
form.set_html(".status", _("your subscription has been cancelled"))
class CoinbaseController(GoldPaymentController):
name = 'coinbase'

View File

@@ -36,6 +36,7 @@ from r2.models.gold import (
days_to_pennies,
gold_goal_on,
gold_revenue_on,
get_subscription_details,
TIMEZONE as GOLD_TIMEZONE,
)
from r2.models.promo import (
@@ -88,6 +89,7 @@ from r2.lib.utils import Storage
from r2.lib.utils import precise_format_timedelta
from babel.numbers import format_currency
from babel.dates import format_date
from collections import defaultdict
import csv
import cStringIO
@@ -1703,6 +1705,8 @@ class ProfileBar(Templated):
if hasattr(user, "gold_subscr_id"):
self.gold_subscr_id = user.gold_subscr_id
if hasattr(user, "stripe_customer_id"):
self.stripe_customer_id = user.stripe_customer_id
if ((user._id == c.user._id or c.user_is_admin) and
user.gold_creddits > 0):
@@ -2171,7 +2175,7 @@ class GoldPayment(Templated):
paypal_buttonid = g.PAYPAL_BUTTONID_AUTORENEW_BYYEAR
quantity = None
stripe_key = None
stripe_key = g.STRIPE_PUBLIC_KEY
coinbase_button_id = None
elif goldtype == "onetime":
@@ -2250,6 +2254,42 @@ class GoldPayment(Templated):
coinbase_button_id=coinbase_button_id)
class GoldSubscription(Templated):
def __init__(self, user):
if user.stripe_customer_id:
details = get_subscription_details(user)
else:
details = None
if details:
self.has_stripe_subscription = True
date = details['next_charge_date']
next_charge_date = format_date(date, format="short",
locale=c.locale)
credit_card_last4 = details['credit_card_last4']
amount = format_currency(details['pennies']/100, 'USD',
locale=c.locale)
text = _("you have a credit card gold subscription. your card "
"(ending in %(last4)s) will be charged %(amount)s on "
"%(date)s.")
self.text = text % dict(last4=credit_card_last4,
amount=amount,
date=next_charge_date)
self.user_fullname = user._fullname
else:
self.has_stripe_subscription = False
paypal_subscr_id = getattr(user, 'gold_subscr_id', None)
if paypal_subscr_id:
self.has_paypal_subscription = True
self.paypal_subscr_id = paypal_subscr_id
self.paypal_url = "https://www.paypal.com/cgi-bin/webscr?cmd=_subscr-find&alias=%s" % g.goldthanks_email
else:
self.has_paypal_subscription = False
self.stripe_key = g.STRIPE_PUBLIC_KEY
Templated.__init__(self)
class CreditGild(Templated):
"""Page for credit card payments for comment gilding."""
pass

View File

@@ -40,10 +40,13 @@ from random import choice
from time import time
from r2.lib.db.tdb_cassandra import NotFound
from r2.models import Account
from r2.models.subreddit import Frontpage
from r2.models.wiki import WikiPage
from r2.lib.memoize import memoize
import stripe
gold_bonus_cutoff = datetime(2010,7,27,0,0,0,0,g.tz)
gold_static_goal_cutoff = datetime(2013, 11, 7, tzinfo=g.display_tz)
@@ -307,3 +310,36 @@ def gold_goal_on(date):
return round(goal, 0)
def account_from_stripe_customer_id(stripe_customer_id):
q = Account._query(Account.c.stripe_customer_id == stripe_customer_id,
Account.c._spam == (True, False), data=True)
return next(iter(q), None)
@memoize("subscription-details", time=60)
def _get_subscription_details(stripe_customer_id):
stripe.api_key = g.STRIPE_SECRET_KEY
customer = stripe.Customer.retrieve(stripe_customer_id)
if getattr(customer, 'deleted', False):
return {}
subscription = customer.subscription
card = customer.active_card
end = datetime.fromtimestamp(subscription.current_period_end).date()
last4 = card.last4
pennies = subscription.plan.amount
return {
'next_charge_date': end,
'credit_card_last4': last4,
'pennies': pennies,
}
def get_subscription_details(user):
if not getattr(user, 'stripe_customer_id', None):
return
return _get_subscription_details(user.stripe_customer_id)

View File

@@ -6324,6 +6324,17 @@ body:not(.gold) .allminus-link {
margin-top: 3px;
}
.gold-form {
.credit-card-input {
display: inline;
}
.stripe-submit {
display: block;
margin-top: 10px;
}
}
.gold-payment form {
display: inline;
}
@@ -7548,7 +7559,11 @@ body.gold .buttons li.comment-save-button { display: inline; }
white-space:nowrap;
font-size:smaller;
}
#stripe-payment .credit-card-amount { text-align: left; }
#stripe-payment {
.credit-card-amount, .credit-card-interval {
text-align: left;
}
}
#stripe-payment th label { display:inline; }
#stripe-payment td input {
font-size:small;
@@ -7574,6 +7589,34 @@ body.gold .buttons li.comment-save-button { display: inline; }
font-size: small;
}
.gold-subscription {
font-size: small;
padding: 2px;
div.buttons {
padding: 10px 0;
}
.cancel-button, .edit-button {
margin: 5px;
display: inline;
}
.status, .error {
font-size: small;
margin: 0;
}
.roundfield {
background-color: #fffdd7;
width: 400px;
}
#stripe-cancel {
display: inline;
}
}
.permissions {
display: inline-block;
font-size: small;

View File

@@ -12,7 +12,13 @@ r.gold = {
$("#stripe-payment").show()
})
$('.stripe-submit').on('click', this.makeStripeToken)
$('#stripe-payment.charge .stripe-submit').on('click', function() {
r.gold.tokenThenPost('stripecharge/gold')
})
$('#stripe-payment.modify .stripe-submit').on('click', function() {
r.gold.tokenThenPost('modify_subscription')
})
},
_toggleCommentGoldForm: function (e) {
@@ -114,7 +120,25 @@ r.gold = {
comment.children('.entry').find('.give-gold').parent().remove()
},
makeStripeToken: function () {
tokenThenPost: function (dest) {
var postOnSuccess = function (status_code, response) {
var form = $('#stripe-payment'),
submit = form.find('.stripe-submit'),
status = form.find('.status'),
token = form.find('[name="stripeToken"]')
if (response.error) {
submit.removeAttr('disabled')
status.html(response.error.message)
} else {
token.val(response.id)
post_form(form, dest)
}
}
r.gold.makeStripeToken(postOnSuccess)
},
makeStripeToken: function (responseHandler) {
var form = $('#stripe-payment'),
publicKey = form.find('[name="stripePublicKey"]').val(),
submit = form.find('.stripe-submit'),
@@ -131,17 +155,6 @@ r.gold = {
cardState = form.find('.card-address_state').val(),
cardCountry = form.find('.card-address_country').val(),
cardZip = form.find('.card-address_zip').val()
var stripeResponseHandler = function(statusCode, response) {
if (response.error) {
submit.removeAttr('disabled')
status.html(response.error.message)
} else {
token.val(response.id)
post_form(form, 'stripecharge/gold')
}
}
Stripe.setPublishableKey(publicKey)
if (!cardName) {
@@ -178,7 +191,7 @@ r.gold = {
address_state: cardState,
address_country: cardCountry,
address_zip: cardZip
}, stripeResponseHandler
}, responseHandler
)
}
return false

View File

@@ -105,24 +105,17 @@
<button class="btn stripe-gold gold-button">${_('Credit Card')}</button>
</%def>
<%def name="stripe_form(display=False)">
<%def name="base_stripe_form()">
<script type="text/javascript" src="https://js.stripe.com/v1/"></script>
<form action="/api/stripecharge/gold" method="POST"
id="stripe-payment"
<div id="base-stripe-form"
class="gold-checkout"
data-vendor="stripe"
${not display and "style='display:none'" or ''}>
<hr>
data-vendor="stripe">
<div class="stripe-note">
<a class="icon" href="https://stripe.com/help/security">powered by stripe</a>
<div>${_('Stripe is PCI compliant and your credit card information is sent directly to them.')}</div>
</div>
<table class="credit-card-input">
<tr>
<th><label>${_('amount')}</label></th>
<th class="credit-card-amount">${thing.price}</th>
</tr>
<tr>
<th><label>${_('name')}</label></th>
<td><input type="text" autocomplete="off" class="card-name"></td>
@@ -183,12 +176,37 @@
</table>
<input type="hidden" name="stripePublicKey" value="${thing.stripe_key}">
<input type="hidden" name="stripeToken" value="">
<input type="hidden" name="pennies" value="${thing.price.pennies}">
<input type="hidden" name="months" value="${thing.months}">
<input type="hidden" name="passthrough" value="${thing.passthrough}">
<button class="btn gold-button stripe-submit">${_('Submit')}</button>
<span class="status"></span>
</form>
</div>
</%def>
<%def name="stripe_form(display=False)">
<div id="stripe-payment"
class="charge"
${not display and "style='display:none'" or ''}>
<hr>
<table class="credit-card-input">
<tr>
<th><label>${_('amount')}</label></th>
<th class="credit-card-amount">${thing.price}</th>
</tr>
%if thing.period:
<tr>
<th><label>${_('renewal interval')}</label></th>
<th class="credit-card-interval">${_(thing.period)}</th>
</tr>
%endif
</table>
<input type="hidden" name="pennies" value="${thing.price.pennies}">
<input type="hidden" name="months" value="${thing.months}">
<input type="hidden" name="period" value="${thing.period}">
<input type="hidden" name="passthrough" value="${thing.passthrough}">
<hr>
${base_stripe_form()}
</div>
</%def>
<%def name="coinbase_button()">

View File

@@ -0,0 +1,73 @@
## The contents of this file are subject to the Common Public Attribution
## License Version 1.0. (the "License"); you may not use this file except in
## compliance with the License. You may obtain a copy of the License at
## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
## License Version 1.1, but Sections 14 and 15 have been added to cover use of
## software over a computer network and provide for limited attribution for the
## Original Developer. In addition, Exhibit A has been modified to be
## consistent with Exhibit B.
##
## Software distributed under the License is distributed on an "AS IS" basis,
## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
## the specific language governing rights and limitations under the License.
##
## The Original Code is reddit.
##
## The Original Developer is the Initial Developer. The Initial Developer of
## the Original Code is reddit Inc.
##
## All portions of the code written by reddit are Copyright (c) 2006-2013
## reddit Inc. All Rights Reserved.
###############################################################################
<%namespace file="goldpayment.html" import="base_stripe_form"/>
%if not (thing.has_stripe_subscription or thing.has_paypal_subscription):
<div class="error">
${_("your account doesn't have a gold subscription.")}
</div>
%endif
%if thing.has_stripe_subscription:
<div class="gold-subscription">
${thing.text}
<div class="buttons">
<button class="edit-button" onclick="$('#stripe-payment').toggle()">
${_("use different card")}
</button>
<button class="cancel-button" onclick="$('#stripe-cancel').toggle()">
${_("cancel subscription")}
</button>
<span id="stripe-cancel" style='display:none'>
<input type="hidden" name="user" value="${thing.user_fullname}">
<span class="option error">
${_("are you sure?")}
&#32;<a href="javascript:void(0)" class="yes"
onclick="post_form('#stripe-cancel', 'cancel_subscription')">
${_("yes")}
</a>&#32;/&#32;
<a href="javascript:void(0)" class="no"
onclick="$('#stripe-cancel').hide()">${_("no")}</a>
</span>
<span class="status"></span>
</span>
</div>
<div class="gold-form">
<div class="modify" id="stripe-payment" style='display:none'>
<div class="roundfield">
${base_stripe_form()}
</div>
</div>
</div>
</div>
%endif
%if thing.has_paypal_subscription:
<div class="gold-subscription">
${_("you have a paypal gold subscription. go to %(paypal)s to manage it.") % dict(paypal=thing.paypal_url)}
</div>
%endif

View File

@@ -136,6 +136,14 @@
${thing.gold_subscr_id}
</div>
%endif
%if getattr(thing, "stripe_customer_id", None):
<div>
<a href="/gold/subscription">
${_("manage recurring subscription")}
</a>
</div>
%endif
%endif
%if thing.gold_creddit_message:
<div class="gold-creddits-remaining">