codecombat/server/routes/stripe.coffee
Matt Lott 9828bd5d1d Sponsored sub cleanup on unsubscribe
Sponsored subscriptions can be partially set up when the incremental
subscription charge fails.  This cleans up the sponsored subscriptions
when one of the recipient subscriptions is cancelled.

Related to #2786
2015-05-28 16:56:13 -07:00

263 lines
10 KiB
CoffeeScript

async = require 'async'
config = require '../../server_config'
stripe = require('stripe')(config.stripe.secretKey)
User = require '../users/User'
Payment = require '../payments/Payment'
errors = require '../commons/errors'
mongoose = require 'mongoose'
utils = require '../../app/core/utils'
module.exports.setup = (app) ->
# Cache customer -> user ID map (increases test perf considerably)
customerUserMap = {}
logStripeWebhookError = (msg) ->
console.warn "Stripe Webhook Error: #{msg}"
app.post '/stripe/webhook', (req, res) ->
# Subscription renewal events:
# https://support.stripe.com/questions/what-events-can-i-see-when-a-subscription-is-renewed
if req.body.type is 'invoice.payment_succeeded'
return handlePaymentSucceeded req, res
else if req.body.type is 'customer.subscription.deleted'
return handleSubscriptionDeleted req, res
else # ignore all other notifications
return res.send(200, '')
app.get '/stripe/coupons', (req, res) ->
return errors.forbidden(res) unless req.user?.isAdmin()
stripe.coupons.list {limit: 100}, (err, coupons) ->
return errors.serverError(res) if err
res.send(200, coupons.data)
return res.end()
handlePaymentSucceeded = (req, res) ->
# if they actually paid, give em some gems
getUserID = (customerID, done) =>
# Asumming Stripe customer never has a different userID
return done(null, customerUserMap[customerID]) if customerID of customerUserMap
stripe.customers.retrieve customerID, (err, customer) =>
return done(err) if err
customerUserMap[customerID] = customer.metadata.id
return done(null, customerUserMap[customerID])
invoiceID = req.body.data.object.id
stripe.invoices.retrieve invoiceID, (err, invoice) =>
return res.send(500, '') if err
unless invoice.total or invoice.discount?.coupon?.id is 'free'
# invoices made when trialing, probably given for people who resubscribe after unsubscribing
return res.send(200, '')
return res.send(200, '') unless invoice.lines?.data?.length > 0
getUserID invoice.customer, (err, userID) =>
return res.send(500, '') if err
# User is recipient if no metadata.id
recipientID = invoice.lines.data[0].metadata?.id or userID
# Subscription id location depends on invoice line_item type
subscriptionID = invoice.lines.data[0].subscription or invoice.lines.data[0].id
User.findById recipientID, (err, recipient) =>
return res.send(500, '') if err
return res.send(200) unless recipient # just for the sake of testing...
Payment.findOne {'stripe.invoiceID': invoiceID}, (err, payment) =>
return res.send(200, '') if payment
payment = new Payment({
'purchaser': mongoose.Types.ObjectId(userID)
'recipient': recipient._id
'created': new Date().toISOString()
'service': 'stripe'
'amount': invoice.total
'stripe': {
customerID: invoice.customer
invoiceID: invoice.id
subscriptionID: subscriptionID
}
})
payment.set 'gems', 3500 if invoice.lines.data[0].plan?.id is 'basic'
payment.save (err) =>
return res.send(500, '') if err
return res.send(201, '') if invoice.lines.data[0].plan?.id isnt 'basic'
# Update purchased gems
# TODO: is this correct for a resub?
Payment.find({recipient: recipient._id, gems: {$exists: true}}).select('gems').exec (err, payments) ->
gems = _.reduce payments, ((sum, p) -> sum + p.get('gems')), 0
purchased = _.clone(recipient.get('purchased'))
purchased ?= {}
purchased.gems = gems
recipient.set('purchased', purchased)
recipient.save (err) ->
return res.send(500, '') if err
return res.send(201, '')
handleSubscriptionDeleted = (req, res) ->
# Three variants:
# normal - Personal subscription deleted
# recipeint - Subscription sponsored by another user is being deleted.
# sponsor - Aggregate subscription used to pay for multiple recipient subscriptions. Ugh.
subscription = req.body.data.object
checkUserExists = (done) ->
stripe.customers.retrieve subscription.customer, (err, customer) =>
if err
logStripeWebhookError("Failed to retrieve #{subscription.customer}")
return res.send(500, '')
unless customer?.metadata?.id
logStripeWebhookError("Customer with no metadata.id #{subscription.customer}")
return res.send(500, '')
User.findById customer.metadata.id, (err, user) =>
if err
logStripeWebhookError(err)
return res.send(500, '')
unless user
logStripeWebhookError("User not found #{customer.metadata.id}")
return res.send(500, '')
return res.send(200, '') if user.get('deleted') is true
done()
checkNormalSubscription = (done) ->
User.findOne {'stripe.subscriptionID': subscription.id}, (err, user) ->
return done() unless user
stripeInfo = _.cloneDeep(user.get('stripe') ? {})
delete stripeInfo.planID
delete stripeInfo.prepaidCode
delete stripeInfo.subscriptionID
user.set('stripe', stripeInfo)
user.save (err) =>
if err
logStripeWebhookError(err)
return res.send(500, '')
return res.send(200, '')
checkRecipientSubscription = (done) ->
return done() unless subscription.plan.id is 'basic'
return done() unless subscription.metadata?.id # Shouldn't be possible
deleteUserStripeProp = (user, propName) ->
stripeInfo = _.cloneDeep(user.get('stripe') ? {})
delete stripeInfo[propName]
if _.isEmpty stripeInfo
user.set 'stripe', undefined
else
user.set 'stripe', stripeInfo
User.findById subscription.metadata.id, (err, recipient) =>
if err
logStripeWebhookError(err)
return res.send(500, '')
unless recipient
logStripeWebhookError("Recipient not found #{subscription.metadata.id}")
return res.send(500, '')
User.findById recipient.get('stripe').sponsorID, (err, sponsor) =>
if err
logStripeWebhookError(err)
return res.send(500, '')
unless sponsor
logStripeWebhookError("Sponsor not found #{recipient.get('stripe').sponsorID}")
return res.send(500, '')
# Update sponsor subscription
stripeInfo = _.cloneDeep(sponsor.get('stripe') ? {})
stripeInfo.recipients ?= []
if stripeInfo.sponsorSubscriptionID
_.remove(stripeInfo.recipients, (s) -> s.userID is recipient.id)
options =
quantity: utils.getSponsoredSubsAmount(subscription.plan.amount, stripeInfo.recipients.length, stripeInfo.subscriptionID?)
stripe.customers.updateSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, options, (err, subscription) =>
if err
logStripeWebhookError(err)
return res.send(500, '')
# Update sponsor user
sponsor.set 'stripe', stripeInfo
sponsor.save (err) =>
if err
logStripeWebhookError(err)
return res.send(500, '')
# Update recipient user
deleteUserStripeProp recipient, 'sponsorID'
recipient.save (err) =>
if err
logStripeWebhookError(err)
return res.send(500, '')
return res.send(200, '')
else
# Remove sponsorships from sponsor and recipients
console.error "Couldn't find sponsorSubscriptionID from stripeInfo", stripeInfo, 'for customer', stripeInfo.customerID, 'with options', options, 'and subscription', subscription, 'for user', recipient.id, 'with sponsor', sponsor.id
# Update recipients
createUpdateFn = (recipientID) ->
(callback) ->
User.findById recipientID, (err, recipient) =>
if err
logStripeWebhookError(err)
return callback(err)
deleteUserStripeProp recipient, 'sponsorID'
recipient.save (err) =>
logStripeWebhookError(err) if err
callback(err)
async.parallel (createUpdateFn(recipient.userID) for recipient in stripeInfo.recipients), (err, results) =>
if err
logStripeWebhookError(err)
return res.send(500, '')
# Update sponsor
deleteUserStripeProp sponsor, 'recipients'
sponsor.save (err) =>
if err
logStripeWebhookError(err)
return res.send(500, '')
return res.send(200, '')
checkSponsorSubscription = (done) ->
return done() unless subscription.plan.id is 'incremental'
customerID = subscription.customer
createUpdateFn = (sub) ->
(callback) ->
# Cancel Stripe recipient subscription
stripe.customers.cancelSubscription customerID, sub.subscriptionID, { at_period_end: true }, (err) ->
callback err
User.findById subscription.metadata.id, (err, sponsor) =>
return res.send(500, '') if err
stripeInfo = _.cloneDeep(sponsor.get('stripe') ? {})
# Cancel all recipient subscriptions
async.parallel (createUpdateFn(sub) for sub in stripeInfo.recipients), (err, results) =>
if err
logStripeWebhookError(err)
return res.send(500, '')
# Update sponsor user
delete stripeInfo.sponsorSubscriptionID
delete stripeInfo.recipients # Loses remaining credit on a re-subscribe for previous user
if _.isEmpty stripeInfo
sponsor.set 'stripe', undefined
else
sponsor.set 'stripe', stripeInfo
sponsor.save (err) =>
if err
logStripeWebhookError(err)
return res.send(500, '')
done()
# TODO: use async.series for this
checkUserExists ->
checkNormalSubscription ->
checkRecipientSubscription ->
checkSponsorSubscription ->
res.send(200, '')