From b0de331a10c2c2e058e2147bd8491ccd6a8d9eec Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Fri, 12 Dec 2014 15:27:58 -0800 Subject: [PATCH] Shored up stripe transaction payment defences to try and fix #1906. --- app/locale/en.coffee | 1 + app/templates/play/modal/buy-gems-modal.jade | 6 ++ app/views/play/modal/BuyGemsModal.coffee | 6 +- server/commons/Handler.coffee | 8 +-- server/payments/payment_handler.coffee | 64 ++++++++++++++++---- test/server/functional/payment.spec.coffee | 38 +++++++++++- 6 files changed, 103 insertions(+), 20 deletions(-) diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 508a8b1e4..e87374a4c 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -333,6 +333,7 @@ prompt_title: "Not Enough Gems" prompt_body: "Do you want to get more?" prompt_button: "Enter Shop" + recovered: "Previous gems purchase recovered. Please refresh the page." subscribe: subscribe_title: "Subscribe" diff --git a/app/templates/play/modal/buy-gems-modal.jade b/app/templates/play/modal/buy-gems-modal.jade index 78255af54..701ecc0aa 100644 --- a/app/templates/play/modal/buy-gems-modal.jade +++ b/app/templates/play/modal/buy-gems-modal.jade @@ -29,3 +29,9 @@ span(aria-hidden="true") × p(data-i18n="loading_error.unknown") p= stateMessage + + if state === 'recovered_charge' + #recovered-alert.alert.alert-danger.alert-dismissible + span(data-i18n="buy_gems.recovered") + button.close(type="button" data-dismiss="alert") + span(aria-hidden="true") × diff --git a/app/views/play/modal/BuyGemsModal.coffee b/app/views/play/modal/BuyGemsModal.coffee index 3dabb85d9..2a3539b50 100644 --- a/app/views/play/modal/BuyGemsModal.coffee +++ b/app/views/play/modal/BuyGemsModal.coffee @@ -24,12 +24,17 @@ module.exports = class BuyGemsModal extends ModalView constructor: (options) -> super(options) + @timestampForPurchase = new Date().getTime() @state = 'standby' if application.isIPadApp @products = [] Backbone.Mediator.publish 'buy-gems-modal:update-products' else @products = @originalProducts + $.post '/db/payment/check-stripe-charges', (something, somethingElse, jqxhr) => + if jqxhr.status is 201 + @state = 'recovered_charge' + @render() getRenderData: -> c = super() @@ -67,7 +72,6 @@ module.exports = class BuyGemsModal extends ModalView @productBeingPurchased = product onStripeReceivedToken: (e) -> - @timestampForPurchase = new Date().getTime() data = { productID: @productBeingPurchased.id stripe: { diff --git a/server/commons/Handler.coffee b/server/commons/Handler.coffee index b98dca663..0fdf72179 100644 --- a/server/commons/Handler.coffee +++ b/server/commons/Handler.coffee @@ -88,15 +88,15 @@ module.exports = class Handler sendError: (res, code, message) -> errors.custom(res, code, message) - sendSuccess: (res, message) -> - res.send(message) + sendSuccess: (res, message='{}') -> + res.send 200, message res.end() - sendCreated: (res, message) -> + sendCreated: (res, message='{}') -> res.send 201, message res.end() - sendAccepted: (res, message) -> + sendAccepted: (res, message='{}') -> res.send 202, message res.end() diff --git a/server/payments/payment_handler.coffee b/server/payments/payment_handler.coffee index 4f9707494..fbd47af15 100644 --- a/server/payments/payment_handler.coffee +++ b/server/payments/payment_handler.coffee @@ -54,7 +54,10 @@ PaymentHandler = class PaymentHandler extends Handler payment.set 'created', new Date().toISOString() payment - post: (req, res) -> + 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) @@ -225,11 +228,11 @@ PaymentHandler = class PaymentHandler extends Handler if not (payment or charge) # Proceed normally from the beginning - @chargeStripe(req, res, payment, product) + @chargeStripe(req, res, product) else if charge and not payment # Initialized Payment. Start from charging. - @recordStripeCharge(req, res, payment, product, charge) + @recordStripeCharge(req, res, charge) else # Charged Stripe and recorded it. Recalculate gems to make sure credited the purchase. @@ -244,7 +247,7 @@ PaymentHandler = class PaymentHandler extends Handler ) - chargeStripe: (req, res, payment, product) -> + chargeStripe: (req, res, product) -> stripe.charges.create({ amount: product.amount currency: 'usd' @@ -258,7 +261,7 @@ PaymentHandler = class PaymentHandler extends Handler receipt_email: req.user.get('email') }).then( # success case - ((charge) => @recordStripeCharge(req, res, payment, product, charge)), + ((charge) => @recordStripeCharge(req, res, charge)), # error case ((err) => @@ -270,19 +273,19 @@ PaymentHandler = class PaymentHandler extends Handler ) - recordStripeCharge: (req, res, payment, product, charge) -> + 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', req.body.productID - payment.set 'amount', product.amount - payment.set 'gems', product.gems + payment.set 'productID', charge.metadata.productID + payment.set 'amount', parseInt(charge.amount) + payment.set 'gems', parseInt(charge.metadata.gems) payment.set 'stripe', { - customerID: req.user.get('stripe')?.customerID - timestamp: parseInt(req.body.stripe.timestamp) + 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.') @@ -291,7 +294,7 @@ PaymentHandler = class PaymentHandler extends Handler # Credit gems return @sendDatabaseError(res, err) if err - @incrementGemsFor(req.user, product.gems, (err) => + @incrementGemsFor(req.user, parseInt(charge.metadata.gems), (err) => if err @logPaymentError(req, 'Stripe incr db error. '+err) return @sendDatabaseError(res, err) @@ -299,6 +302,41 @@ PaymentHandler = class PaymentHandler extends Handler ) ) + + #- 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 } } + 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 if charge.invoice # filter out subscription charges + if charge.id not in recordedChargeIDs + return @recordStripeCharge(req, res, charge) + + @sendSuccess(res) + ) + ) #- Incrementing/recalculating gems diff --git a/test/server/functional/payment.spec.coffee b/test/server/functional/payment.spec.coffee index f8fb257cf..bd9e89f01 100644 --- a/test/server/functional/payment.spec.coffee +++ b/test/server/functional/payment.spec.coffee @@ -6,6 +6,7 @@ require '../common' describe '/db/payment', -> request = require 'request' paymentURL = getURL('/db/payment') + checkChargesURL = getURL('/db/payment/check-stripe-charges') firstApplePayment = { apple: { @@ -47,7 +48,7 @@ describe '/db/payment', -> expect(user.get('purchased').gems).toBe(5000) done() ) - + it 'is idempotent', (done) -> loginJoe -> request.post {uri: paymentURL, json: firstApplePayment}, (err, res, body) -> @@ -261,4 +262,37 @@ describe '/db/payment', -> done() ) - \ No newline at end of file + describe '/db/payment/check-stripe-charges', -> + stripe = require('stripe')(config.stripe.secretKey) + + it 'clears the db', (done) -> + clearModels [User, Payment], (err) -> + throw err if err + done() + + it 'finds and records charges which are not in our db', (done) -> + timestamp = new Date().getTime() + stripe.tokens.create { + card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } + }, (err, token) -> + + data = { + productID: 'gems_5' + stripe: { token: token.id, timestamp: timestamp } + breakAfterCharging: true + } + + loginJoe (joe) -> + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe 500 + + request.post { uri: checkChargesURL }, (err, res, body) -> + expect(res.statusCode).toBe 201 + Payment.count({}, (err, count) -> + expect(count).toBe(1) + User.findById(joe.get('_id'), (err, user) -> + expect(user.get('purchased').gems).toBe(5000) + done() + ) + ) + \ No newline at end of file