Add subscription sale

Give a discount for purchasing a year
New sale button on subscribe modal
New subscription sale landing page
This commit is contained in:
Matt Lott 2015-08-21 11:10:06 -07:00
parent a7074b11ca
commit 9e222d0873
14 changed files with 385 additions and 19 deletions

View file

@ -24,6 +24,7 @@ module.exports = class CocoRouter extends Backbone.Router
'account/profile': go('EmployersView') # Show the not-recruiting-now screen
'account/payments': go('account/PaymentsView')
'account/subscription': go('account/SubscriptionView')
'account/subscription/sale': go('account/SubscriptionSaleView')
'account/invoices': go('account/InvoicesView')
'admin': go('admin/MainAdminView')

View file

@ -437,7 +437,18 @@
payment_methods_title: "Accepted Payment Methods"
payment_methods_blurb1: "We currently accept credit cards and Alipay."
payment_methods_blurb2: "If you require an alternate form of payment, please contact"
sale_already_subscribed: "You're already subscribed!"
sale_blurb1: "Save 35%"
sale_blurb2: "off regular subscription price!"
sale_button: "Sale!"
sale_button_title: "Save 35% when you purchase a 1 year subscription"
sale_click_here: "Click Here"
sale_continue: "Ready to continue adventuring?"
sale_paid: "Payment received. Thanks!"
sale_title: "Back to School Sale"
sale_view_button: "Buy 1 year subscription for"
stripe_description: "Monthly Subscription"
stripe_description_year_sale: "1 Year Subscription (35% discount)"
subscription_required_to_play: "You'll need a subscription to play this level."
unlock_help_videos: "Subscribe to unlock all video tutorials."
@ -446,7 +457,7 @@
managed_by: "Managed by"
will_be_cancelled: "Will be cancelled on"
currently_free: "You currently have a free subscription"
currently_free_until: "You currently have a free subscription until"
currently_free_until: "You currently have a subscription until" #{changed}
was_free_until: "You had a free subscription until"
managed_subs: "Managed Subscriptions"
managed_subs_desc: "Add subscriptions for other players (students, children, etc.)"
@ -1102,6 +1113,7 @@
no_recent_games: "No games played during the past two weeks."
payments: "Payments"
purchased: "Purchased"
sale: "Sale"
subscription: "Subscription"
invoices: "Invoices"
service_apple: "Apple"

View file

@ -137,14 +137,17 @@ module.exports = class User extends CocoModel
return 0 unless numVideos > 0
return me.get('testGroupNumber') % numVideos
isPremium: ->
return true if me.isInGodMode()
return true if me.isAdmin()
hasSubscription: ->
return false unless stripe = @get('stripe')
return true if stripe.sponsorID
return true if stripe.subscriptionID
return true if stripe.free is true
return true if _.isString(stripe.free) and new Date() < new Date(stripe.free)
isPremium: ->
return true if me.isInGodMode()
return true if me.isAdmin()
return true if me.hasSubscription()
return false
tiersByLevel = [-1, 0, 0.05, 0.14, 0.18, 0.32, 0.41, 0.5, 0.64, 0.82, 0.91, 1.04, 1.22, 1.35, 1.48, 1.65, 1.78, 1.96, 2.1, 2.24, 2.38, 2.55, 2.69, 2.86, 3.03, 3.16, 3.29, 3.42, 3.58, 3.74, 3.89, 4.04, 4.19, 4.32, 4.47, 4.64, 4.79, 4.96,

View file

@ -0,0 +1,7 @@
#subscription-sale-view
.center
text-align: center
.sale-blurb
font-size: 22px
#pay-button
font-size: 18px

View file

@ -152,7 +152,7 @@
.purchase-button
position: absolute
right: 24px
width: 400px
width: 300px
height: 70px
top: 430px
font-size: 32px
@ -190,6 +190,24 @@
border-width: 14px 20px 20px 20px
color: darken(white, 5%)
//- Sale button
.sale-button
position: absolute
left: 290px
width: 115px
height: 70px
top: 430px
font-size: 32px
line-height: 42px
border-style: solid
border-image: url(/images/common/button-background-primary-active.png) 14 20 20 20 fill round
border-width: 14px 20px 20px 20px
color: darken(white, 5%)
span
pointer-events: none
.email-parent-form
.email_invalid
color: red

View file

@ -0,0 +1,54 @@
extends /templates/base
block content
ol.breadcrumb
li
a(href="/")
span.glyphicon.glyphicon-home
li
a(href="/account", data-i18n="nav.account")
li
a(href="/account/subscription", data-i18n="account.subscription")
li.active(data-i18n="account.sale") Sale
if me.get('anonymous')
p(data-i18n="account_settings.not_logged_in")
else if me.hasSubscription()
h1(data-i18n="subscribe.sale_already_subscribed")
span.spr(data-i18n="subscribe.sale_continue")
a(href="/play", data-i18n="subscribe.sale_click_here")
else
if state === 'purchasing'
.alert.alert-info(data-i18n="account_invoices.purchasing")
else
h1.center(data-i18n="subscribe.sale_title")
br
p.center.sale-blurb
strong.spr(data-i18n="subscribe.sale_blurb1")
span(data-i18n="subscribe.sale_blurb2")
br
if !state || state !== 'invoice_paid'
p.center
button.btn.btn-success#pay-button #{payButtonText}
br
else if state === 'invoice_paid'
div.center
span.spr(data-i18n="subscribe.sale_continue")
a(href="/play", data-i18n="subscribe.sale_click_here")
br
#declined-alert.alert.alert-success.alert-dismissible
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") &times;
p(data-i18n="subscribe.sale_paid")
if state === 'declined'
#declined-alert.alert.alert-danger.alert-dismissible
span(data-i18n="account_invoices.declined")
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") &times;
if state === 'unknown_error'
#error-alert.alert.alert-danger.alert-dismissible
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") &times;
p(data-i18n="loading_error.unknown")
p= stateMessage

View file

@ -106,7 +106,6 @@ block content
td= personalSub.card
else
button.start-subscription-button.btn.btn-lg.btn-success(data-i18n="subscribe.subscribe_title") Subscribe
if personalSub.free === true
div(data-i18n="subscribe.currently_free")
else if typeof personalSub.free === 'string'
@ -117,6 +116,8 @@ block content
else
span(data-i18n="subscribe.was_free_until")
span.spl.spr= moment(new Date(personalSub.free)).format('l')
else
button.start-subscription-button.btn.btn-lg.btn-success(data-i18n="subscribe.subscribe_title") Subscribe
//- Sponsored Subscriptions

View file

@ -80,8 +80,9 @@
#parents-info(data-i18n="subscribe.parents")
#payment-methods-info(data-i18n="subscribe.payment_methods")
button.btn.btn-lg.btn-illustrated.purchase-button(data-i18n="subscribe.subscribe_title")
button.btn.btn-lg.btn-illustrated.parent-button(data-i18n="subscribe.parent_button")
button.btn.btn-lg.btn-illustrated.sale-button(title="#{saleButtonTitle}", data-i18n="subscribe.sale_button")
button.btn.btn-lg.btn-illustrated.purchase-button(data-i18n="subscribe.subscribe_title")
if state === 'declined'
#declined-alert.alert.alert-danger.alert-dismissible

View file

@ -0,0 +1,69 @@
RootView = require 'views/core/RootView'
template = require 'templates/account/subscription-sale-view'
stripeHandler = require 'core/services/stripe'
utils = require 'core/utils'
module.exports = class SubscriptionSaleView extends RootView
id: "subscription-sale-view"
template: template
yearSaleAmount: 7900
events:
'click #pay-button': 'onPayButton'
subscriptions:
'stripe:received-token': 'onStripeReceivedToken'
constructor: (options) ->
super(options)
@description = $.i18n.t('subscribe.stripe_description_year_sale')
displayAmount = (@yearSaleAmount / 100).toFixed(2)
@payButtonText = "#{$.i18n.t('subscribe.sale_view_button')} $#{displayAmount}"
getRenderData: ->
c = super()
c.payButtonText = @payButtonText
c.state = @state
c.stateMessage = @stateMessage
c
onPayButton: ->
@state = undefined
@stateMessage = undefined
@render()
# Show Stripe handler
application.tracker?.trackEvent 'Started sale landing page subscription purchase'
@timestampForPurchase = new Date().getTime()
stripeHandler.open
amount: @yearSaleAmount
description: @description
bitcoin: true
alipay: if me.get('chinaVersion') or (me.get('preferredLanguage') or 'en-US')[...2] is 'zh' then true else 'auto'
onStripeReceivedToken: (e) ->
@state = 'purchasing'
@render?()
# Call year sale API
data =
stripe:
token: e.token.id
timestamp: @timestampForPurchase
jqxhr = $.post('/db/subscription/-/year_sale', data)
jqxhr.done (data, textStatus, jqXHR) =>
application.tracker?.trackEvent 'Finished sale landing page subscription purchase', value: @yearSaleAmount
me.set 'stripe', data?.stripe if data?.stripe?
@state = 'invoice_paid'
@stateMessage = undefined
@render?()
jqxhr.fail (xhr, textStatus, errorThrown) =>
console.error 'We got an error subscribing with Stripe from our server:', textStatus, errorThrown
application.tracker?.trackEvent 'Failed to finish 1 year subscription purchase', status: textStatus
if xhr.status is 402
@state = 'declined'
@stateMessage = arguments[2]
else
@state = 'unknown_error'
@stateMessage = "#{xhr.status}: #{xhr.responseText}"
@render?()

View file

@ -12,6 +12,7 @@ module.exports = class SubscribeModal extends ModalView
product:
amount: 999
planID: 'basic'
yearAmount: 7900
subscriptions:
'stripe:received-token': 'onStripeReceivedToken'
@ -21,13 +22,16 @@ module.exports = class SubscribeModal extends ModalView
'click .popover-content .parent-send': 'onClickParentSendButton'
'click .email-parent-complete button': 'onClickParentEmailCompleteButton'
'click .purchase-button': 'onClickPurchaseButton'
'click .sale-button': 'onClickSaleButton'
constructor: (options) ->
super(options)
@state = 'standby'
@saleButtonTitle = $.i18n.t('subscribe.sale_button_title')
getRenderData: ->
c = super()
c.saleButtonTitle = @saleButtonTitle
c.state = @state
c.stateMessage = @stateMessage
c.price = @product.amount / 100
@ -137,21 +141,61 @@ module.exports = class SubscribeModal extends ModalView
#}
@purchasedAmount = options.amount
stripeHandler.open(options)
onClickSaleButton: (e) ->
@playSound 'menu-button-click'
return @openModalView new AuthModal() if me.get('anonymous')
application.tracker?.trackEvent 'Started 1 year subscription purchase'
options =
description: $.i18n.t('subscribe.stripe_description_year_sale')
amount: @product.yearAmount
alipay: if me.get('chinaVersion') or (me.get('preferredLanguage') or 'en-US')[...2] is 'zh' then true else 'auto'
alipayReusable: true
@purchasedAmount = options.amount
stripeHandler.open(options)
onStripeReceivedToken: (e) ->
@state = 'purchasing'
@render()
stripe = _.clone(me.get('stripe') ? {})
stripe.planID = @product.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'}})
if @purchasedAmount is @product.amount
stripe = _.clone(me.get('stripe') ? {})
stripe.planID = @product.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
# Purchasing a year
data =
stripe:
token: e.token.id
timestamp: new Date().getTime()
jqxhr = $.post('/db/subscription/-/year_sale', data)
jqxhr.done (data, textStatus, jqXHR) =>
application.tracker?.trackEvent 'Finished 1 year subscription purchase', value: @purchasedAmount
me.set 'stripe', data?.stripe if data?.stripe?
Backbone.Mediator.publish 'subscribe-modal:subscribed', {}
@playSound 'victory'
@hide()
jqxhr.fail (xhr, textStatus, errorThrown) =>
console.error 'We got an error subscribing with Stripe from our server:', textStatus, errorThrown
application.tracker?.trackEvent 'Failed to finish 1 year subscription purchase', status: textStatus, value: @purchasedAmount
stripe = me.get('stripe') ? {}
delete stripe.token
delete stripe.planID
if xhr.status is 402
@state = 'declined'
else
@state = 'unknown_error'
@stateMessage = "#{xhr.status}: #{xhr.responseText}"
@render()
else
console.error "Unexpected purchase amount received", @purchasedAmount, e
@state = 'unknown_error'
@stateMessage = "Uknown problem occurred while processing Stripe request"
onSubscriptionSuccess: ->
application.tracker?.trackEvent 'Finished subscription purchase', value: @purchasedAmount
@ -161,6 +205,7 @@ module.exports = class SubscribeModal extends ModalView
onSubscriptionError: (user, response, options) ->
console.error 'We got an error subscribing with Stripe from our server:', response
application.tracker?.trackEvent 'Failed to finish subscription purchase', status: options.xhr?.status, value: @purchasedAmount
stripe = me.get('stripe') ? {}
delete stripe.token
delete stripe.planID

View file

@ -0,0 +1,79 @@
log = require 'winston'
Payment = require '../payments/Payment'
PaymentHandler = require '../payments/payment_handler'
module.exports =
logError: (user, msg) ->
log.error "Stripe Utils Error: #{user.get('slug')} (#{user._id}): '#{msg}'"
createCharge: (user, amount, metadata, done) ->
options =
amount: amount
currency: 'usd'
customer: user.get('stripe')?.customerID
metadata: metadata
receipt_email: user.get('email')
statement_descriptor: 'CODECOMBAT.COM'
stripe.charges.create options, (err, charge) =>
if err
@logError(user, "Charge create error: #{JSON.stringify(err)}")
return done(err)
done(err, charge)
createPayment: (user, stripeCharge, done) ->
payment = new Payment
purchaser: user._id
recipient: user._id
created: new Date().toISOString()
service: 'stripe'
amount: parseInt(stripeCharge.amount)
payment.set 'description', stripeCharge.metadata.description if stripeCharge.metadata.description
payment.set 'stripe',
customerID: stripeCharge.customer
timestamp: parseInt(stripeCharge.metadata.timestamp)
chargeID: stripeCharge.id
validation = PaymentHandler.validateDocumentInput(payment.toObject())
if validation.valid is false
@logError(user, 'Invalid stripe payment object.')
return done(validation.errors)
payment.save (err) =>
if err
@logError(user, "Payment save error: #{JSON.stringify(err)}")
return done(err)
done(err, payment)
getCustomer: (user, token, done) ->
# If necessary, creates new Stripe customer and saves to user
customerID = user.get('stripe')?.customerID
if customerID
if token
# old customer, new token. Save it.
stripe.customers.update customerID, { card: token }, (err, customer) =>
if err
@logError(user, "Customer update error: #{JSON.stringify(err)}")
return done(err)
done(err, customer)
else
stripe.customers.retrieve customerID, (err, customer) =>
if err
@logError(user, "Customer retrieve error: #{JSON.stringify(err)}")
return done(err)
done(err, customer)
else
newCustomer = {
card: token
email: user.get('email')
metadata: { id: user._id + '', slug: user.get('slug') }
}
stripe.customers.create newCustomer, (err, customer) =>
if err
@logError(user, "Customer creation error: #{JSON.stringify(err)}")
return done(err)
stripeInfo = _.cloneDeep(user.get('stripe') ? {})
stripeInfo.customerID = customer.id
user.set('stripe', stripeInfo)
user.save (err) =>
if err
@logError(user, 'Stripe customer id save db error. '+err)
return done(err)
done(err, customer)

View file

@ -12,6 +12,7 @@ Prepaid = require '../prepaids/Prepaid'
User = require '../users/User'
{findStripeSubscription} = require '../lib/utils'
{getSponsoredSubsAmount} = require '../../app/core/utils'
StripeUtils = require '../lib/stripe_utils'
recipientCouponID = 'free'
@ -21,6 +22,9 @@ subscriptions = {
gems: 3500
amount: 999 # For calculating incremental quantity before sub creation
}
year_sale: {
amount: 7900
}
}
class SubscriptionHandler extends Handler
@ -32,6 +36,7 @@ class SubscriptionHandler extends Handler
return @getStripeInvoices(req, res) if args[1] is 'stripe_invoices'
return @getStripeSubscriptions(req, res) if args[1] is 'stripe_subscriptions'
return @getSubscribers(req, res) if args[1] is 'subscribers'
return @purchaseYearSale(req, res) if args[1] is 'year_sale'
super(arguments...)
getStripeEvents: (req, res) ->
@ -111,6 +116,42 @@ class SubscriptionHandler extends Handler
log.debug 'Analytics error:\n' + err
@sendSuccess(res, userMap)
purchaseYearSale: (req, res) ->
return @sendForbiddenError(res) unless req.user?
return @sendForbiddenError(res) if req.user?.hasSubscription()
StripeUtils.getCustomer req.user, req.body.stripe?.token, (err, customer) =>
if err
@logSubscriptionError(req.user, "Purchase year sale get customer: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
metadata =
type: req.body.type
userID: req.user._id + ''
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) =>
if err
@logSubscriptionError(req.user, "Purchase year sale create payment: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
# Add terminal subscription to User
endDate = new Date()
endDate.setUTCFullYear(endDate.getUTCFullYear() + 1)
stripeInfo = _.cloneDeep(req.user.get('stripe') ? {})
stripeInfo.free = endDate.toISOString().substring(0, 10)
req.user.set('stripe', stripeInfo)
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()
return done({res: 'You must be signed in to subscribe.', code: 403})
@ -234,7 +275,7 @@ class SubscriptionHandler extends Handler
options.coupon = couponID if couponID
stripe.customers.createSubscription customer.id, options, (err, subscription) =>
if err
@logSubscriptionError(user, 'Stripe customer plan setting error. ' + err)
@logSubscriptionError(user, 'Stripe customer plan resetting error. ' + err)
return done({res: 'Database error.', code: 500})
@updateUser(req, user, customer, subscription, false, done)

View file

@ -217,14 +217,17 @@ UserSchema.methods.register = (done) ->
delighted.addDelightedUser @
@saveActiveUser 'register'
UserSchema.methods.isPremium = ->
return true if @isInGodMode()
return true if @isAdmin()
UserSchema.methods.hasSubscription = ->
return false unless stripeObject = @get('stripe')
return true if stripeObject.sponsorID
return true if stripeObject.subscriptionID
return true if stripeObject.free is true
return true if _.isString(stripeObject.free) and new Date() < new Date(stripeObject.free)
UserSchema.methods.isPremium = ->
return true if @isInGodMode()
return true if @isAdmin()
return true if @hasSubscription()
return false
UserSchema.methods.level = ->

View file

@ -375,6 +375,8 @@ describe 'Subscriptions', ->
expect(err).toBeNull()
return done() if err
expect(res.statusCode).toBe(200)
expect(body.stripe).toBeDefined()
return done() unless body.stripe
expect(body.stripe.customerID).toBeDefined()
expect(body.stripe.planID).toBe('basic')
expect(body.stripe.token).toBeUndefined()
@ -1366,3 +1368,33 @@ describe 'Subscriptions', ->
else
expect(subscription.quantity).toEqual(subPrice + 10 * subPrice * 0.8 + (numSponsored - 11) * subPrice * 0.6)
done()
describe 'APIs', ->
subscriptionURL = getURL('/db/subscription')
it 'year_sale', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
loginNewUser (user1) ->
expect(user1.get('stripe')?.free).toBeUndefined()
requestBody =
stripe:
token: token.id
timestamp: new Date()
request.put {uri: "#{subscriptionURL}/-/year_sale", json: requestBody, headers: headers }, (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
User.findById user1.id, (err, user1) ->
expect(err).toBeNull()
stripeInfo = user1.get('stripe')
expect(stripeInfo).toBeDefined()
return done() unless stripeInfo
endDate = new Date()
endDate.setUTCFullYear(endDate.getUTCFullYear() + 1)
expect(stripeInfo.free).toEqual(endDate.toISOString().substring(0, 10))
expect(stripeInfo.customerID).toBeDefined()
Payment.findOne 'stripe.customerID': stripeInfo.customerID, (err, payment) ->
expect(err).toBeNull()
expect(payment).toBeDefined()
done()