mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2025-03-14 07:00:01 -04:00
Created stripe subscription logic.
This commit is contained in:
parent
15d7ac876a
commit
cd3bb690f4
9 changed files with 415 additions and 6 deletions
|
@ -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' }
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -30,6 +30,7 @@ module.exports.routes =
|
|||
'routes/sprites'
|
||||
'routes/queue'
|
||||
'routes/stacklead'
|
||||
'routes/stripe'
|
||||
]
|
||||
|
||||
mongoose = require 'mongoose'
|
||||
|
|
138
server/payments/subscription_handler.coffee
Normal file
138
server/payments/subscription_handler.coffee
Normal 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()
|
64
server/routes/stripe.coffee
Normal file
64
server/routes/stripe.coffee
Normal 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, '')
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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) ->
|
||||
|
|
184
test/server/functional/subscription.spec.coffee
Normal file
184
test/server/functional/subscription.spec.coffee
Normal 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()
|
Loading…
Reference in a new issue