Created stripe subscription logic.

This commit is contained in:
Scott Erickson 2014-12-02 20:01:35 -08:00
parent 15d7ac876a
commit cd3bb690f4
9 changed files with 415 additions and 6 deletions

View file

@ -20,6 +20,7 @@ PaymentSchema = c.object({title: 'Payment', required: []}, {
timestamp: { type: 'integer', description: 'Unique identifier provided by the client, to guard against duplicate payments.' }
chargeID: { type: 'string' }
customerID: { type: 'string' }
invoiceID: { type: 'string' }
})
})

View file

@ -275,8 +275,9 @@ _.extend UserSchema.properties,
stripe: c.object {}, {
customerID: { type: 'string' }
subscription: { enum: ['basic'] }
token: { type: 'string' }
planID: { enum: ['basic'] }
subscriptionID: { type: 'string' }
token: { type: 'string' }
}
c.extendBasicProperties UserSchema, 'user'

View file

@ -24,10 +24,10 @@ module.exports = class Handler
constructor: ->
# TODO The second 'or' is for backward compatibility only is for backward compatibility only
@privateProperties = @modelClass.privateProperties or @privateProperties or []
@editableProperties = @modelClass.editableProperties or @editableProperties or []
@postEditableProperties = @modelClass.postEditableProperties or @postEditableProperties or []
@jsonSchema = @modelClass.jsonSchema or @jsonSchema or {}
@privateProperties = @modelClass?.privateProperties or @privateProperties or []
@editableProperties = @modelClass?.editableProperties or @editableProperties or []
@postEditableProperties = @modelClass?.postEditableProperties or @postEditableProperties or []
@jsonSchema = @modelClass?.jsonSchema or @jsonSchema or {}
# subclasses should override these methods
hasAccess: (req) -> true
@ -336,6 +336,7 @@ module.exports = class Handler
return @sendNotFoundError(res) unless document?
return @sendForbiddenError(res) unless @hasAccessToDocument(req, document)
@doWaterfallChecks req, document, (err, document) =>
return if err is true
return @sendError(res, err.code, err.res) if err
@saveChangesToDocument req, document, (err) =>
return @sendBadInputError(res, err.errors) if err?.valid is false

View file

@ -30,6 +30,7 @@ module.exports.routes =
'routes/sprites'
'routes/queue'
'routes/stacklead'
'routes/stripe'
]
mongoose = require 'mongoose'

View file

@ -0,0 +1,138 @@
# Not paired with a document in the DB, just handles coordinating between
# the stripe property in the user with what's being stored in Stripe.
Handler = require '../commons/Handler'
config = require '../../server_config'
stripe = require('stripe')(config.stripe.secretKey)
subscriptions = {
basic: {
gems: 3500
}
}
class SubscriptionHandler extends Handler
logSubscriptionError: (req, msg) ->
console.warn "Subscription Error: #{req.user.get('slug')} (#{req.user._id}): '#{msg}'"
subscribeUser: (req, user, done) ->
stripeToken = req.body.stripe?.token
extantCustomerID = user.get('stripe')?.customerID
if not (stripeToken or extantCustomerID)
@logSubscriptionError(req, 'Missing stripe token or customer ID.')
return done({res: 'Missing stripe token or customer ID.', code: 422})
if stripeToken
stripe.customers.create({
card: stripeToken
description: req.user._id + ''
}).then(((customer) =>
stripeInfo = _.cloneDeep(req.user.get('stripe') ? {})
stripeInfo.customerID = customer.id
req.user.set('stripe', stripeInfo)
req.user.save((err) =>
if err
@logSubscriptionError(req, 'Stripe customer id save db error. '+err)
return done({res: 'Database error.', code: 500})
@checkForExistingSubscription(req, user, customer, done)
)
),
(err) =>
@logSubscriptionError(req, 'Stripe customer creation error. '+err)
return done({res: 'Database error.', code: 500})
)
else
stripe.customers.retrieve(extantCustomerID, (err, customer) =>
if err
@logSubscriptionError(req, 'Stripe customer creation error. '+err)
return done({res: 'Database error.', code: 500})
else if not customer
# TODO: what actually happens when you try to retrieve a customer and it DNE?
@logSubscriptionError(req, 'Stripe customer id is missing! '+err)
stripeInfo = _.cloneDeep(req.user.get('stripe') ? {})
delete stripeInfo.customerID
req.user.set('stripe', stripeInfo)
req.user.save (err) =>
if err
@logSubscriptionError(req, 'Stripe customer id delete db error. '+err)
return done({res: 'Database error.', code: 500})
@subscribeUser(req, done)
else
@checkForExistingSubscription(req, user, customer, done)
)
checkForExistingSubscription: (req, user, customer, done) ->
if subscription = customer.subscriptions?.data?[0]
if subscription.cancel_at_period_end
# Things are a little tricky here. Can't re-enable a cancelled subscription,
# so it needs to be deleted, but also don't want to charge for the new subscription immediately.
# So delete the cancelled subscription (no at_period_end given here) and give the new
# subscription a trial period that ends when the cancelled subscription would have ended.
stripe.customers.cancelSubscription subscription.customer, subscription.id, (err) =>
if err
@logSubscriptionError(req, 'Stripe cancel subscription error. '+err)
return done({res: 'Database error.', code: 500})
options = { plan: 'basic', trial_end: subscription.current_period_end }
stripe.customers.update req.user.get('stripe').customerID, options, (err, customer) =>
if err
@logSubscriptionError(req, 'Stripe customer plan setting error. '+err)
return done({res: 'Database error.', code: 500})
@updateUser(req, user, customer.subscriptions.data[0], false, done)
else
# can skip creating the subscription
return @updateUser(req, user, customer.subscriptions.data[0], false, done)
else
stripe.customers.update req.user.get('stripe').customerID, { plan: 'basic' }, (err, customer) =>
if err
@logSubscriptionError(req, 'Stripe customer plan setting error. '+err)
return done({res: 'Database error.', code: 500})
@updateUser(req, user, customer.subscriptions.data[0], true, done)
updateUser: (req, user, subscription, increment, done) ->
stripeInfo = _.cloneDeep(user.get('stripe') ? {})
stripeInfo.planID = 'basic'
stripeInfo.subscriptionID = subscription.id
stripeInfo.customerID = subscription.customer
req.body.stripe = stripeInfo # to make sure things work for admins, who are mad with power
user.set('stripe', stripeInfo)
if increment
purchased = _.clone(user.get('purchased'))
purchased ?= {}
purchased.gems ?= 0
purchased.gems += subscriptions.basic.gems # TODO: Put actual subscription amount here
user.set('purchased', purchased)
user.save (err) =>
if err
@logSubscriptionError(req, 'Stripe user plan saving error. '+err)
return done({res: 'Database error.', code: 500})
return done()
unsubscribeUser: (req, user, done) ->
stripeInfo = _.cloneDeep(user.get('stripe'))
stripe.customers.cancelSubscription stripeInfo.customerID, stripeInfo.subscriptionID, { at_period_end: true }, (err) =>
if err
@logSubscriptionError(req, 'Stripe cancel subscription error. '+err)
return done({res: 'Database error.', code: 500})
delete stripeInfo.planID
user.set('stripe', stripeInfo)
req.body.stripe = stripeInfo
user.save (err) =>
if err
@logSubscriptionError(req, 'User save unsubscribe error. '+err)
return done({res: 'Database error.', code: 500})
return done()
module.exports = new SubscriptionHandler()

View file

@ -0,0 +1,64 @@
config = require '../../server_config'
stripe = require('stripe')(config.stripe.secretKey)
User = require '../users/User'
Payment = require '../payments/Payment'
module.exports.setup = (app) ->
app.post '/stripe/webhook', (req, res, next) ->
if req.body.type is 'invoice.payment_succeeded' # if they actually paid, give em some gems
invoiceID = req.body.data.object.id
stripe.invoices.retrieve invoiceID, (err, invoice) =>
return res.send(500, '') if err
return res.send(200, '') unless invoice.total # invoices made when trialing, probably given for people who resubscribe after unsubscribing
stripe.customers.retrieve invoice.customer, (err, customer) =>
return res.send(500, '') if err
userID = customer.description
User.findById userID, (err, user) =>
return res.send(500, '') if err
return res.send(200) if not user # just for the sake of testing...
Payment.findOne {'stripe.invoiceID': invoiceID}, (err, payment) =>
return res.send(200, '') if payment
payment = new Payment({
'purchaser': user._id
'recipient': user._id
'created': new Date().toISOString()
'service': 'stripe'
'amount': invoice.total
'gems': 3500
'stripe': {
customerID: invoice.customer
invoiceID: invoice.id
subscriptionID: 'basic'
}
})
payment.save (err) =>
return res.send(500, '') if err
Payment.find({recipient: user._id}).select('gems').exec (err, payments) ->
gems = _.reduce payments, ((sum, p) -> sum + p.get('gems')), 0
purchased = _.clone(user.get('purchased'))
purchased ?= {}
purchased.gems = gems
user.set('purchased', purchased)
user.save (err) ->
return res.send(500, '') if err
return res.send(201, '')
else if req.body.type is 'customer.subscription.deleted'
User.findOne {'stripe.subscriptionID': req.body.data.object.id}, (err, user) ->
return res.send(200, '') unless user
stripeInfo = _.cloneDeep(user.get('stripe'))
delete stripeInfo.planID
delete stripeInfo.subscriptionID
user.set('stripe', stripeInfo)
user.save (err) =>
return res.send(500, '') if err
return res.send(200, '')
else # ignore all other notifications
return res.send(200, '')

View file

@ -204,6 +204,7 @@ UserSchema.statics.editableProperties = [
]
UserSchema.plugin plugins.NamedPlugin
UserSchema.index({'stripe.subscriptionID':1}, {unique: true, sparse: true})
module.exports = User = mongoose.model('User', UserSchema)

View file

@ -11,6 +11,7 @@ log = require 'winston'
moment = require 'moment'
LevelSession = require '../levels/sessions/LevelSession'
LevelSessionHandler = require '../levels/sessions/level_session_handler'
SubscriptionHandler = require '../payments/subscription_handler'
EarnedAchievement = require '../achievements/EarnedAchievement'
UserRemark = require './remarks/UserRemark'
{isID} = require '../lib/utils'
@ -105,6 +106,23 @@ UserHandler = class UserHandler extends Handler
return callback({res: r, code: 409}) if otherUser
user.set('name', req.body.name)
callback(null, req, user)
# Subscription setting
(req, user, callback) ->
hasPlan = user.get('stripe')?.planID?
wantsPlan = req.body.stripe?.planID?
return callback(null, req, user) if hasPlan is wantsPlan
if wantsPlan and not hasPlan
SubscriptionHandler.subscribeUser(req, user, (err) ->
return callback(err) if err
return callback(null, req, user)
)
else if hasPlan and not wantsPlan
SubscriptionHandler.unsubscribeUser(req, user, (err) ->
return callback(err) if err
return callback(null, req, user)
)
]
getById: (req, res, id) ->

View file

@ -0,0 +1,184 @@
config = require '../../../server_config'
require '../common'
# sample data that comes in through the webhook when you subscribe
invoiceChargeSampleEvent = {
id: 'evt_155TBeKaReE7xLUdrKM72O5R',
created: 1417574898,
livemode: false,
type: 'invoice.payment_succeeded',
data: {
object: {
date: 1417574897,
id: 'in_155TBdKaReE7xLUdv8z8ipWl',
period_start: 1417574897,
period_end: 1417574897,
lines: {},
subtotal: 999,
total: 999,
customer: 'cus_5Fz9MVWP2bDPGV',
object: 'invoice',
attempted: true,
closed: true,
forgiven: false,
paid: true,
livemode: false,
attempt_count: 1,
amount_due: 999,
currency: 'usd',
starting_balance: 0,
ending_balance: 0,
next_payment_attempt: null,
webhooks_delivered_at: null,
charge: 'ch_155TBdKaReE7xLUdRU0WcMzR',
discount: null,
application_fee: null,
subscription: 'sub_5Fz99gXrBtreNe',
metadata: {},
statement_description: null,
description: null,
receipt_number: null
}
},
object: 'event',
pending_webhooks: 1,
request: 'iar_5Fz9c4BZJyNNsM',
api_version: '2014-11-05'
}
customerSubscriptionDeletedSampleEvent = {
id: 'evt_155Tj4KaReE7xLUdpoMx0UaA',
created: 1417576970,
livemode: false,
type: 'customer.subscription.deleted',
data: {
object: {
id: 'sub_5FziOkege03vT7',
plan: [Object],
object: 'subscription',
start: 1417576967,
status: 'canceled',
customer: 'cus_5Fzi54gMvGG9Px',
cancel_at_period_end: true,
current_period_start: 1417576967,
current_period_end: 1420255367,
ended_at: 1417576970,
trial_start: null,
trial_end: null,
canceled_at: 1417576970,
quantity: 1,
application_fee_percent: null,
discount: null,
metadata: {}
}
},
object: 'event',
pending_webhooks: 1,
request: 'iar_5FziYQJ4oQdL6w',
api_version: '2014-11-05'
}
describe '/db/user, editing stripe property', ->
stripe = require('stripe')(config.stripe.secretKey)
userURL = getURL('/db/user')
webhookURL = getURL('/stripe/webhook')
it 'clears the db first', (done) ->
clearModels [User, Payment], (err) ->
throw err if err
done()
#- shared data between tests
joeData = null
firstSubscriptionID = null
it 'creates a subscription when you put a token and plan', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
stripeTokenID = token.id
loginJoe (joe) ->
joeData = joe.toObject()
joeData.stripe = {
token: stripeTokenID
planID: 'basic'
}
request.put {uri: userURL, json: joeData }, (err, res, body) ->
joeData = body
expect(res.statusCode).toBe(200)
expect(joeData.purchased.gems).toBe(3500)
expect(joeData.stripe.customerID).toBeDefined()
expect(firstSubscriptionID = joeData.stripe.subscriptionID).toBeDefined()
expect(joeData.stripe.planID).toBe('basic')
expect(joeData.stripe.token).toBeUndefined()
done()
it 'records a payment through the webhook', (done) ->
# Don't even want to think about hooking in tests to webhooks, so... put in some data manually
stripe.invoices.list {customer: joeData.stripe.customerID}, (err, invoices) ->
expect(invoices.data.length).toBe(1)
event = _.cloneDeep(invoiceChargeSampleEvent)
event.data.object = invoices.data[0]
request.post {uri: webhookURL, json: event}, (err, res, body) ->
expect(res.statusCode).toBe(201)
Payment.find {}, (err, payments) ->
expect(payments.length).toBe(1)
User.findById joeData._id, (err, user) ->
expect(user.get('purchased').gems).toBe(3500)
done()
it 'schedules the stripe subscription to be cancelled when stripe.planID is removed from the user', (done) ->
delete joeData.stripe.planID
request.put {uri: userURL, json: joeData }, (err, res, body) ->
joeData = body
expect(res.statusCode).toBe(200)
expect(joeData.stripe.subscriptionID).toBeDefined()
expect(joeData.stripe.planID).toBeUndefined()
expect(joeData.stripe.customerID).toBeDefined()
stripe.customers.retrieve joeData.stripe.customerID, (err, customer) ->
expect(customer.subscriptions.data.length).toBe(1)
expect(customer.subscriptions.data[0].cancel_at_period_end).toBe(true)
done()
it 'allows you to sign up again using the same customer ID as before, no token necessary', (done) ->
joeData.stripe.planID = 'basic'
request.put {uri: userURL, json: joeData }, (err, res, body) ->
joeData = body
expect(res.statusCode).toBe(200)
expect(joeData.stripe.customerID).toBeDefined()
expect(joeData.stripe.subscriptionID).toBeDefined()
expect(joeData.stripe.subscriptionID).not.toBe(firstSubscriptionID)
expect(joeData.stripe.planID).toBe('basic')
done()
it 'will not have immediately created new payments when signing back up from a cancelled subscription', (done) ->
stripe.invoices.list {customer: joeData.stripe.customerID}, (err, invoices) ->
expect(invoices.data.length).toBe(2)
expect(invoices.data[0].total).toBe(0)
event = _.cloneDeep(invoiceChargeSampleEvent)
event.data.object = invoices.data[0]
request.post {uri: webhookURL, json: event}, (err, res, body) ->
expect(res.statusCode).toBe(200)
Payment.find {}, (err, payments) ->
expect(payments.length).toBe(1)
User.findById joeData._id, (err, user) ->
expect(user.get('purchased').gems).toBe(3500)
done()
it 'deletes the subscription from the user object when an event about it comes through the webhook', (done) ->
stripe.customers.retrieveSubscription joeData.stripe.customerID, joeData.stripe.subscriptionID, (err, subscription) ->
event = _.cloneDeep(customerSubscriptionDeletedSampleEvent)
event.data.object = subscription
request.post {uri: webhookURL, json: event}, (err, res, body) ->
User.findById joeData._id, (err, user) ->
expect(user.get('purchased').gems).toBe(3500)
expect(user.get('stripe').subscriptionID).toBeUndefined()
expect(user.get('stripe').planID).toBeUndefined()
done()