mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2025-03-14 07:00:01 -04:00
Set up stripe on the server and site to allow purchases through the website.
This commit is contained in:
parent
d3da5e330a
commit
95dca575d1
14 changed files with 404 additions and 39 deletions
10
app/lib/services/stripe.coffee
Normal file
10
app/lib/services/stripe.coffee
Normal 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 }
|
||||
})
|
|
@ -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"
|
||||
|
|
|
@ -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' }
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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'}
|
||||
}}
|
|
@ -42,3 +42,12 @@
|
|||
|
||||
button
|
||||
width: 80%
|
||||
|
||||
|
||||
//- Errors
|
||||
.alert
|
||||
position: absolute
|
||||
left: 10%
|
||||
width: 80%
|
||||
top: 20px
|
||||
border: 5px solid gray
|
||||
|
|
|
@ -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") ×
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue