mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-24 08:08:15 -05:00
609884eb51
Adding a second table, setting range to be 60 and 365 days. Updating teacher columns to prioritize student paid status over furthest student course.
414 lines
16 KiB
CoffeeScript
414 lines
16 KiB
CoffeeScript
Payment = require './Payment'
|
|
Prepaid = require '../prepaids/Prepaid'
|
|
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)
|
|
)
|
|
|
|
getByRelationship: (req, res, args...) ->
|
|
relationship = args[1]
|
|
return @getSchoolSalesAPI(req, res) if relationship is 'school_sales'
|
|
super arguments...
|
|
|
|
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
|
|
|
|
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)
|
|
@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()
|