From 7c516c4d9fe334af8db3fc0d8fe8840dca543519 Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Mon, 14 Dec 2015 11:10:37 -0800 Subject: [PATCH] Move product information to the db --- app/collections/Products.coffee | 8 + app/core/utils.coffee | 2 +- app/models/Product.coffee | 6 + app/schemas/models/product.schema.coffee | 13 + app/templates/account/prepaid-view.jade | 4 +- app/templates/core/subscribe-modal.jade | 7 +- .../courses/purchase-courses-view.jade | 2 +- app/templates/play/modal/buy-gems-modal.jade | 22 +- app/views/account/PrepaidView.coffee | 19 +- app/views/core/SubscribeModal.coffee | 24 +- app/views/courses/PurchaseCoursesView.coffee | 13 +- app/views/play/modal/BuyGemsModal.coffee | 45 +-- server/models/Product.coffee | 5 + server/payments/payment_handler.coffee | 92 +++--- server/payments/subscription_handler.coffee | 289 +++++++++--------- server/prepaids/prepaid_handler.coffee | 31 +- server/routes/index.coffee | 2 + server_setup.coffee | 2 + spec/helpers/helper.js | 9 + 19 files changed, 334 insertions(+), 261 deletions(-) create mode 100644 app/collections/Products.coffee create mode 100644 app/models/Product.coffee create mode 100644 app/schemas/models/product.schema.coffee create mode 100644 server/models/Product.coffee create mode 100644 server/routes/index.coffee diff --git a/app/collections/Products.coffee b/app/collections/Products.coffee new file mode 100644 index 000000000..a68524d67 --- /dev/null +++ b/app/collections/Products.coffee @@ -0,0 +1,8 @@ +CocoCollection = require './CocoCollection' +Product = require 'models/Product' + +module.exports = class Products extends CocoCollection + model: Product + url: '/db/products' + + getByName: (name) -> @findWhere { name: name } \ No newline at end of file diff --git a/app/core/utils.coffee b/app/core/utils.coffee index 938c8c1f6..538444ca6 100644 --- a/app/core/utils.coffee +++ b/app/core/utils.coffee @@ -244,7 +244,7 @@ module.exports.getCoursePraise = getCoursePraise = -> ] praise[_.random(0, praise.length - 1)] -module.exports.getPrepaidCodeAmount = getPrepaidCodeAmount = (price=999, users=0, months=0) -> +module.exports.getPrepaidCodeAmount = getPrepaidCodeAmount = (price=0, users=0, months=0) -> return 0 unless users > 0 and months > 0 total = price * users * months total diff --git a/app/models/Product.coffee b/app/models/Product.coffee new file mode 100644 index 000000000..62a8c8434 --- /dev/null +++ b/app/models/Product.coffee @@ -0,0 +1,6 @@ +CocoModel = require './CocoModel' + +module.exports = class ProductModel extends CocoModel + @className: 'Product' + @schema: require 'schemas/models/product.schema' + urlRoot: '/db/products' diff --git a/app/schemas/models/product.schema.coffee b/app/schemas/models/product.schema.coffee new file mode 100644 index 000000000..b3cdc55b5 --- /dev/null +++ b/app/schemas/models/product.schema.coffee @@ -0,0 +1,13 @@ +c = require './../schemas' + +module.exports = ProductSchema = { + type: 'object' + additionalProperties: false + properties: { + name: { type: 'string' } + amount: { type: 'integer', description: 'Cost in cents' } + gems: { type: 'integer', description: 'Number of gems awarded' } + } +} + +c.extendBasicProperties ProductSchema, 'Product' diff --git a/app/templates/account/prepaid-view.jade b/app/templates/account/prepaid-view.jade index fc6069300..d10c0f9ad 100644 --- a/app/templates/account/prepaid-view.jade +++ b/app/templates/account/prepaid-view.jade @@ -39,7 +39,7 @@ block content label.control-label.col-md-2(data-i18n="account_prepaid.purchase_total") .col-md-10 p.form-control-static $ - span#total #{purchase.total.toFixed(2)} + span#total #{(purchase.total/100).toFixed(2)} button#purchase-btn.btn.btn-success.pull-right(data-i18n="account_prepaid.purchase_button") .row .col-md-12 @@ -66,7 +66,7 @@ block content button#redeem-code-btn.btn.btn-success(data-i18n="account_prepaid.apply_account") .row .col-md-12 - .panel.panel-default + #codes-panel.panel.panel-default .panel-heading .panel-title a(data-toggle="collapse" href="#codeslist") diff --git a/app/templates/core/subscribe-modal.jade b/app/templates/core/subscribe-modal.jade index d2be043b0..b24aa4b36 100644 --- a/app/templates/core/subscribe-modal.jade +++ b/app/templates/core/subscribe-modal.jade @@ -24,8 +24,11 @@ th.free-cell(data-i18n="subscribe.free") th //- TODO: find a better way to localize '$9.99/month' - span $#{(view.product.amount / 100)}/ - span(data-i18n="subscribe.month") + if view.basicProduct + span $#{(view.basicProduct.get('amount') / 100)}/ + span(data-i18n="subscribe.month") + else + span '...' tbody tr td.feature-description diff --git a/app/templates/courses/purchase-courses-view.jade b/app/templates/courses/purchase-courses-view.jade index 07dab6137..3bba3b638 100644 --- a/app/templates/courses/purchase-courses-view.jade +++ b/app/templates/courses/purchase-courses-view.jade @@ -82,7 +82,7 @@ block content span(data-i18n="account_prepaid.purchase_total") span.spr : #{view.numberOfStudents} span(data-i18n="courses.enrollments") - span.spl x $#{view.pricePerStudent.toFixed(2)} = #{view.getPriceString()} + span.spl x $#{(view.pricePerStudent/100).toFixed(2)} = #{view.getPriceString()} p.text-center button#purchase-btn.btn.btn-lg.btn-success.uppercase(data-i18n="courses.purchase_now") diff --git a/app/templates/play/modal/buy-gems-modal.jade b/app/templates/play/modal/buy-gems-modal.jade index 340d21cb8..157b6dfb9 100644 --- a/app/templates/play/modal/buy-gems-modal.jade +++ b/app/templates/play/modal/buy-gems-modal.jade @@ -1,10 +1,10 @@ .modal-dialog .modal-content - if state === 'purchasing' + if view.state === 'purchasing' .alert.alert-info(data-i18n="buy_gems.purchasing") - else if state === 'retrying' + else if view.state === 'retrying' #retrying-alert.alert.alert-danger(data-i18n="buy_gems.retrying") else @@ -12,12 +12,12 @@ h1(data-i18n="play.buy_gems") #products - for product in products + for product in view.products.models .product - h4 x#{product.gems} - h3(data-i18n=product.i18n) - button.btn.btn-illustrated.btn-lg(value=product.id) - span= product.price + h4 x#{product.get('gems')} + h3(data-i18n=product.get('i18n')) + button.btn.btn-illustrated.btn-lg(value=product.get('name')) + span= product.get('priceString') .product h4(data-i18n="buy_gems.price") x3500 / mo @@ -29,20 +29,20 @@ else button.start-subscription-button.btn.btn-lg.btn-illustrated.btn-success(data-i18n="subscribe.subscribe_title") Subscribe - if state === 'declined' + if view.state === 'declined' #declined-alert.alert.alert-danger.alert-dismissible span(data-i18n="buy_gems.declined") button.close(type="button" data-dismiss="alert") span(aria-hidden="true") × - if state === 'unknown_error' + if view.state === 'unknown_error' #error-alert.alert.alert-danger.alert-dismissible button.close(type="button" data-dismiss="alert") span(aria-hidden="true") × p(data-i18n="loading_error.unknown") - p= stateMessage + p= view.stateMessage - if state === 'recovered_charge' + if view.state === 'recovered_charge' #recovered-alert.alert.alert-danger.alert-dismissible span(data-i18n="buy_gems.recovered") button.close(type="button" data-dismiss="alert") diff --git a/app/views/account/PrepaidView.coffee b/app/views/account/PrepaidView.coffee index 7a9e3a9b0..dcc503010 100644 --- a/app/views/account/PrepaidView.coffee +++ b/app/views/account/PrepaidView.coffee @@ -7,6 +7,7 @@ Prepaid = require '../../models/Prepaid' utils = require 'core/utils' RedeemModal = require 'views/account/PrepaidRedeemModal' forms = require 'core/forms' +Products = require 'collections/Products' # TODO: remove redeem code modal @@ -26,12 +27,10 @@ module.exports = class PrepaidView extends RootView subscriptions: 'stripe:received-token': 'onStripeReceivedToken' - baseAmount: 9.99 - constructor: (options) -> super(options) @purchase = - total: @baseAmount + total: 0 users: 3 months: 3 @updateTotal() @@ -45,6 +44,14 @@ module.exports = class PrepaidView extends RootView @ppcQuery = true @loadPrepaid(@ppc) + @products = new Products() + @supermodel.loadCollection(@products, 'products') + + onLoaded: -> + @prepaidProduct = @products.findWhere { name: 'prepaid_subscription' } + @updateTotal() + super() + getRenderData: -> c = super() c.purchase = @purchase @@ -62,7 +69,8 @@ module.exports = class PrepaidView extends RootView noty text: message, layout: 'topCenter', type: type, killer: false, timeout: 5000, dismissQueue: true, maxVisible: 3 updateTotal: -> - @purchase.total = getPrepaidCodeAmount(@baseAmount, @purchase.users, @purchase.months) + return unless @prepaidProduct + @purchase.total = getPrepaidCodeAmount(@prepaidProduct.get('amount'), @purchase.users, @purchase.months) @renderSelectors("#total", "#users-input", "#months-input") # Form Input Callbacks @@ -99,7 +107,7 @@ module.exports = class PrepaidView extends RootView onClickPurchaseButton: (e) -> return unless $("#users-input").val() >= 3 or $("#months-input").val() >= 3 @purchaseTimestamp = new Date().getTime() - @stripeAmount = @purchase.total * 100 + @stripeAmount = @purchase.total @description = "Prepaid Code for " + @purchase.users + " users / " + @purchase.months + " months" stripeHandler.open @@ -179,6 +187,7 @@ module.exports = class PrepaidView extends RootView # console.log 'SUCCESS: Prepaid purchase', model.code @statusMessage "Successfully purchased Prepaid Code!", "success" @codes.add(model) + @renderSelectors('#codes-panel') @statusMessage "Finalizing purchase...", "information" @supermodel.addRequestResource('purchase_prepaid', options, 0).load() diff --git a/app/views/core/SubscribeModal.coffee b/app/views/core/SubscribeModal.coffee index a978ec0d6..d3f1a1112 100644 --- a/app/views/core/SubscribeModal.coffee +++ b/app/views/core/SubscribeModal.coffee @@ -3,16 +3,14 @@ template = require 'templates/core/subscribe-modal' stripeHandler = require 'core/services/stripe' utils = require 'core/utils' AuthModal = require 'views/core/AuthModal' +Products = require 'collections/Products' module.exports = class SubscribeModal extends ModalView id: 'subscribe-modal' template: template plain: true closesOnClickOutside: false - product: - amount: 999 - planID: 'basic' - yearAmount: 9900 + planID: 'basic' subscriptions: 'stripe:received-token': 'onStripeReceivedToken' @@ -27,6 +25,13 @@ module.exports = class SubscribeModal extends ModalView constructor: (options) -> super(options) @state = 'standby' + @products = new Products() + @supermodel.loadCollection(@products, 'products') + + onLoaded: -> + @basicProduct = @products.findWhere { name: 'basic_subscription' } + @yearProduct = @products.findWhere { name: 'year_subscription' } + super() afterRender: -> super() @@ -109,12 +114,13 @@ module.exports = class SubscribeModal extends ModalView @$el.find('.parent-button').popover('hide') onClickPurchaseButton: (e) -> + return unless @basicProduct and @yearProduct @playSound 'menu-button-click' return @openModalView new AuthModal() if me.get('anonymous') application.tracker?.trackEvent 'Started subscription purchase' options = { description: $.i18n.t('subscribe.stripe_description') - amount: @product.amount + amount: @basicProduct.get('amount') alipay: if me.get('country') is 'china' or (me.get('preferredLanguage') or 'en-US')[...2] is 'zh' then true else 'auto' alipayReusable: true } @@ -138,7 +144,7 @@ module.exports = class SubscribeModal extends ModalView application.tracker?.trackEvent 'Started 1 year subscription purchase' options = description: $.i18n.t('subscribe.stripe_description_year_sale') - amount: @product.yearAmount + amount: @yearProduct.get('amount') alipay: if me.get('country') is 'china' or (me.get('preferredLanguage') or 'en-US')[...2] is 'zh' then true else 'auto' alipayReusable: true @purchasedAmount = options.amount @@ -148,15 +154,15 @@ module.exports = class SubscribeModal extends ModalView @state = 'purchasing' @render() - if @purchasedAmount is @product.amount + if @purchasedAmount is @basicProduct.get('amount') stripe = _.clone(me.get('stripe') ? {}) - stripe.planID = @product.planID + stripe.planID = @basicProduct.get('planID') stripe.token = e.token.id me.set 'stripe', stripe @listenToOnce me, 'sync', @onSubscriptionSuccess @listenToOnce me, 'error', @onSubscriptionError me.patch({headers: {'X-Change-Plan': 'true'}}) - else if @purchasedAmount is @product.yearAmount + else if @purchasedAmount is @yearProduct.get('amount') # Purchasing a year data = stripe: diff --git a/app/views/courses/PurchaseCoursesView.coffee b/app/views/courses/PurchaseCoursesView.coffee index 9c1aae564..a97c32cd9 100644 --- a/app/views/courses/PurchaseCoursesView.coffee +++ b/app/views/courses/PurchaseCoursesView.coffee @@ -9,12 +9,13 @@ stripeHandler = require 'core/services/stripe' template = require 'templates/courses/purchase-courses-view' User = require 'models/User' utils = require 'core/utils' +Products = require 'collections/Products' module.exports = class PurchaseCoursesView extends RootView id: 'purchase-courses-view' template: template numberOfStudents: 30 - pricePerStudent: 4 + pricePerStudent: 0 initialize: (options) -> @listenTo stripeHandler, 'received-token', @onStripeReceivedToken @@ -29,13 +30,19 @@ module.exports = class PurchaseCoursesView extends RootView @prepaids.comparator = '_id' @prepaids.fetchByCreator(me.id) @supermodel.loadCollection(@prepaids, 'prepaids') + @products = new Products() + @supermodel.loadCollection(@products, 'products') super(options) events: 'input #students-input': 'onInputStudentsInput' 'click #purchase-btn': 'onClickPurchaseButton' + + onLoaded: -> + @pricePerStudent = @products.findWhere({name: 'course'}).get('amount') + super() - getPriceString: -> '$' + (@getPrice()).toFixed(2) + getPriceString: -> '$' + (@getPrice()/100).toFixed(2) getPrice: -> @pricePerStudent * @numberOfStudents onceClassroomsSync: -> @@ -80,7 +87,7 @@ module.exports = class PurchaseCoursesView extends RootView application.tracker?.trackEvent 'Started course prepaid purchase', { price: @pricePerStudent, students: @numberOfStudents} stripeHandler.open - amount: @numberOfStudents * @pricePerStudent * 100 + amount: @numberOfStudents * @pricePerStudent description: "Full course access for #{@numberOfStudents} students" bitcoin: true alipay: if me.get('country') is 'china' or (me.get('preferredLanguage') or 'en-US')[...2] is 'zh' then true else 'auto' diff --git a/app/views/play/modal/BuyGemsModal.coffee b/app/views/play/modal/BuyGemsModal.coffee index 9840de7cd..1b4b2da51 100644 --- a/app/views/play/modal/BuyGemsModal.coffee +++ b/app/views/play/modal/BuyGemsModal.coffee @@ -3,6 +3,7 @@ template = require 'templates/play/modal/buy-gems-modal' stripeHandler = require 'core/services/stripe' utils = require 'core/utils' SubscribeModal = require 'views/core/SubscribeModal' +Products = require 'collections/Products' module.exports = class BuyGemsModal extends ModalView id: 'buy-gems-modal' @@ -29,22 +30,21 @@ module.exports = class BuyGemsModal extends ModalView super(options) @timestampForPurchase = new Date().getTime() @state = 'standby' + @products = new Products() + @products.comparator = 'amount' if application.isIPadApp @products = [] Backbone.Mediator.publish 'buy-gems-modal:update-products' else - @products = @originalProducts + @supermodel.loadCollection(@products, 'products') $.post '/db/payment/check-stripe-charges', (something, somethingElse, jqxhr) => if jqxhr.status is 201 @state = 'recovered_charge' @render() - getRenderData: -> - c = super() - c.products = @products - c.state = @state - c.stateMessage = @stateMessage - return c + onLoaded: -> + @products.reset @products.filter (product) -> _.string.startsWith(product.get('name'), 'gems_') + super() afterRender: -> super() @@ -56,19 +56,20 @@ module.exports = class BuyGemsModal extends ModalView @playSound 'game-menu-close' onIPadProducts: (e) -> - newProducts = [] - for iapProduct in e.products - localProduct = _.find @originalProducts, { id: iapProduct.id } - continue unless localProduct - localProduct.price = iapProduct.price - newProducts.push localProduct - @products = _.sortBy newProducts, 'gems' - @render() + # TODO: Update to handle new products collection +# newProducts = [] +# for iapProduct in e.products +# localProduct = _.find @originalProducts, { id: iapProduct.id } +# continue unless localProduct +# localProduct.price = iapProduct.price +# newProducts.push localProduct +# @products = _.sortBy newProducts, 'gems' +# @render() onClickProductButton: (e) -> @playSound 'menu-button-click' productID = $(e.target).closest('button').val() - product = _.find @products, { id: productID } + product = @products.findWhere { name: productID } if application.isIPadApp Backbone.Mediator.publish 'buy-gems-modal:purchase-initiated', { productID: productID } @@ -76,8 +77,8 @@ module.exports = class BuyGemsModal extends ModalView else application.tracker?.trackEvent 'Started gem purchase', { productID: productID } stripeHandler.open({ - description: $.t(product.i18n) - amount: product.amount + description: $.t(product.get('i18n')) + amount: product.get('amount') bitcoin: true alipay: if me.get('country') is 'china' or (me.get('preferredLanguage') or 'en-US')[...2] is 'zh' then true else 'auto' }) @@ -86,7 +87,7 @@ module.exports = class BuyGemsModal extends ModalView onStripeReceivedToken: (e) -> data = { - productID: @productBeingPurchased.id + productID: @productBeingPurchased.get('name') stripe: { token: e.token.id timestamp: @timestampForPurchase @@ -97,8 +98,8 @@ module.exports = class BuyGemsModal extends ModalView jqxhr = $.post('/db/payment', data) jqxhr.done(=> application.tracker?.trackEvent 'Finished gem purchase', - productID: @productBeingPurchased.id - value: @productBeingPurchased.amount + productID: @productBeingPurchased.get('name') + value: @productBeingPurchased.get('amount') document.location.reload() ) jqxhr.fail(=> @@ -116,7 +117,7 @@ module.exports = class BuyGemsModal extends ModalView ) onIAPComplete: (e) -> - product = _.find @products, { id: e.productID } + product = @products.findWhere { name: e.productID } purchased = me.get('purchased') ? {} purchased = _.clone purchased purchased.gems ?= 0 diff --git a/server/models/Product.coffee b/server/models/Product.coffee new file mode 100644 index 000000000..25da01e7d --- /dev/null +++ b/server/models/Product.coffee @@ -0,0 +1,5 @@ +mongoose = require('mongoose') +config = require '../../server_config' +ProductSchema = new mongoose.Schema({}, {strict: false,read:config.mongo.readpref}) + +module.exports = mongoose.model('product', ProductSchema) diff --git a/server/payments/payment_handler.coffee b/server/payments/payment_handler.coffee index a70f0c630..fd5da1677 100644 --- a/server/payments/payment_handler.coffee +++ b/server/payments/payment_handler.coffee @@ -1,4 +1,5 @@ Payment = require './Payment' +Product = require '../models/Product' User = require '../users/User' Handler = require '../commons/Handler' {handlers} = require '../commons/mapping' @@ -11,30 +12,6 @@ request = require 'request' async = require 'async' apple_utils = require '../lib/apple_utils' -products = { - 'gems_5': { - amount: 499 - gems: 5000 - id: 'gems_5' - } - - 'gems_10': { - amount: 999 - gems: 11000 - id: 'gems_10' - } - - 'gems_20': { - amount: 1999 - gems: 25000 - id: 'gems_20' - } - - 'custom': { - # amount expected in request body - id: 'custom' - } -} PaymentHandler = class PaymentHandler extends Handler modelClass: Payment @@ -134,33 +111,34 @@ PaymentHandler = class PaymentHandler extends Handler payment = @makeNewInstance(req) payment.set 'service', 'ios' - product = products[transaction.product_id] - - payment.set 'amount', product.amount - payment.set 'gems', product.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.gems, (err) => + 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 incr db error.'+err) + @logPaymentError(req, 'Apple payment save error.'+err) return @sendDatabaseError(res, err) - @sendPaymentHipChatMessage user: req.user, payment: payment - @sendCreated(res, @formatEntity(req, payment)) + @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)) + ) ) - ) ) ) @@ -203,7 +181,6 @@ PaymentHandler = class PaymentHandler extends Handler beginStripePayment: (req, res, timestamp, productID) -> - product = products[productID] async.parallel([ ((callback) -> @@ -218,6 +195,10 @@ PaymentHandler = class PaymentHandler extends Handler charge = _.find recentCharges.data, (c) -> c.metadata.timestamp is timestamp callback(null, charge) ) + ), + ((callback) -> + Product.findOne({name: productID}).exec (err, product) => + callback(err, product) ) ], @@ -225,7 +206,10 @@ PaymentHandler = class PaymentHandler extends Handler if err @logPaymentError(req, 'Stripe async load db error. '+err) return @sendDatabaseError(res, err) - [payment, charge] = results + [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 @@ -236,7 +220,7 @@ PaymentHandler = class PaymentHandler extends Handler @recordStripeCharge(req, res, charge) else - return @sendSuccess(res, @formatEntity(req, payment)) if product.id is 'custom' + 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) => @@ -250,7 +234,7 @@ PaymentHandler = class PaymentHandler extends Handler ) chargeStripe: (req, res, product) -> - amount = parseInt product.amount ? req.body.amount + amount = parseInt product.get('amount') ? req.body.amount return @sendError(res, 400, "Invalid amount.") if isNaN(amount) stripe.charges.create({ @@ -258,9 +242,9 @@ PaymentHandler = class PaymentHandler extends Handler currency: 'usd' customer: req.user.get('stripe')?.customerID metadata: { - productID: product.id + productID: product.get('name') userID: req.user._id + '' - gems: product.gems + gems: product.get('gems') timestamp: parseInt(req.body.stripe?.timestamp) description: req.body.description } diff --git a/server/payments/subscription_handler.coffee b/server/payments/subscription_handler.coffee index 321010903..e4cb53d88 100644 --- a/server/payments/subscription_handler.coffee +++ b/server/payments/subscription_handler.coffee @@ -15,20 +15,10 @@ User = require '../users/User' {getSponsoredSubsAmount} = require '../../app/core/utils' StripeUtils = require '../lib/stripe_utils' moment = require 'moment' +Product = require '../models/Product' recipientCouponID = 'free' -# TODO: rename this to avoid collisions with 'subscriptions' variables -subscriptions = { - basic: { - gems: 3500 - amount: 999 # For calculating incremental quantity before sub creation - } - year_sale: { - amount: 9900 - } -} - class SubscriptionHandler extends Handler logSubscriptionError: (user, msg) -> console.warn "Subscription Error: #{user.get('slug')} (#{user._id}): '#{msg}'" @@ -149,51 +139,56 @@ class SubscriptionHandler extends Handler if err @logSubscriptionError(user, "Purchase year sale Stripe cancel subscription error: #{JSON.stringify(err)}") return @sendDatabaseError(res, err) - metadata = - type: req.body.type - userID: req.user._id + '' - gems: subscriptions.basic.gems * 12 - timestamp: parseInt(req.body.stripe?.timestamp) - description: req.body.description - - StripeUtils.createCharge req.user, subscriptions.year_sale.amount, metadata, (err, charge) => - if err - @logSubscriptionError(req.user, "Purchase year sale create charge: #{JSON.stringify(err)}") - return @sendDatabaseError(res, err) - - StripeUtils.createPayment req.user, charge, (err, payment) => + + Product.findOne({name: 'year_subscription'}).exec (err, product) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'year_subscription product not found') if not product + + metadata = + type: req.body.type + userID: req.user._id + '' + gems: product.get('gems') + timestamp: parseInt(req.body.stripe?.timestamp) + description: req.body.description + + StripeUtils.createCharge req.user, product.get('amount'), metadata, (err, charge) => if err - @logSubscriptionError(req.user, "Purchase year sale create payment: #{JSON.stringify(err)}") + @logSubscriptionError(req.user, "Purchase year sale create charge: #{JSON.stringify(err)}") return @sendDatabaseError(res, err) - - # Add terminal subscription to User with extensions for existing subscriptions - stripeInfo = _.cloneDeep(req.user.get('stripe') ? {}) - endDate = new Date() - if stripeSubscriptionPeriodEndDate - endDate = stripeSubscriptionPeriodEndDate - else if _.isString(stripeInfo.free) and new Date() < new Date(stripeInfo.free) - endDate = new Date(stripeInfo.free) - endDate.setUTCFullYear(endDate.getUTCFullYear() + 1) - stripeInfo.free = endDate.toISOString().substring(0, 10) - req.user.set('stripe', stripeInfo) - - # Add year's worth of gems to User - purchased = _.clone(req.user.get('purchased')) - purchased ?= {} - purchased.gems ?= 0 - purchased.gems += parseInt(charge.metadata.gems) - req.user.set('purchased', purchased) - - req.user.save (err, user) => + + StripeUtils.createPayment req.user, charge, (err, payment) => if err - @logSubscriptionError(req.user, "User save error: #{JSON.stringify(err)}") + @logSubscriptionError(req.user, "Purchase year sale create payment: #{JSON.stringify(err)}") return @sendDatabaseError(res, err) - try - msg = "Year subscription purchased by #{req.user.get('email')} #{req.user.id}" - hipchat.sendHipChatMessage msg, ['tower'] - catch error - @logSubscriptionError(req.user, "Year sub sale HipChat tower msg error: #{JSON.stringify(error)}") - @sendSuccess(res, user) + + # Add terminal subscription to User with extensions for existing subscriptions + stripeInfo = _.cloneDeep(req.user.get('stripe') ? {}) + endDate = new Date() + if stripeSubscriptionPeriodEndDate + endDate = stripeSubscriptionPeriodEndDate + else if _.isString(stripeInfo.free) and new Date() < new Date(stripeInfo.free) + endDate = new Date(stripeInfo.free) + endDate.setUTCFullYear(endDate.getUTCFullYear() + 1) + stripeInfo.free = endDate.toISOString().substring(0, 10) + req.user.set('stripe', stripeInfo) + + # Add year's worth of gems to User + purchased = _.clone(req.user.get('purchased')) + purchased ?= {} + purchased.gems ?= 0 + purchased.gems += parseInt(charge.metadata.gems) + req.user.set('purchased', purchased) + + req.user.save (err, user) => + if err + @logSubscriptionError(req.user, "User save error: #{JSON.stringify(err)}") + return @sendDatabaseError(res, err) + try + msg = "Year subscription purchased by #{req.user.get('email')} #{req.user.id}" + hipchat.sendHipChatMessage msg, ['tower'] + catch error + @logSubscriptionError(req.user, "Year sub sale HipChat tower msg error: #{JSON.stringify(error)}") + @sendSuccess(res, user) subscribeWithPrepaidCode: (req, res) -> return @sendUnauthorizedError(res) unless req.user? @@ -241,31 +236,35 @@ class SubscriptionHandler extends Handler @logSubscriptionError(user, "Redeem Prepaid Code Stripe cancel subscription error: #{JSON.stringify(err)}") return @sendDatabaseError(res, err) - # Add terminal subscription to User, extending existing subscriptions - # TODO: refactor this into some form useable by both this and purchaseYearSale - stripeInfo = _.cloneDeep(req.user.get('stripe') ? {}) - endDate = new moment() - if stripeSubscriptionPeriodEndDate - endDate = new moment(stripeSubscriptionPeriodEndDate) - else if _.isString(stripeInfo.free) and new moment().isBefore(new moment(stripeInfo.free)) - endDate = new moment(stripeInfo.free) + Product.findOne({name: 'basic_subscription'}).exec (err, product) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'basic_subscription product not found') if not product - endDate = endDate.add(months, 'months') - stripeInfo.free = endDate.toISOString().substring(0, 10) - req.user.set('stripe', stripeInfo) - - # Add gems to User - purchased = _.clone(req.user.get('purchased')) - purchased ?= {} - purchased.gems ?= 0 - purchased.gems += subscriptions.basic.gems * months - req.user.set('purchased', purchased) - - req.user.save (err, user) => - if err - @logSubscriptionError(req.user, "User save error: #{JSON.stringify(err)}") - return @sendDatabaseError(res, err) - @sendSuccess(res, user) + # Add terminal subscription to User, extending existing subscriptions + # TODO: refactor this into some form useable by both this and purchaseYearSale + stripeInfo = _.cloneDeep(req.user.get('stripe') ? {}) + endDate = new moment() + if stripeSubscriptionPeriodEndDate + endDate = new moment(stripeSubscriptionPeriodEndDate) + else if _.isString(stripeInfo.free) and new moment().isBefore(new moment(stripeInfo.free)) + endDate = new moment(stripeInfo.free) + + endDate = endDate.add(months, 'months') + stripeInfo.free = endDate.toISOString().substring(0, 10) + req.user.set('stripe', stripeInfo) + + # Add gems to User + purchased = _.clone(req.user.get('purchased')) + purchased ?= {} + purchased.gems ?= 0 + purchased.gems += product.get('gems') * months + req.user.set('purchased', purchased) + + req.user.save (err, user) => + if err + @logSubscriptionError(req.user, "User save error: #{JSON.stringify(err)}") + return @sendDatabaseError(res, err) + @sendSuccess(res, user) subscribeUser: (req, user, done) -> if (not req.user) or req.user.isAnonymous() or user.isAnonymous() @@ -427,18 +426,22 @@ class SubscriptionHandler extends Handler req.body.stripe = stripeInfo user.set('stripe', stripeInfo) - if increment - purchased = _.clone(user.get('purchased')) - purchased ?= {} - purchased.gems ?= 0 - purchased.gems += subscriptions.basic.gems # TODO: Put actual subscription amount here - user.set('purchased', purchased) + Product.findOne({name: 'basic_subscription'}).exec (err, product) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'basic_subscription product not found') if not product - user.save (err) => - if err - @logSubscriptionError(user, 'Stripe user plan saving error. ' + err) - return done({res: 'Database error.', code: 500}) - done() + if increment + purchased = _.clone(user.get('purchased')) + purchased ?= {} + purchased.gems ?= 0 + purchased.gems += product.get('gems') # TODO: Put actual subscription amount here + user.set('purchased', purchased) + + user.save (err) => + if err + @logSubscriptionError(user, 'Stripe user plan saving error. ' + err) + return done({res: 'Database error.', code: 500}) + done() updateStripeRecipientSubscriptions: (req, user, customer, done) -> return done({res: 'Database error.', code: 500}) unless req.body.stripe?.subscribeEmails? @@ -527,36 +530,40 @@ class SubscriptionHandler extends Handler @logSubscriptionError(user, 'User saving stripe error. ' + err) return done({res: 'Database error.', code: 500}) - createUpdateFn = (recipient, increment) => - (done) => - # Update recipient - stripeInfo = _.cloneDeep(recipient.get('stripe') ? {}) - stripeInfo.sponsorID = user.id - recipient.set 'stripe', stripeInfo - if increment - purchased = _.clone(recipient.get('purchased')) - purchased ?= {} - purchased.gems ?= 0 - purchased.gems += subscriptions.basic.gems - recipient.set('purchased', purchased) - recipient.save (err) => - if err - @logSubscriptionError(user, 'Stripe user saving stripe error. ' + err) - return done({res: 'Database error.', code: 500}) - done() + Product.findOne({name: 'basic_subscription'}).exec (err, product) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'basic_subscription product not found') if not product - tasks = [] - for sub in stripeRecipients - tasks.push createUpdateFn(sub.recipient, sub.increment) + createUpdateFn = (recipient, increment) => + (done) => + # Update recipient + stripeInfo = _.cloneDeep(recipient.get('stripe') ? {}) + stripeInfo.sponsorID = user.id + recipient.set 'stripe', stripeInfo + if increment + purchased = _.clone(recipient.get('purchased')) + purchased ?= {} + purchased.gems ?= 0 + purchased.gems += product.get('gems') + recipient.set('purchased', purchased) + recipient.save (err) => + if err + @logSubscriptionError(user, 'Stripe user saving stripe error. ' + err) + return done({res: 'Database error.', code: 500}) + done() + + tasks = [] + for sub in stripeRecipients + tasks.push createUpdateFn(sub.recipient, sub.increment) + + async.parallel tasks, (err, results) => + return done(err) if err + @updateStripeSponsorSubscription(req, user, customer, product, done) - async.parallel tasks, (err, results) => - return done(err) if err - @updateStripeSponsorSubscription(req, user, customer, done) - - updateStripeSponsorSubscription: (req, user, customer, done) -> + updateStripeSponsorSubscription: (req, user, customer, product, done) -> stripeInfo = user.get('stripe') ? {} numSponsored = stripeInfo.recipients.length - quantity = getSponsoredSubsAmount(subscriptions.basic.amount, numSponsored, stripeInfo.subscriptionID?) + quantity = getSponsoredSubsAmount(product.get('amount'), numSponsored, stripeInfo.subscriptionID?) findStripeSubscription customer.id, subscriptionID: stripeInfo.sponsorSubscriptionID, (subscription) => if stripeInfo.sponsorSubscriptionID? and not subscription? @@ -656,38 +663,42 @@ class SubscriptionHandler extends Handler @logSubscriptionError(user, 'Unable to find recipient subscription. ') return done({res: 'Database error.', code: 500}) - # Update recipient user - deleteUserStripeProp(recipient, 'sponsorID') - recipient.save (err) => - if err - @logSubscriptionError(user, 'Recipient user save unsubscribe error. ' + err) - return done({res: 'Database error.', code: 500}) + Product.findOne({name: 'basic_subscription'}).exec (err, product) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'basic_subscription product not found') if not product - # Cancel Stripe subscription - stripe.customers.cancelSubscription stripeInfo.customerID, sponsoredEntry.subscriptionID, (err) => + # Update recipient user + deleteUserStripeProp(recipient, 'sponsorID') + recipient.save (err) => if err - @logSubscriptionError(user, "Stripe cancel sponsored subscription failed. " + err) + @logSubscriptionError(user, 'Recipient user save unsubscribe error. ' + err) return done({res: 'Database error.', code: 500}) - - # Update sponsor user - _.remove(stripeInfo.recipients, (s) -> s.userID is recipient.id) - delete stripeInfo.unsubscribeEmail - user.set('stripe', stripeInfo) - req.body.stripe = stripeInfo - user.save (err) => + + # Cancel Stripe subscription + stripe.customers.cancelSubscription stripeInfo.customerID, sponsoredEntry.subscriptionID, (err) => if err - @logSubscriptionError(user, 'Sponsor user save unsubscribe error. ' + err) + @logSubscriptionError(user, "Stripe cancel sponsored subscription failed. " + err) return done({res: 'Database error.', code: 500}) - - return done() unless stripeInfo.sponsorSubscriptionID? - - # Update sponsored subscription quantity - options = - quantity: getSponsoredSubsAmount(subscriptions.basic.amount, stripeInfo.recipients.length, stripeInfo.subscriptionID?) - stripe.customers.updateSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, options, (err, subscription) => + + # Update sponsor user + _.remove(stripeInfo.recipients, (s) -> s.userID is recipient.id) + delete stripeInfo.unsubscribeEmail + user.set('stripe', stripeInfo) + req.body.stripe = stripeInfo + user.save (err) => if err - @logSubscriptionError(user, 'Sponsored subscription quantity update error. ' + JSON.stringify(err)) + @logSubscriptionError(user, 'Sponsor user save unsubscribe error. ' + err) return done({res: 'Database error.', code: 500}) - done() + + return done() unless stripeInfo.sponsorSubscriptionID? + + # Update sponsored subscription quantity + options = + quantity: getSponsoredSubsAmount(product.get('amount'), stripeInfo.recipients.length, stripeInfo.subscriptionID?) + stripe.customers.updateSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, options, (err, subscription) => + if err + @logSubscriptionError(user, 'Sponsored subscription quantity update error. ' + JSON.stringify(err)) + return done({res: 'Database error.', code: 500}) + done() module.exports = new SubscriptionHandler() diff --git a/server/prepaids/prepaid_handler.coffee b/server/prepaids/prepaid_handler.coffee index 0b5e8e984..60dceca68 100644 --- a/server/prepaids/prepaid_handler.coffee +++ b/server/prepaids/prepaid_handler.coffee @@ -6,6 +6,7 @@ User = require '../users/User' StripeUtils = require '../lib/stripe_utils' utils = require '../../app/core/utils' mongoose = require 'mongoose' +Product = require '../models/Product' # TODO: Should this happen on a save() call instead of a prepaid/-/create post? # TODO: Probably a better way to create a unique 8 charactor string property using db voodoo @@ -17,8 +18,6 @@ PrepaidHandler = class PrepaidHandler extends Handler jsonSchema: require '../../app/schemas/models/prepaid.schema' allowedMethods: ['GET','POST'] - baseAmount: 999 - logError: (user, msg) -> console.warn "Prepaid Error: [#{user.get('slug')} (#{user._id})] '#{msg}'" @@ -134,9 +133,13 @@ PrepaidHandler = class PrepaidHandler extends Handler return @sendBadInputError(res) unless isNaN(months) is false and months > 0 return @sendError(res, 403, "Users or Months must be greater than 3") if maxRedeemers < 3 and months < 3 - @purchasePrepaidTerminalSubscription req.user, description, maxRedeemers, months, timestamp, token, (err, prepaid) => + Product.findOne({name: 'prepaid_subscription'}).exec (err, product) => return @sendDatabaseError(res, err) if err - @sendSuccess(res, prepaid.toObject()) + return @sendNotFoundError(res, 'prepaid_subscription product not found') if not product + + @purchasePrepaidTerminalSubscription req.user, description, maxRedeemers, months, timestamp, token, product, (err, prepaid) => + return @sendDatabaseError(res, err) if err + @sendSuccess(res, prepaid.toObject()) else if req.body.type is 'course' maxRedeemers = parseInt(req.body.maxRedeemers) @@ -145,18 +148,22 @@ PrepaidHandler = class PrepaidHandler extends Handler return @sendBadInputError(res) unless isNaN(maxRedeemers) is false and maxRedeemers > 0 - @purchasePrepaidCourse req.user, maxRedeemers, timestamp, token, (err, prepaid) => - # TODO: this badinput detection is fragile, in course instance handler as well - return @sendBadInputError(res, err) if err is 'Missing required Stripe token' + Product.findOne({name: 'course'}).exec (err, product) => return @sendDatabaseError(res, err) if err - @sendSuccess(res, prepaid.toObject()) + return @sendNotFoundError(res, 'course product not found') if not product + + @purchasePrepaidCourse req.user, maxRedeemers, timestamp, token, product, (err, prepaid) => + # TODO: this badinput detection is fragile, in course instance handler as well + return @sendBadInputError(res, err) if err is 'Missing required Stripe token' + return @sendDatabaseError(res, err) if err + @sendSuccess(res, prepaid.toObject()) else @sendForbiddenError(res) - purchasePrepaidCourse: (user, maxRedeemers, timestamp, token, done) -> + purchasePrepaidCourse: (user, maxRedeemers, timestamp, token, product, done) -> type = 'course' - amount = maxRedeemers * 400 + amount = maxRedeemers * product.get('amount') if amount > 0 and not (token or user.isAdmin()) @logError(user, "Purchase prepaid courses missing required Stripe token #{amount}") return done('Missing required Stripe token') @@ -190,7 +197,7 @@ PrepaidHandler = class PrepaidHandler extends Handler hipchat.sendHipChatMessage msg, ['tower'] @createPrepaid(user, type, maxRedeemers, {}, done) - purchasePrepaidTerminalSubscription: (user, description, maxRedeemers, months, timestamp, token, done) -> + purchasePrepaidTerminalSubscription: (user, description, maxRedeemers, months, timestamp, token, product, done) -> type = 'terminal_subscription' StripeUtils.getCustomer user, token, (err, customer) => @@ -207,7 +214,7 @@ PrepaidHandler = class PrepaidHandler extends Handler months: months productID: "prepaid #{type}" - amount = utils.getPrepaidCodeAmount(@baseAmount, maxRedeemers, months) + amount = utils.getPrepaidCodeAmount(product.get('amount'), maxRedeemers, months) StripeUtils.createCharge user, amount, metadata, (err, charge) => if err diff --git a/server/routes/index.coffee b/server/routes/index.coffee new file mode 100644 index 000000000..82dc00d49 --- /dev/null +++ b/server/routes/index.coffee @@ -0,0 +1,2 @@ +module.exports.setup = (app) -> + app.get('/db/products', require('./db/products').get) \ No newline at end of file diff --git a/server_setup.coffee b/server_setup.coffee index 230f7c56b..77d00967a 100644 --- a/server_setup.coffee +++ b/server_setup.coffee @@ -14,6 +14,7 @@ user = require './server/users/user_handler' logging = require './server/commons/logging' config = require './server_config' auth = require './server/routes/auth' +routes = require './server/routes' UserHandler = require './server/users/user_handler' hipchat = require './server/hipchat' global.tv4 = require 'tv4' # required for TreemaUtils to work @@ -166,6 +167,7 @@ setupFacebookCrossDomainCommunicationRoute = (app) -> res.sendfile path.join(__dirname, 'public', 'channel.html') exports.setupRoutes = (app) -> + routes.setup(app) app.use app.router baseRoute.setup app diff --git a/spec/helpers/helper.js b/spec/helpers/helper.js index 76664a21c..4ad741bb7 100644 --- a/spec/helpers/helper.js +++ b/spec/helpers/helper.js @@ -67,5 +67,14 @@ describe('Server Test Helper', function() { if (err) { console.log(err); } done(); }); + }); + + it('initializes products', function(done) { + var request = require('request'); + request.get(getURL('/db/products'), function(err, res, body) { + expect(err).toBe(null); + expect(res.statusCode).toBe(200); + done(); + }); }) }); \ No newline at end of file