diff --git a/r2/example.ini b/r2/example.ini index 1cbd17e95..3bdbde42d 100644 --- a/r2/example.ini +++ b/r2/example.ini @@ -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 = diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index 527051359..428016bd8 100644 --- a/r2/r2/config/routing.py +++ b/r2/r2/config/routing.py @@ -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', diff --git a/r2/r2/controllers/front.py b/r2/r2/controllers/front.py index 4e0fc5bb9..60ac768c1 100755 --- a/r2/r2/controllers/front.py +++ b/r2/r2/controllers/front.py @@ -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() diff --git a/r2/r2/controllers/ipn.py b/r2/r2/controllers/ipn.py index 9c2b8d667..8b2d33e70 100644 --- a/r2/r2/controllers/ipn.py +++ b/r2/r2/controllers/ipn.py @@ -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' diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 0387a2073..52dcd9689 100644 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -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 diff --git a/r2/r2/models/gold.py b/r2/r2/models/gold.py index 55e2b0742..bd41aba82 100644 --- a/r2/r2/models/gold.py +++ b/r2/r2/models/gold.py @@ -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) diff --git a/r2/r2/public/static/css/reddit.less b/r2/r2/public/static/css/reddit.less index 1d71d45fb..a406e3467 100755 --- a/r2/r2/public/static/css/reddit.less +++ b/r2/r2/public/static/css/reddit.less @@ -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; diff --git a/r2/r2/public/static/js/gold.js b/r2/r2/public/static/js/gold.js index db34d739f..c96d60d6b 100644 --- a/r2/r2/public/static/js/gold.js +++ b/r2/r2/public/static/js/gold.js @@ -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 diff --git a/r2/r2/templates/goldpayment.html b/r2/r2/templates/goldpayment.html index 23ec79bdb..8a2aeff06 100644 --- a/r2/r2/templates/goldpayment.html +++ b/r2/r2/templates/goldpayment.html @@ -105,24 +105,17 @@ %def> -<%def name="stripe_form(display=False)"> +<%def name="base_stripe_form()"> -
+ +%def> + +<%def name="stripe_form(display=False)"> +| + | ${thing.price} | +
|---|---|
| + | ${_(thing.period)} | +