mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-18 11:32:25 -05:00
371 lines
14 KiB
CoffeeScript
371 lines
14 KiB
CoffeeScript
Payment = require './Payment'
|
|
Product = require '../models/Product'
|
|
User = require '../users/User'
|
|
Handler = require '../commons/Handler'
|
|
{handlers} = require '../commons/mapping'
|
|
mongoose = require 'mongoose'
|
|
log = require 'winston'
|
|
sendwithus = require '../sendwithus'
|
|
hipchat = require '../hipchat'
|
|
config = require '../../server_config'
|
|
request = require 'request'
|
|
async = require 'async'
|
|
apple_utils = require '../lib/apple_utils'
|
|
|
|
|
|
PaymentHandler = class PaymentHandler extends Handler
|
|
modelClass: Payment
|
|
editableProperties: []
|
|
postEditableProperties: ['purchased']
|
|
jsonSchema: require '../../app/schemas/models/payment.schema'
|
|
|
|
get: (req, res) ->
|
|
return res.send([]) unless req.user
|
|
q = Payment.find({recipient:req.user._id})
|
|
q.exec((err, payments) ->
|
|
return @sendDatabaseError(res, err) if err
|
|
res.send(payments)
|
|
)
|
|
|
|
logPaymentError: (req, msg) ->
|
|
console.warn "Payment Error: #{req.user.get('slug')} (#{req.user._id}): '#{msg}'"
|
|
|
|
makeNewInstance: (req) ->
|
|
payment = super(req)
|
|
payment.set 'purchaser', req.user._id
|
|
payment.set 'recipient', req.user._id
|
|
payment.set 'created', new Date().toISOString()
|
|
payment
|
|
|
|
post: (req, res, pathName) ->
|
|
if pathName is 'check-stripe-charges'
|
|
return @checkStripeCharges(req, res)
|
|
|
|
if (not req.user) or req.user.isAnonymous()
|
|
return @sendForbiddenError(res)
|
|
|
|
appleReceipt = req.body.apple?.rawReceipt
|
|
appleTransactionID = req.body.apple?.transactionID
|
|
appleLocalPrice = req.body.apple?.localPrice
|
|
stripeToken = req.body.stripe?.token
|
|
stripeTimestamp = parseInt(req.body.stripe?.timestamp)
|
|
productID = req.body.productID
|
|
|
|
if pathName is 'custom'
|
|
return @handleStripePaymentPost(req, res, stripeTimestamp, 'custom', stripeToken)
|
|
|
|
if not (appleReceipt or (stripeTimestamp and productID))
|
|
@logPaymentError(req, "Missing data. Apple? #{!!appleReceipt}. Stripe timestamp? #{!!stripeTimestamp}. Product id? #{!!productID}.")
|
|
return @sendBadInputError(res, 'Need either apple.rawReceipt or stripe.timestamp and productID')
|
|
|
|
if stripeTimestamp and not productID
|
|
@logPaymentError(req, 'Missing stripe productID')
|
|
return @sendBadInputError(res, 'Need productID if paying with Stripe.')
|
|
|
|
if stripeTimestamp and (not stripeToken) and (not req.user.get('stripe')?.customerID)
|
|
@logPaymentError(req, 'Missing stripe token')
|
|
return @sendBadInputError(res, 'Need stripe.token if new customer.')
|
|
|
|
if appleReceipt
|
|
if not appleTransactionID
|
|
@logPaymentError(req, 'Missing apple transaction id')
|
|
return @sendBadInputError(res, 'Apple purchase? Need to specify which transaction.')
|
|
@handleApplePaymentPost(req, res, appleReceipt, appleTransactionID, appleLocalPrice)
|
|
else
|
|
@handleStripePaymentPost(req, res, stripeTimestamp, productID, stripeToken)
|
|
|
|
#- Apple payments
|
|
|
|
handleApplePaymentPost: (req, res, receipt, transactionID, localPrice) ->
|
|
#- verify receipt with Apple
|
|
|
|
apple_utils.verifyReceipt(receipt, (err, body) =>
|
|
if err or not body?.receipt?.in_app or (not body?.bundle_id is 'com.codecombat.CodeCombat')
|
|
console.warn 'apple receipt error?', err, body
|
|
@logPaymentError(req, 'Unable to verify apple receipt')
|
|
@sendBadInputError(res, 'Unable to verify Apple receipt.')
|
|
return
|
|
|
|
transaction = _.find body.receipt.in_app, { transaction_id: transactionID }
|
|
unless transaction
|
|
@logPaymentError(req, 'Missing transaction given id.')
|
|
return @sendBadInputError(res, 'Invalid transactionID.')
|
|
|
|
#- Check existence
|
|
transactionID = transaction.transaction_id
|
|
criteria = { 'ios.transactionID': transactionID }
|
|
Payment.findOne(criteria).exec((err, payment) =>
|
|
|
|
if payment
|
|
unless payment.get('recipient').equals(req.user._id)
|
|
@logPaymentError(req, 'Cross user apple payment.')
|
|
return @sendForbiddenError(res)
|
|
|
|
@recalculateGemsFor(req.user, (err) =>
|
|
if err
|
|
@logPaymentError(req, 'Apple recalc db error.'+err)
|
|
return @sendDatabaseError(res, err)
|
|
@sendSuccess(res, @formatEntity(req, payment))
|
|
)
|
|
return
|
|
|
|
payment = @makeNewInstance(req)
|
|
payment.set 'service', 'ios'
|
|
Product.findOne({name: transaction.product_id}).exec (err, product) =>
|
|
return @sendDatabaseError(res, err) if err
|
|
return @sendNotFoundError(res) if not product
|
|
payment.set 'amount', product.get('amount')
|
|
payment.set 'gems', product.get('gems')
|
|
payment.set 'ios', {
|
|
transactionID: transactionID
|
|
rawReceipt: receipt
|
|
localPrice: localPrice
|
|
}
|
|
|
|
validation = @validateDocumentInput(payment.toObject())
|
|
if validation.valid is false
|
|
@logPaymentError(req, 'Invalid apple payment object.')
|
|
return @sendBadInputError(res, validation.errors)
|
|
|
|
payment.save((err) =>
|
|
if err
|
|
@logPaymentError(req, 'Apple payment save error.'+err)
|
|
return @sendDatabaseError(res, err)
|
|
@incrementGemsFor(req.user, product.get('gems'), (err) =>
|
|
if err
|
|
@logPaymentError(req, 'Apple incr db error.'+err)
|
|
return @sendDatabaseError(res, err)
|
|
@sendPaymentHipChatMessage user: req.user, payment: payment
|
|
@sendCreated(res, @formatEntity(req, payment))
|
|
)
|
|
)
|
|
)
|
|
)
|
|
|
|
#- 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 token
|
|
customerID = req.user.get('stripe')?.customerID
|
|
|
|
if customerID
|
|
# old customer, new token. Save it.
|
|
stripe.customers.update customerID, { card: token }, (err, customer) =>
|
|
@beginStripePayment(req, res, timestamp, productID)
|
|
|
|
else
|
|
newCustomer = {
|
|
card: token
|
|
email: req.user.get('email')
|
|
metadata: { id: req.user._id + '', slug: req.user.get('slug') }
|
|
}
|
|
|
|
stripe.customers.create newCustomer, (err, customer) =>
|
|
if err
|
|
@logPaymentError(req, 'Stripe customer creation error. '+err)
|
|
return @sendDatabaseError(res, err)
|
|
|
|
stripeInfo = _.cloneDeep(req.user.get('stripe') ? {})
|
|
stripeInfo.customerID = customer.id
|
|
req.user.set('stripe', stripeInfo)
|
|
req.user.save (err) =>
|
|
if err
|
|
@logPaymentError(req, 'Stripe customer id save db error. '+err)
|
|
return @sendDatabaseError(res, err)
|
|
@beginStripePayment(req, res, timestamp, productID)
|
|
|
|
else
|
|
@beginStripePayment(req, res, timestamp, productID)
|
|
|
|
|
|
beginStripePayment: (req, res, timestamp, 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('stripe')?.customerID}, (err, recentCharges) =>
|
|
return callback(err) if err
|
|
charge = _.find recentCharges.data, (c) -> c.metadata.timestamp is timestamp
|
|
callback(null, charge)
|
|
)
|
|
),
|
|
((callback) ->
|
|
Product.findOne({name: productID}).exec (err, product) =>
|
|
callback(err, product)
|
|
)
|
|
],
|
|
|
|
((err, results) =>
|
|
if err
|
|
@logPaymentError(req, 'Stripe async load db error. '+err)
|
|
return @sendDatabaseError(res, err)
|
|
[payment, charge, product] = results
|
|
|
|
if not product
|
|
return @sendNotFoundError(res, 'could not find product with id '+productID)
|
|
|
|
if not (payment or charge)
|
|
# Proceed normally from the beginning
|
|
@chargeStripe(req, res, product)
|
|
|
|
else if charge and not payment
|
|
# Initialized Payment. Start from charging.
|
|
@recordStripeCharge(req, res, charge)
|
|
|
|
else
|
|
return @sendSuccess(res, @formatEntity(req, payment)) if product.get('name') is 'custom'
|
|
|
|
# Charged Stripe and recorded it. Recalculate gems to make sure credited the purchase.
|
|
@recalculateGemsFor(req.user, (err) =>
|
|
if err
|
|
@logPaymentError(req, 'Stripe recalc db error. '+err)
|
|
return @sendDatabaseError(res, err)
|
|
@sendPaymentHipChatMessage user: req.user, payment: payment
|
|
@sendSuccess(res, @formatEntity(req, payment))
|
|
)
|
|
)
|
|
)
|
|
|
|
chargeStripe: (req, res, product) ->
|
|
amount = parseInt product.get('amount') ? req.body.amount
|
|
return @sendError(res, 400, "Invalid amount.") if isNaN(amount)
|
|
|
|
stripe.charges.create({
|
|
amount: amount
|
|
currency: 'usd'
|
|
customer: req.user.get('stripe')?.customerID
|
|
metadata: {
|
|
productID: product.get('name')
|
|
userID: req.user._id + ''
|
|
gems: product.get('gems')
|
|
timestamp: parseInt(req.body.stripe?.timestamp)
|
|
description: req.body.description
|
|
}
|
|
receipt_email: req.user.get('email')
|
|
statement_descriptor: 'CODECOMBAT.COM'
|
|
}).then(
|
|
# success case
|
|
((charge) => @recordStripeCharge(req, res, charge)),
|
|
|
|
# error case
|
|
((err) =>
|
|
if err.type in ['StripeCardError', 'StripeInvalidRequestError']
|
|
@sendError(res, 402, err.message)
|
|
else
|
|
@logPaymentError(req, 'Stripe charge error. '+err)
|
|
@sendDatabaseError(res, 'Error charging card, please retry.'))
|
|
)
|
|
|
|
recordStripeCharge: (req, res, charge) ->
|
|
return @sendError(res, 500, 'Fake db error for testing.') if req.body.breakAfterCharging
|
|
payment = @makeNewInstance(req)
|
|
payment.set 'service', 'stripe'
|
|
payment.set 'productID', charge.metadata.productID
|
|
payment.set 'amount', parseInt(charge.amount)
|
|
payment.set 'gems', parseInt(charge.metadata.gems) if charge.metadata.gems
|
|
payment.set 'description', charge.metadata.description if charge.metadata.description
|
|
payment.set 'stripe', {
|
|
customerID: charge.customer
|
|
timestamp: parseInt(charge.metadata.timestamp)
|
|
chargeID: charge.id
|
|
}
|
|
|
|
validation = @validateDocumentInput(payment.toObject())
|
|
if validation.valid is false
|
|
@logPaymentError(req, 'Invalid stripe payment object.')
|
|
return @sendBadInputError(res, validation.errors)
|
|
payment.save((err) =>
|
|
return @sendDatabaseError(res, err) if err
|
|
return @sendCreated(res, @formatEntity(req, payment)) if payment.productID is 'custom'
|
|
|
|
# Credit gems
|
|
@incrementGemsFor(req.user, parseInt(charge.metadata.gems), (err) =>
|
|
if err
|
|
@logPaymentError(req, 'Stripe incr db error. '+err)
|
|
return @sendDatabaseError(res, err)
|
|
@sendPaymentHipChatMessage user: req.user, payment: payment
|
|
@sendCreated(res, @formatEntity(req, payment))
|
|
)
|
|
)
|
|
|
|
#- Confirm all Stripe charges are recorded on our server
|
|
|
|
checkStripeCharges: (req, res) ->
|
|
return @sendSuccess(res) unless customerID = req.user.get('stripe')?.customerID
|
|
async.parallel([
|
|
((callback) ->
|
|
criteria = { recipient: req.user._id, 'stripe.invoiceID': { $exists: false }, 'ios.transactionID': { $exists: false } }
|
|
Payment.find(criteria).limit(100).sort({_id:-1}).exec((err, payments) =>
|
|
callback(err, payments)
|
|
)
|
|
),
|
|
((callback) ->
|
|
stripe.charges.list({customer: customerID, limit: 100}, (err, recentCharges) =>
|
|
return callback(err) if err
|
|
callback(null, recentCharges.data)
|
|
)
|
|
)
|
|
],
|
|
|
|
((err, results) =>
|
|
if err
|
|
@logPaymentError(req, 'Stripe async load db error. '+err)
|
|
return @sendDatabaseError(res, err)
|
|
|
|
[payments, charges] = results
|
|
recordedChargeIDs = (p.get('stripe').chargeID for p in payments)
|
|
for charge in charges
|
|
continue unless charge.paid
|
|
continue if charge.invoice # filter out subscription charges
|
|
if charge.id not in recordedChargeIDs
|
|
return @recordStripeCharge(req, res, charge)
|
|
|
|
@sendSuccess(res)
|
|
)
|
|
)
|
|
|
|
#- Incrementing/recalculating gems
|
|
|
|
incrementGemsFor: (user, gems, done) ->
|
|
purchased = _.clone(user.get('purchased'))
|
|
if not purchased?.gems
|
|
purchased ?= {}
|
|
purchased.gems = gems
|
|
user.set('purchased', purchased)
|
|
user.save((err) -> done(err))
|
|
|
|
else
|
|
user.update({$inc: {'purchased.gems': gems}}, {}, (err) -> done(err))
|
|
|
|
recalculateGemsFor: (user, done, saveIfUnchanged=true) ->
|
|
|
|
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 ?= {}
|
|
if (purchased.gems or 0) isnt gems
|
|
log.debug "Updating #{user.get('_id')} gems from #{purchased.gems} to #{gems} from #{payments.length} payments; #{user.get('email')} #{user.get('name')}"
|
|
else unless saveIfUnchanged
|
|
log.debug "#{user.get('_id')} already had #{purchased.gems} #{gems} from #{payments.length} payments; #{user.get('email')} #{user.get('name')}"
|
|
return done()
|
|
purchased.gems = gems
|
|
user.set('purchased', purchased)
|
|
user.save((err) -> done(err))
|
|
)
|
|
|
|
sendPaymentHipChatMessage: (options) ->
|
|
try
|
|
message = "#{options.user?.get('name')} bought #{options.payment?.get('amount')} via #{options.payment?.get('service')}"
|
|
message += " for #{options.payment.get('description')}" if options.payment?.get('description')
|
|
hipchat.sendHipChatMessage message, ['tower']
|
|
catch e
|
|
log.error "Couldn't send HipChat message on payment because of error: #{e}"
|
|
|
|
module.exports = new PaymentHandler()
|