codecombat/server/routes/stripe.coffee

282 lines
11 KiB
CoffeeScript

async = require 'async'
config = require '../../server_config'
stripe = require('stripe')(config.stripe.secretKey)
User = require '../models/User'
Payment = require '../models/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) =>
if err
logStripeWebhookError("Retrieve invoice error: #{JSON.stringify(err)}")
return res.send(500, '')
unless invoice.total or invoice.discount?.coupon?.id in ['free', 'brazil']
# invoices made when trialing, probably given for people who resubscribe after unsubscribing
# also I can't change the test-mode brazil coupon to not end up with a zero price now
return res.send(200, '')
return res.send(200, '') unless invoice.lines?.data?.length > 0
getUserID invoice.customer, (err, userID) =>
if err
logStripeWebhookError("Get user ID error: #{JSON.stringify(err)}")
return res.send(500, '')
# 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) =>
if err
logStripeWebhookError("Find recipient user error: #{JSON.stringify(err)}")
return res.send(500, '')
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
}
})
# TODO: load gems from correct Product
productGems = 3500
if recipient.get('country') is 'brazil'
productGems = 1500
payment.set 'gems', productGems if invoice.lines.data[0].plan?.id is 'basic'
payment.save (err) =>
if err
logStripeWebhookError("Save payment error: #{JSON.stringify(err)}")
return res.send(500, '')
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) ->
if err
logStripeWebhookError("Save recipient user error: #{JSON.stringify(err)}")
return res.send(500, '')
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, '')
# Recipient cancellations are immediate, no work to perform if recipient's sponsorID is already gone
return res.send(200, '') unless recipient.get('stripe')?.sponsorID?
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, '')