Set up stripe on the server and site to allow purchases through the website.

This commit is contained in:
Scott Erickson 2014-11-17 15:15:02 -08:00
parent d3da5e330a
commit 95dca575d1
14 changed files with 404 additions and 39 deletions

View file

@ -0,0 +1,10 @@
publishableKey = if application.isProduction() then 'pk_live_27jQZozjDGN1HSUTnSuM578g' else 'pk_test_zG5UwVu6Ww8YhtE9ZYh0JO6a'
module.exports = handler = StripeCheckout.configure({
key: publishableKey
name: 'CodeCombat'
email: me.get('email')
image: '/images/pages/base/logo_square_250.png'
token: (token) ->
Backbone.Mediator.publish 'stripe:received-token', { token: token }
})

View file

@ -319,6 +319,9 @@
few_gems: 'A few gems'
pile_gems: 'Pile of gems'
chest_gems: 'Chest of gems'
purchasing: 'Purchasing...'
declined: 'Your card was declined'
retrying: 'Server error, retrying.'
choose_hero:
choose_hero: "Choose Your Hero"

View file

@ -8,6 +8,7 @@ PaymentSchema = c.object({title: 'Payment', required: []}, {
amount: { type: 'integer', description: 'Payment in cents.' }
created: c.date({title: 'Created', readOnly: true})
gems: { type: 'integer', description: 'The number of gems acquired.' }
productID: { enum: ['gems_5', 'gems_10', 'gems_20']}
ios: c.object({title: 'iOS IAP Data'}, {
transactionID: { type: 'string' }
@ -17,7 +18,7 @@ PaymentSchema = c.object({title: 'Payment', required: []}, {
stripe: c.object({title: 'Stripe Data'}, {
timestamp: { type: 'integer', description: 'Unique identifier provided by the client, to guard against duplicate payments.' }
transactionID: { type: 'string' }
chargeID: { type: 'string' }
customerID: { type: 'string' }
})
})

View file

@ -270,6 +270,7 @@ _.extend UserSchema.properties,
earned: c.RewardSchema 'earned by achievements'
purchased: c.RewardSchema 'purchased with gems or money'
spent: {type: 'number'}
stripeCustomerID: { type: 'string' }
c.extendBasicProperties UserSchema, 'user'

View file

@ -51,4 +51,9 @@ module.exports =
'buy-gems-modal:update-products': { }
'buy-gems-modal:purchase-initiated': c.object {required: ['productID']},
productID: { type: 'string' }
productID: { type: 'string' }
'stripe:received-token': c.object { required: ['token'] },
token: { type: 'object', properties: {
id: {type: 'string'}
}}

View file

@ -42,3 +42,12 @@
button
width: 80%
//- Errors
.alert
position: absolute
left: 10%
width: 80%
top: 20px
border: 5px solid gray

View file

@ -1,11 +1,30 @@
.modal-dialog
.modal-content
img(src="/images/pages/play/modal/buy-gems-background.png")#buy-gems-background
if state === 'purchasing'
.alert.alert-info(data-i18n="buy_gems.purchasing")
#products
for product in products
.product
h4 x#{product.gems}
h3(data-i18n=product.i18n)
button.btn.btn-illustrated.btn-lg(value=product.id)
span= product.price
else if state === 'retrying'
#retrying-alert.alert.alert-danger(data-i18n="buy_gems.retrying")
else
img(src="/images/pages/play/modal/buy-gems-background.png")#buy-gems-background
#products
for product in products
.product
h4 x#{product.gems}
h3(data-i18n=product.i18n)
button.btn.btn-illustrated.btn-lg(value=product.id)
span= product.price
if state === 'declined'
#declined-alert.alert.alert-danger.alert-dismissible
span(data-i18n="buy_gems.declined")
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") ×
if state === 'unknown_error'
#error-alert.alert.alert-danger.alert-dismissible
span(data-i18n="loading_error.unknown")
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") ×

View file

@ -43,13 +43,13 @@
.game-controls.header-font
button.btn.items(data-toggle='coco-modal', data-target='play/modal/PlayItemsModal', data-i18n="[title]play.items")
button.btn.heroes(data-toggle='coco-modal', data-target='play/modal/PlayHeroesModal', data-i18n="[title]play.heroes")
if me.isAdmin() || isIPadApp
if me.get('anonymous') === false
button.btn.gems(data-toggle='coco-modal', data-target='play/modal/BuyGemsModal', data-i18n="[title]play.buy_gems")
if me.isAdmin()
button.btn.achievements(data-toggle='coco-modal', data-target='play/modal/PlayAchievementsModal', data-i18n="[title]play.achievements")
button.btn.account(data-toggle='coco-modal', data-target='play/modal/PlayAccountModal', data-i18n="[title]play.account")
button.btn.settings(data-toggle='coco-modal', data-target='play/modal/PlaySettingsModal', data-i18n="[title]play.settings")
else if me.get('anonymous')
else if me.get('anonymous', true)
button.btn.settings(data-toggle='coco-modal', data-target='modal/AuthModal', data-i18n="[title]play.settings")
// Don't show these things, they are bad and take us out of the game. Just wait until the new ones work.
//else

View file

@ -1,5 +1,7 @@
ModalView = require 'views/kinds/ModalView'
template = require 'templates/play/modal/buy-gems-modal'
stripeHandler = require 'lib/services/stripe'
utils = require 'lib/utils'
module.exports = class BuyGemsModal extends ModalView
id: 'buy-gems-modal'
@ -7,20 +9,22 @@ module.exports = class BuyGemsModal extends ModalView
plain: true
originalProducts: [
{ price: '$4.99', gems: 5000, id: 'gems_5', i18n: 'buy_gems.few_gems' }
{ price: '$9.99', gems: 11000, id: 'gems_10', i18n: 'buy_gems.pile_gems' }
{ price: '$19.99', gems: 25000, id: 'gems_20', i18n: 'buy_gems.chest_gems' }
{ price: '$4.99', gems: 5000, amount: 499, id: 'gems_5', i18n: 'buy_gems.few_gems' }
{ price: '$9.99', gems: 11000, amount: 999, id: 'gems_10', i18n: 'buy_gems.pile_gems' }
{ price: '$19.99', gems: 25000, amount: 1999, id: 'gems_20', i18n: 'buy_gems.chest_gems' }
]
subscriptions:
'ipad:products': 'onIPadProducts'
'ipad:iap-complete': 'onIAPComplete'
'stripe:received-token': 'onStripeReceivedToken'
events:
'click .product button': 'onClickProductButton'
constructor: (options) ->
super(options)
@state = 'standby'
if application.isIPadApp
@products = []
Backbone.Mediator.publish 'buy-gems-modal:update-products'
@ -30,6 +34,7 @@ module.exports = class BuyGemsModal extends ModalView
getRenderData: ->
c = super()
c.products = @products
c.state = @state
return c
onIPadProducts: (e) ->
@ -43,15 +48,48 @@ module.exports = class BuyGemsModal extends ModalView
@render()
onClickProductButton: (e) ->
productID = $(e.target).closest('button.product').val()
console.log 'purchasing', _.find @products, { id: productID }
productID = $(e.target).closest('button').val()
product = _.find @products, { id: productID }
if application.isIPadApp
Backbone.Mediator.publish 'buy-gems-modal:purchase-initiated', { productID: productID }
else
application.tracker?.trackEvent 'Started purchase', {productID:productID}, ['Google Analytics']
alert('Not yet implemented, but thanks for trying!')
stripeHandler.open({
description: $.t(product.i18n)
amount: product.amount
})
@productBeingPurchased = product
onStripeReceivedToken: (e) ->
@timestampForPurchase = new Date().getTime()
data = {
productID: @productBeingPurchased.id
stripe: {
token: e.token.id
timestamp: @timestampForPurchase
}
}
@state = 'purchasing'
@render()
jqxhr = $.post('/db/payment', data)
jqxhr.done(=>
document.location.reload()
)
jqxhr.fail(=>
if jqxhr.status is 402
@state = 'declined'
@stateMessage = arguments[2]
else if jqxhr.status is 500
@state = 'retrying'
f = _.bind @onStripeReceivedToken, @, e
_.delay f, 2000
else
@state = 'unknown_error'
@render()
)
onIAPComplete: (e) ->
product = _.find @products, { id: e.productID }

View file

@ -69,7 +69,8 @@
"aether": "~0.2.39",
"JASON": "~0.1.3",
"JQDeferred": "~2.1.0",
"jsondiffpatch": "0.1.17"
"jsondiffpatch": "0.1.17",
"stripe": "~2.9.0"
},
"devDependencies": {
"jade": "0.33.x",

View file

@ -8,21 +8,26 @@ sendwithus = require '../sendwithus'
hipchat = require '../hipchat'
config = require '../../server_config'
request = require 'request'
stripe = require('stripe')(config.stripe.secretKey)
async = require 'async'
products = {
'gems_5': {
amount: 500
amount: 499
gems: 5000
id: 'gems_5'
}
'gems_10': {
amount: 1000
amount: 999
gems: 11000
id: 'gems_10'
}
'gems_20': {
amount: 2000
amount: 1999
gems: 25000
id: 'gems_20'
}
}
@ -45,9 +50,16 @@ PaymentHandler = class PaymentHandler extends Handler
appleLocalPrice = req.body.apple?.localPrice
stripeToken = req.body.stripe?.token
stripeTimestamp = parseInt(req.body.stripe?.timestamp)
productID = req.body.productID
if not (appleReceipt or stripeTimestamp)
return @sendBadInputError(res, 'Need either apple.rawReceipt or stripe.timestamp')
if not (appleReceipt or (stripeTimestamp and productID))
return @sendBadInputError(res, 'Need either apple.rawReceipt or stripe.timestamp and productID')
if stripeTimestamp and not productID
return @sendBadInputError(res, 'Need productID if paying with Stripe.')
if stripeTimestamp and (not stripeToken) and (not user.get('stripeCustomerID'))
return @sendBadInputError(res, 'Need stripe.token if new customer.')
if appleReceipt
if not appleTransactionID
@ -55,7 +67,10 @@ PaymentHandler = class PaymentHandler extends Handler
@handleApplePaymentPost(req, res, appleReceipt, appleTransactionID, appleLocalPrice)
else
@handleStripePaymentPost(req, res, stripeTimestamp, stripeToken)
@handleStripePaymentPost(req, res, stripeTimestamp, productID, stripeToken)
#- Apple payments
handleApplePaymentPost: (req, res, receipt, transactionID, localPrice) ->
formFields = { 'receipt-data': receipt }
@ -87,8 +102,6 @@ PaymentHandler = class PaymentHandler extends Handler
payment.set 'service', 'ios'
product = products[transaction.product_id]
product ?= _.values(products)[0] # TEST
payment.set 'amount', product.amount
payment.set 'gems', product.gems
payment.set 'ios', {
@ -110,10 +123,123 @@ PaymentHandler = class PaymentHandler extends Handler
)
handleStripePaymentPost: (req, res, timestamp, token) ->
console.log 'lol not implemented yet'
@sendNotFoundError(res)
#- Stripe payments
handleStripePaymentPost: (req, res, timestamp, productID, token) ->
# First, make sure we save the payment info as a Customer object, if we haven't already.
if not req.user.get('stripeCustomerID')
stripe.customers.create({
card: token
description: req.user._id + ''
}).then(((customer) =>
req.user.set('stripeCustomerID', customer.id)
req.user.save((err) =>
return @sendDatabaseError(res, err) if err
@beginStripePayment(req, res, timestamp, productID)
)
),
(err) =>
return @sendDatabaseError(res, err)
)
else
@beginStripePayment(req, res, timestamp, productID)
beginStripePayment: (req, res, timestamp, productID) ->
product = products[productID]
async.parallel([
((callback) ->
criteria = { recipient: req.user._id, 'stripe.timestamp': timestamp }
Payment.findOne(criteria).exec((err, payment) =>
callback(err, payment)
)
),
((callback) ->
stripe.charges.list({customer: req.user.get('stripeCustomerID')}, (err, recentCharges) =>
return callback(err) if err
charge = _.find recentCharges.data, (c) -> c.metadata.timestamp is timestamp
callback(null, charge)
)
)
],
((err, results) =>
return @sendDatabaseError(res, err) if err
[payment, charge] = results
if not (payment or charge)
# Proceed normally from the beginning
@chargeStripe(req, res, payment, product)
else if charge and not payment
# Initialized Payment. Start from charging.
@recordStripeCharge(req, res, payment, product, charge)
else
# Charged Stripe and recorded it. Recalculate gems to make sure credited the purchase.
@recalculateGemsFor(req.user, (err) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, @formatEntity(req, payment))
)
)
)
chargeStripe: (req, res, payment, product) ->
stripe.charges.create({
amount: product.amount
currency: 'usd'
customer: req.user.get('stripeCustomerID')
metadata: {
productID: product.id
userID: req.user._id + ''
gems: product.gems
timestamp: parseInt(req.body.stripe?.timestamp)
}
receipt_email: req.user.get('email')
}).then(
# success case
((charge) => @recordStripeCharge(req, res, payment, product, charge)),
# error case
((err) =>
if err.type in ['StripeCardError', 'StripeInvalidRequestError']
@sendError(res, 402, err.message)
else
@sendDatabaseError(res, 'Error charging card, please retry.'))
)
recordStripeCharge: (req, res, payment, product, charge) ->
return @sendError(res, 500, 'Fake db error for testing.') if req.body.breakAfterCharging
payment = @makeNewInstance(req)
payment.set 'service', 'stripe'
payment.set 'productID', req.body.productID
payment.set 'amount', product.amount
payment.set 'gems', product.gems
payment.set 'stripe', {
customerID: req.user.get('stripeCustomerID')
timestamp: parseInt(req.body.stripe.timestamp)
chargeID: charge.id
}
validation = @validateDocumentInput(payment.toObject())
return @sendBadInputError(res, validation.errors) if validation.valid is false
payment.save((err) =>
# Credit gems
return @sendDatabaseError(res, err) if err
@incrementGemsFor(req.user, product.gems, (err) =>
return @sendDatabaseError(res, err) if err
@sendCreated(res, @formatEntity(req, payment))
)
)
#- Incrementing/recalculating gems
incrementGemsFor: (user, gems, done) ->
purchased = _.clone(user.get('purchased'))

View file

@ -192,7 +192,7 @@ UserSchema.statics.hashPassword = (password) ->
UserSchema.statics.privateProperties = [
'permissions', 'email', 'mailChimp', 'firstName', 'lastName', 'gender', 'facebookID',
'gplusID', 'music', 'volume', 'aceConfig', 'employerAt', 'signedEmployerAgreement',
'emailSubscriptions', 'emails', 'activity'
'emailSubscriptions', 'emails', 'activity', 'stripeCustomerID'
]
UserSchema.statics.jsonSchema = jsonschema
UserSchema.statics.editableProperties = [

View file

@ -18,8 +18,10 @@ config.mongo =
mongoose_replica_string: process.env.COCO_MONGO_MONGOOSE_REPLICA_STRING or ''
config.apple =
#verifyURL: process.env.COCO_APPLE_VERIFY_URL or 'https://sandbox.itunes.apple.com/verifyReceipt'
verifyURL: process.env.COCO_APPLE_VERIFY_URL or 'https://buy.itunes.apple.com/verifyReceipt'
verifyURL: process.env.COCO_APPLE_VERIFY_URL or 'https://sandbox.itunes.apple.com/verifyReceipt'
config.stripe =
secretKey: process.env.COCO_STRIPE_SECRET_KEY or 'sk_test_MFnZHYD0ixBbiBuvTlLjl2da'
config.redis =
port: process.env.COCO_REDIS_PORT or 6379

File diff suppressed because one or more lines are too long