mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-28 01:55:38 -05:00
38d19a142a
Also sought to more thoroughly protect earned.gems.
416 lines
16 KiB
CoffeeScript
416 lines
16 KiB
CoffeeScript
Payment = require './../models/Payment'
|
|
Prepaid = require '../models/Prepaid'
|
|
Product = require '../models/Product'
|
|
User = require '../models/User'
|
|
Handler = require '../commons/Handler'
|
|
{handlers} = require '../commons/mapping'
|
|
mongoose = require 'mongoose'
|
|
log = require 'winston'
|
|
sendwithus = require '../sendwithus'
|
|
slack = require '../slack'
|
|
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)
|
|
)
|
|
|
|
getByRelationship: (req, res, args...) ->
|
|
relationship = args[1]
|
|
return @getSchoolSalesAPI(req, res) if relationship is 'school_sales'
|
|
super arguments...
|
|
|
|
logPaymentError: (req, msg) ->
|
|
log.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
|
|
|
|
getSchoolSalesAPI: (req, res, code) ->
|
|
return @sendUnauthorizedError(res) unless req.user?.isAdmin()
|
|
userIDs = [];
|
|
Payment.find({}, {amount: 1, created: 1, description: 1, prepaidID: 1, productID: 1, purchaser: 1, service: 1}).exec (err, payments) =>
|
|
return @sendDatabaseError(res, err) if err
|
|
schoolSales = []
|
|
prepaidIDs = []
|
|
prepaidPaymentMap = {}
|
|
for payment in payments
|
|
continue unless payment.get('amount')? and payment.get('amount') > 0
|
|
unless created = payment.get('created')
|
|
created = payment.get('_id').getTimestamp()
|
|
description = payment.get('description') ? ''
|
|
if prepaidID = payment.get('prepaidID')
|
|
unless prepaidPaymentMap[prepaidID.valueOf()]
|
|
prepaidPaymentMap[prepaidID.valueOf()] = {_id: payment.get('_id').valueOf(), amount: payment.get('amount'), created: created, description: description, userID: payment.get('purchaser').valueOf(), prepaidID: prepaidID.valueOf()}
|
|
prepaidIDs.push(prepaidID)
|
|
userIDs.push(payment.get('purchaser'))
|
|
else if payment.get('productID') is 'custom' or payment.get('service') is 'external' or payment.get('service') is 'invoice'
|
|
schoolSales.push({_id: payment.get('_id').valueOf(), amount: payment.get('amount'), created: created, description: description, userID: payment.get('purchaser').valueOf()})
|
|
userIDs.push(payment.get('purchaser'))
|
|
|
|
Prepaid.find({$and: [{_id: {$in: prepaidIDs}}, {type: 'course'}]}, {_id: 1}).exec (err, prepaids) =>
|
|
return @sendDatabaseError(res, err) if err
|
|
for prepaid in prepaids
|
|
schoolSales.push(prepaidPaymentMap[prepaid.get('_id').valueOf()])
|
|
|
|
User.find({_id: {$in: userIDs}}).exec (err, users) =>
|
|
return @sendDatabaseError(res, err) if err
|
|
userMap = {}
|
|
for user in users
|
|
userMap[user.get('_id').valueOf()] = user
|
|
for schoolSale in schoolSales
|
|
schoolSale.user = userMap[schoolSale.userID]?.toObject()
|
|
|
|
@sendSuccess(res, schoolSales)
|
|
|
|
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)
|
|
@sendPaymentSlackMessage 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)
|
|
@sendPaymentSlackMessage 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)
|
|
@sendPaymentSlackMessage 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) ->
|
|
if not gems
|
|
return 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))
|
|
)
|
|
|
|
sendPaymentSlackMessage: (options) ->
|
|
try
|
|
message = "#{options.user?.get('emailLower')} paid #{options.payment?.get('amount')} for #{options.payment.get('description') or '???, no payment description!'}"
|
|
slack.sendSlackMessage message, ['tower']
|
|
catch e
|
|
log.error "Couldn't send Slack message on payment because of error: #{e}"
|
|
|
|
module.exports = new PaymentHandler()
|