From d0ee8cb7c7105e3af7c36f3ed12810bf1deb1b23 Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Thu, 4 Dec 2014 17:41:17 -0800 Subject: [PATCH] Set up the server to allow admins to assign customers coupons. Fixed up how customers are created/updated by the server to allow setting new payment information rather than forever using the payment info first provided. --- app/schemas/models/user.coffee | 3 + server/payments/discount_handler.coffee | 48 ++++++++ server/payments/payment_handler.coffee | 49 ++++---- server/payments/subscription_handler.coffee | 78 ++++++------ server/users/user_handler.coffee | 20 +++ .../functional/discount_handler.spec.coffee | 114 ++++++++++++++++++ test/server/functional/payment.spec.coffee | 29 +++++ 7 files changed, 278 insertions(+), 63 deletions(-) create mode 100644 server/payments/discount_handler.coffee create mode 100644 test/server/functional/discount_handler.spec.coffee diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 015145768..1d6396ac0 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -278,6 +278,9 @@ _.extend UserSchema.properties, planID: { enum: ['basic'] } subscriptionID: { type: 'string' } token: { type: 'string' } + couponID: { type: 'string' } + discountID: { type: 'string' } + free: { type: ['boolean', 'string'], format: 'date-time' } } c.extendBasicProperties UserSchema, 'user' diff --git a/server/payments/discount_handler.coffee b/server/payments/discount_handler.coffee new file mode 100644 index 000000000..64e865c94 --- /dev/null +++ b/server/payments/discount_handler.coffee @@ -0,0 +1,48 @@ +# Not paired with a document in the DB, just handles coordinating between +# the stripe property in the user with what's being stored in Stripe. + +Handler = require '../commons/Handler' +config = require '../../server_config' +stripe = require('stripe')(config.stripe.secretKey) + +class DiscountHandler extends Handler + logDiscountError: (req, msg) -> + console.warn "Discount Error: #{req.user.get('slug')} (#{req.user._id}): '#{msg}'" + + discountUser: (req, user, done) -> + if (not user) or user.isAnonymous() + return done({res: 'User must not be anonymous.', code: 403}) + + couponID = req.body.stripe.couponID + if not couponID + @logDiscountError(req, 'Missing couponID.') + return done({res: 'Missing couponID.', code: 422}) + + stripe.coupons.retrieve couponID, (err, coupon) => + if (err) + return done({res: 'No coupon with id '+couponID, code: 404}) + + if customerID = user.get('stripe')?.customerID + options = { coupon: coupon.id } + stripe.customers.update customerID, options, (err, customer) => + if err + @logDiscountError(req, 'Error applying coupon to customer'+customerID) + return done({res: 'Error applying coupon to customer.', code: 500}) + done() + + else + # couponID will be set on the user by the handler + done() + + removeDiscountFromCustomer: (req, user, done) -> + customerID = user.get('stripe').customerID + return done() unless customerID + + stripe.customers.deleteDiscount customerID, (err, customer) => + if err + console.log 'err?', err + @logDiscountError(req, 'Error removing coupon from customer ' + customerID) + return done({res: 'Error applying coupon to customer.', code: 500}) + done() + +module.exports = new DiscountHandler() \ No newline at end of file diff --git a/server/payments/payment_handler.coffee b/server/payments/payment_handler.coffee index f2e4f2bd4..cc077994b 100644 --- a/server/payments/payment_handler.coffee +++ b/server/payments/payment_handler.coffee @@ -166,29 +166,34 @@ PaymentHandler = class PaymentHandler extends Handler 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('stripe')?.customerID - stripe.customers.create({ - card: token - email: req.user.get('email') - metadata: { - id: req.user._id + '' - slug: req.user.get('slug') - } - }).then(((customer) => - 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) + 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) - ) - ), - (err) => - @logPaymentError(req, 'Stripe customer creation error. '+err) - return @sendDatabaseError(res, err) - ) + + 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) diff --git a/server/payments/subscription_handler.coffee b/server/payments/subscription_handler.coffee index 231f059d4..f1d0531e5 100644 --- a/server/payments/subscription_handler.coffee +++ b/server/payments/subscription_handler.coffee @@ -4,6 +4,7 @@ Handler = require '../commons/Handler' config = require '../../server_config' stripe = require('stripe')(config.stripe.secretKey) +discountHandler = require './discount_handler' subscriptions = { basic: { @@ -19,61 +20,52 @@ class SubscriptionHandler extends Handler if (not req.user) or req.user.isAnonymous() return done({res: 'You must be signed in to subscribe.', code: 403}) - stripeToken = req.body.stripe?.token - extantCustomerID = user.get('stripe')?.customerID - if not (stripeToken or extantCustomerID) + token = req.body.stripe?.token + customerID = user.get('stripe')?.customerID + if not (token or customerID) @logSubscriptionError(req, 'Missing stripe token or customer ID.') return done({res: 'Missing stripe token or customer ID.', code: 422}) - if stripeToken - stripe.customers.create({ - card: stripeToken - email: req.user.get('email') - metadata: { - id: req.user._id + '' - slug: req.user.get('slug') + if token + if customerID + stripe.customers.update customerID, { card: token }, (err, customer) => + @checkForExistingSubscription(req, user, customer, done) + + else + newCustomer = { + card: token + email: req.user.get('email') + metadata: { id: req.user._id + '', slug: req.user.get('slug') } } - }).then(((customer) => + + stripe.customers.create newCustomer, (err, customer) => + if err + if err.type in ['StripeCardError', 'StripeInvalidRequestError'] + return done({res: 'Card error', code: 402}) + else + @logSubscriptionError(req, 'Stripe customer creation error. '+err) + return done({res: 'Database error.', code: 500}) + stripeInfo = _.cloneDeep(req.user.get('stripe') ? {}) stripeInfo.customerID = customer.id req.user.set('stripe', stripeInfo) - req.user.save((err) => + req.user.save (err) => if err @logSubscriptionError(req, 'Stripe customer id save db error. '+err) return done({res: 'Database error.', code: 500}) @checkForExistingSubscription(req, user, customer, done) - ) - ), - (err) => - if err.type in ['StripeCardError', 'StripeInvalidRequestError'] - done({res: 'Card error', code: 402}) - else - @logSubscriptionError(req, 'Stripe customer creation error. '+err) - return done({res: 'Database error.', code: 500}) - ) else - stripe.customers.retrieve(extantCustomerID, (err, customer) => + stripe.customers.retrieve(customerID, (err, customer) => if err @logSubscriptionError(req, 'Stripe customer creation error. '+err) return done({res: 'Database error.', code: 500}) - else if not customer - # TODO: what actually happens when you try to retrieve a customer and it DNE? - @logSubscriptionError(req, 'Stripe customer id is missing! '+err) - stripeInfo = _.cloneDeep(req.user.get('stripe') ? {}) - delete stripeInfo.customerID - req.user.set('stripe', stripeInfo) - req.user.save (err) => - if err - @logSubscriptionError(req, 'Stripe customer id delete db error. '+err) - return done({res: 'Database error.', code: 500}) - @subscribeUser(req, done) - else - @checkForExistingSubscription(req, user, customer, done) + @checkForExistingSubscription(req, user, customer, done) ) checkForExistingSubscription: (req, user, customer, done) -> + couponID = user.get('stripe')?.couponID if subscription = customer.subscriptions?.data?[0] if subscription.cancel_at_period_end @@ -87,30 +79,34 @@ class SubscriptionHandler extends Handler return done({res: 'Database error.', code: 500}) options = { plan: 'basic', trial_end: subscription.current_period_end } + options.coupon = couponID if couponID stripe.customers.update req.user.get('stripe').customerID, options, (err, customer) => if err @logSubscriptionError(req, 'Stripe customer plan setting error. '+err) return done({res: 'Database error.', code: 500}) - @updateUser(req, user, customer.subscriptions.data[0], false, done) + @updateUser(req, user, customer, false, done) else # can skip creating the subscription - return @updateUser(req, user, customer.subscriptions.data[0], false, done) + return @updateUser(req, user, customer, false, done) else - stripe.customers.update req.user.get('stripe').customerID, { plan: 'basic' }, (err, customer) => + options = { plan: 'basic' } + options.coupon = couponID if couponID + stripe.customers.update req.user.get('stripe').customerID, options, (err, customer) => if err @logSubscriptionError(req, 'Stripe customer plan setting error. '+err) return done({res: 'Database error.', code: 500}) - @updateUser(req, user, customer.subscriptions.data[0], true, done) + @updateUser(req, user, customer, true, done) - updateUser: (req, user, subscription, increment, done) -> + updateUser: (req, user, customer, increment, done) -> + subscription = customer.subscriptions.data[0] stripeInfo = _.cloneDeep(user.get('stripe') ? {}) stripeInfo.planID = 'basic' stripeInfo.subscriptionID = subscription.id - stripeInfo.customerID = subscription.customer + stripeInfo.customerID = customer.id req.body.stripe = stripeInfo # to make sure things work for admins, who are mad with power user.set('stripe', stripeInfo) diff --git a/server/users/user_handler.coffee b/server/users/user_handler.coffee index 083b91ab4..c413d93da 100644 --- a/server/users/user_handler.coffee +++ b/server/users/user_handler.coffee @@ -12,6 +12,7 @@ moment = require 'moment' LevelSession = require '../levels/sessions/LevelSession' LevelSessionHandler = require '../levels/sessions/level_session_handler' SubscriptionHandler = require '../payments/subscription_handler' +DiscountHandler = require '../payments/discount_handler' EarnedAchievement = require '../achievements/EarnedAchievement' UserRemark = require './remarks/UserRemark' {isID} = require '../lib/utils' @@ -123,6 +124,25 @@ UserHandler = class UserHandler extends Handler return callback(err) if err return callback(null, req, user) ) + + # Discount setting + (req, user, callback) -> + return callback(null, req, user) unless req.user?.isAdmin() + hasCoupon = user.get('stripe')?.couponID + wantsCoupon = req.body.stripe?.couponID + + return callback(null, req, user) if hasCoupon is wantsCoupon + if wantsCoupon and (hasCoupon isnt wantsCoupon) + DiscountHandler.discountUser(req, user, (err) -> + return callback(err) if err + return callback(null, req, user) + ) + else if hasCoupon and not wantsCoupon + DiscountHandler.removeDiscountFromCustomer(req, user, (err) -> + return callback(err) if err + return callback(null, req, user) + ) + ] getById: (req, res, id) -> diff --git a/test/server/functional/discount_handler.spec.coffee b/test/server/functional/discount_handler.spec.coffee new file mode 100644 index 000000000..909b97f41 --- /dev/null +++ b/test/server/functional/discount_handler.spec.coffee @@ -0,0 +1,114 @@ + +config = require '../../../server_config' +require '../common' + +# sample data that comes in through the webhook when you subscribe + + +describe '/db/user, editing stripe.couponID property', -> + + stripe = require('stripe')(config.stripe.secretKey) + userURL = getURL('/db/user') + webhookURL = getURL('/stripe/webhook') + + it 'clears the db first', (done) -> + clearModels [User, Payment], (err) -> + throw err if err + done() + + #- shared data between tests + joeData = null + firstSubscriptionID = null + + it 'does not work for non-admins', (done) -> + loginJoe (joe) -> + joeData = joe.toObject() + expect(joeData.stripe).toBeUndefined() + joeData.stripe = { couponID: '20pct' } + request.put {uri: userURL, json: joeData }, (err, res, body) -> + expect(res.statusCode).toBe(200) # fails silently + expect(res.body.stripe).toBeUndefined() # but still fails + done() + + it 'does not work with invalid coupons', (done) -> + loginAdmin (admin) -> + joeData.stripe = { couponID: 'DNE' } + request.put {uri: userURL, json: joeData }, (err, res, body) -> + expect(res.statusCode).toBe(404) + done() + + it 'sets the couponID on a user without an existing stripe object', (done) -> + joeData.stripe = { couponID: '20pct' } + request.put {uri: userURL, json: joeData }, (err, res, body) -> + joeData = body + expect(res.statusCode).toBe(200) + expect(body.stripe.couponID).toBe('20pct') + done() + + it 'just updates the couponID when it changes and there is no existing subscription', (done) -> + joeData.stripe.couponID = '500off' + request.put {uri: userURL, json: joeData }, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.stripe.couponID).toBe('500off') + done() + + it 'removes the couponID from the user when the admin makes it so', (done) -> + delete joeData.stripe.couponID + request.put {uri: userURL, json: joeData }, (err, res, body) -> + joeData = body + expect(res.statusCode).toBe(200) + expect(body.stripe).toBeUndefined() + done() + + it 'puts the coupon back', (done) -> + joeData.stripe = {couponID: '500off'} + request.put {uri: userURL, json: joeData }, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.stripe.couponID).toBe('500off') + done() + + it 'applies a discount to the newly created customer when a plan is set', (done) -> + stripe.tokens.create { + card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } + }, (err, token) -> + stripeTokenID = token.id + loginJoe (joe) -> + joeData.stripe.token = stripeTokenID + joeData.stripe.planID = 'basic' + request.put {uri: userURL, json: joeData }, (err, res, body) -> + joeData = body + expect(res.statusCode).toBe(200) + stripe.customers.retrieve joeData.stripe.customerID, (err, customer) -> + expect(customer.discount).toBeDefined() + expect(customer.discount.coupon.id).toBe('500off') + done() + + + it 'updates the discount on the customer when an admin changes the couponID', (done) -> + loginAdmin (admin) -> + joeData.stripe.couponID = '20pct' + request.put {uri: userURL, json: joeData }, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.stripe.couponID).toBe('20pct') + stripe.customers.retrieve joeData.stripe.customerID, (err, customer) -> + expect(customer.discount.coupon.id).toBe('20pct') + done() + + it 'removes discounts from the customer when an admin removes the couponID', (done) -> + delete joeData.stripe.couponID + request.put {uri: userURL, json: joeData }, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.stripe.couponID).toBeUndefined() + stripe.customers.retrieve joeData.stripe.customerID, (err, customer) -> + expect(customer.discount).toBe(null) + done() + + it 'adds a discount to the customer when an admin adds the couponID', (done) -> + joeData.stripe.couponID = '20pct' + request.put {uri: userURL, json: joeData }, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.stripe.couponID).toBe('20pct') + stripe.customers.retrieve joeData.stripe.customerID, (err, customer) -> + expect(customer.discount.coupon.id).toBe('20pct') + done() + diff --git a/test/server/functional/payment.spec.coffee b/test/server/functional/payment.spec.coffee index 26cb4cb44..f8fb257cf 100644 --- a/test/server/functional/payment.spec.coffee +++ b/test/server/functional/payment.spec.coffee @@ -82,6 +82,7 @@ describe '/db/payment', -> joeID = null timestamp = new Date().getTime() stripeTokenID = null + joeData = null it 'clears the db first', (done) -> clearModels [User, Payment], (err) -> @@ -131,6 +132,34 @@ describe '/db/payment', -> done() ) ) + + it 'allows a new charge on the existing customer', (done) -> + data = { productID: 'gems_5', stripe: { timestamp: new Date().getTime() } } + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe 201 + Payment.count {}, (err, count) -> + expect(count).toBe(2) + User.findById joeID, (err, user) -> + joeData = user.toObject() + expect(user.get('purchased').gems).toBe(10000) + done() + + it "updates the customer's card when you submit a new token", (done) -> + stripe.customers.retrieve joeData.stripe.customerID, (err, customer) -> + originalCustomerID = customer.id + originalCardID = customer.cards.data[0].id + stripe.tokens.create { + card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } + }, (err, token) -> + data = { productID: 'gems_5', stripe: { timestamp: new Date().getTime(), token: token.id } } + request.post {uri: paymentURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(201) + User.findById joeID, (err, user) -> + joeData = user.toObject() + expect(joeData.stripe.customerID).toBe(originalCustomerID) + stripe.customers.retrieve joeData.stripe.customerID, (err, customer) -> + expect(customer.cards.data[0].id).not.toBe(originalCardID) + done() it 'clears the db', (done) -> clearModels [User, Payment], (err) ->