Merge branch 'sponsored-subs'

This commit is contained in:
Matt Lott 2015-03-13 15:19:20 -07:00
parent c32dd6d284
commit af89452b93
17 changed files with 1807 additions and 210 deletions

View file

@ -184,3 +184,17 @@ module.exports.getQueryVariable = getQueryVariable = (param, defaultValue) ->
for pair in pairs when pair[0] is param for pair in pairs when pair[0] is param
return {'true': true, 'false': false}[pair[1]] ? decodeURIComponent(pair[1]) return {'true': true, 'false': false}[pair[1]] ? decodeURIComponent(pair[1])
defaultValue defaultValue
module.exports.getSponsoredSubsAmount = getSponsoredSubsAmount = (price=999, subCount=0, personalSub=false) ->
# 1 100%
# 2-11 80%
# 12+ 60%
# TODO: make this less confusing
return 0 unless subCount > 0
offset = if personalSub then 1 else 0
if subCount <= 1 - offset
price
else if subCount <= 11 - offset
Math.round((1 - offset) * price + (subCount - 1 + offset) * price * 0.8)
else
Math.round((1 - offset) * price + 10 * price * 0.8 + (subCount - 11 + offset) * price * 0.6)

View file

@ -432,6 +432,31 @@
subscription_required_to_play: "You'll need a subscription to play this level." subscription_required_to_play: "You'll need a subscription to play this level."
unlock_help_videos: "Subscribe to unlock all video tutorials." unlock_help_videos: "Subscribe to unlock all video tutorials."
# Accounts Subscription View
personal_sub: "Personal Subscription"
loading_info: "Loading subscription information..."
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"
was_free_until: "You had a free subscription until"
managed_subs: "Managed Subscriptions"
managed_subs_desc: "Add subscriptions for other players (students, children, etc.)"
group_discounts: "Group discounts"
group_discounts_1st: "1st subscription (includes yours)"
group_discounts_full: "Full price"
group_discounts_2nd: "Subscriptions 2-11"
group_discounts_20: "20% off"
group_discounts_12th: "Subscriptions 12+"
group_discounts_40: "40% off"
subscribing: "Subscribing..."
recipient_emails_placeholder: "Enter email address to subscribe, one per line."
subscribe_users: "Subscribe Users"
users_subscribed: "Users subscribed:"
no_users_subscribed: "No users subscribed, please double check your email addresses."
current_recipients: "Current Recipients"
unsubscribing: "Unsubscribing..."
choose_hero: choose_hero:
choose_hero: "Choose Your Hero" choose_hero: "Choose Your Hero"
programming_language: "Programming Language" programming_language: "Programming Language"

View file

@ -139,6 +139,7 @@ module.exports = class User extends CocoModel
return true if me.isInGodMode() return true if me.isInGodMode()
return true if me.isAdmin() return true if me.isAdmin()
return false unless stripe = @get('stripe') return false unless stripe = @get('stripe')
return true if stripe.sponsorID
return true if stripe.subscriptionID return true if stripe.subscriptionID
return true if stripe.free is true return true if stripe.free is true
return true if _.isString(stripe.free) and new Date() < new Date(stripe.free) return true if _.isString(stripe.free) and new Date() < new Date(stripe.free)

View file

@ -283,12 +283,22 @@ _.extend UserSchema.properties,
stripe: c.object {}, { stripe: c.object {}, {
customerID: { type: 'string' } customerID: { type: 'string' }
planID: { enum: ['basic'] } planID: { enum: ['basic'], description: 'Determines if a user has or wants to subscribe' }
subscriptionID: { type: 'string' } subscriptionID: { type: 'string', description: 'Determines if a user is subscribed' }
token: { type: 'string' } token: { type: 'string' }
couponID: { type: 'string' } couponID: { type: 'string' }
discountID: { type: 'string' } free: { type: ['boolean', 'string'], format: 'date-time', description: 'Type string is subscription end date' }
free: { type: ['boolean', 'string'], format: 'date-time' }
# Sponsored subscriptions
subscribeEmails: c.array { description: 'Input for subscribing other users' }, c.shortString()
unsubscribeEmail: { type: 'string', description: 'Input for unsubscribing a sponsored user' }
recipients: c.array { title: 'Recipient subscriptions owned by this user' },
c.object { required: ['userID', 'subscriptionID'] },
userID: c.objectId { description: 'User ID of recipient' }
subscriptionID: { type: 'string' }
couponID: { type: 'string' }
sponsorID: c.objectId { description: "User ID that owns this user's subscription" }
sponsorSubscriptionID: { type: 'string', description: 'Sponsor aggregate subscription used to pay for all recipient subs' }
} }
siteref: { type: 'string' } siteref: { type: 'string' }

View file

@ -16,3 +16,25 @@
button.btn button.btn
width: 100% width: 100%
margin-top: 12px margin-top: 12px
// Sponsored subscriptions
.recipient-emails
min-width: 50%
.recipients-subscribe-button
margin-top: 10px
.recipient-unsubscribe-button
width: auto
.confirm-recipient-unsubscribe-button
width: auto
.discount-table
width: 50%
.recipients-table
width: 50%
.recipient-unsubscribe
text-align: right

View file

@ -10,54 +10,161 @@ block content
a(href="/account", data-i18n="nav.account") a(href="/account", data-i18n="nav.account")
li.active(data-i18n="account.subscription") li.active(data-i18n="account.subscription")
.panel.panel-default if me.get('anonymous')
.panel-heading p(data-i18n="account_settings.not_logged_in")
if subscribed else
button.end-subscription-button.btn.btn-lg.btn-warning(data-i18n="subscribe.unsubscribe") Unsubscribe
.unsubscribe-feedback.row.secret //- Personal Subscriptions
.col-lg-7
h3 .panel.panel-default
if monthsSubscribed > 1 .panel-heading
span(data-i18n="subscribe.thank_you_months_prefix") Thank you for supporting us these last h3(data-i18n="subscribe.personal_sub")
span.spl.spr= monthsSubscribed .panel-body
span(data-i18n="subscribe.thank_you_months_suffix") months. if personalSub.state === 'loading'
else .alert.alert-info(data-i18n="subscribe.loading_info")
span(data-i18n="subscribe.thank_you") Thank you for supporting CodeCombat. else if personalSub.sponsor
div(data-i18n="subscribe.sorry_to_see_you_go") Sorry to see you go! Please let us know what we could have done better. div
textarea(rows=3, data-i18n="[placeholder]subscribe.unsubscribe_feedback_placeholder") span(data-i18n="subscribe.managed_by")
.col-lg-5 span.spl.spr #{personalSub.sponsorName} (#{personalSub.sponsorEmail})
button.cancel-end-subscription-button.btn.btn-lg.btn-default(data-i18n="subscribe.never_mind") Never Mind, I Still Love You if personalSub.endDate
button.confirm-end-subscription-button.btn.btn-lg.btn-warning(data-i18n="subscribe.confirm_unsubscribe") Confirm Unsubscribe div
else if !me.isAnonymous() span(data-i18n="subscribe.will_be_cancelled")
button.start-subscription-button.btn.btn-lg.btn-success(data-i18n="subscribe.subscribe_title") Subscribe span.spl.spr= moment(personalSub.endDate).format('l')
else if personalSub.self
.panel-body if personalSub.subscribed
table.table.table-striped button.end-subscription-button.btn.btn-lg.btn-warning(data-i18n="subscribe.unsubscribe") Unsubscribe
tr else
th(data-i18n="user.status") Status button.start-subscription-button.btn.btn-lg.btn-success(data-i18n="subscribe.subscribe_title") Subscribe
td .unsubscribe-feedback.row.secret
if subscribed .col-lg-7
strong(data-i18n="account.subscribed") h3
if personalSub.monthsSubscribed > 1
span(data-i18n="subscribe.thank_you_months_prefix") Thank you for supporting us these last
span.spl.spr= personalSub.monthsSubscribed
span(data-i18n="subscribe.thank_you_months_suffix") months.
else
span(data-i18n="subscribe.thank_you") Thank you for supporting CodeCombat.
div(data-i18n="subscribe.sorry_to_see_you_go") Sorry to see you go! Please let us know what we could have done better.
textarea(rows=3, data-i18n="[placeholder]subscribe.unsubscribe_feedback_placeholder")
.col-lg-5
button.cancel-end-subscription-button.btn.btn-lg.btn-default(data-i18n="subscribe.never_mind") Never Mind, I Still Love You
button.confirm-end-subscription-button.btn.btn-lg.btn-warning(data-i18n="subscribe.confirm_unsubscribe") Confirm Unsubscribe
table.table.table-striped.table-condensed
tr
th(data-i18n="user.status") Status
td
if personalSub.subscribed
strong(data-i18n="account.subscribed")
else
if personalSub.active
strong(data-i18n="account.active")
.text-muted(data-i18n="account.status_unsubscribed_active")
else
strong(data-i18n="account.unsubscribed")
.text-muted(data-i18n="account.status_unsubscribed")
if personalSub.activeUntil
tr
th(data-i18n="account.active_until")
td= moment(activeUntil).format('l')
if personalSub.nextPaymentDate
tr
th(data-i18n="account.next_payment")
td= moment(nextPaymentDate).format('l')
if personalSub.cost
tr
th(data-i18n="account.cost")
td= personalSub.cost
if personalSub.card
tr
th(data-i18n="account.card")
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'
if new Date() < new Date(personalSub.free)
div
span(data-i18n="subscribe.currently_free_until")
span.spl.spr= moment(new Date(personalSub.free)).format('l')
else else
if active span(data-i18n="subscribe.was_free_until")
strong(data-i18n="account.active") span.spl.spr= moment(new Date(personalSub.free)).format('l')
.text-muted(data-i18n="account.status_unsubscribed_active")
else //- Sponsored Subscriptions
strong(data-i18n="account.unsubscribed")
.text-muted(data-i18n="account.status_unsubscribed") .panel.panel-default
if activeUntil .panel-heading
h3(data-i18n="subscribe.managed_subs")
div(data-i18n="subscribe.managed_subs_desc")
h4(data-i18n="subscribe.group_discounts")
table.table.table-striped.table-condensed.discount-table
tr
td(data-i18n="subscribe.group_discounts_1st")
td(data-i18n="subscribe.group_discounts_full")
tr tr
th(data-i18n="account.active_until") td(data-i18n="subscribe.group_discounts_2nd")
td= moment(activeUntil).format('l') td(data-i18n="subscribe.group_discounts_20")
if nextPaymentDate
tr tr
th(data-i18n="account.next_payment") td(data-i18n="subscribe.group_discounts_12th")
td= moment(nextPaymentDate).format('l') td(data-i18n="subscribe.group_discounts_40")
if cost .panel-body
if recipientSubs.state === 'subscribing'
.alert.alert-info(data-i18n="subscribe.subscribing")
else
textarea.recipient-emails(rows=3, data-i18n="[placeholder]subscribe.recipient_emails_placeholder")
div
button.recipients-subscribe-button.btn.btn-lg.btn-success(data-i18n="subscribe.subscribe_users")
if recipientSubs.state === 'declined'
br
.alert.alert-danger.alert-dismissible
span(data-i18n="buy_gems.declined")
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") &times;
else if recipientSubs.state === 'unknown_error'
br
.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
else if recipientSubs.justSubscribed && recipientSubs.justSubscribed.length > 0
br
.alert.alert-success.alert-dismissible
if recipientSubs.justSubscribed.length > 0
div(data-i18n="subscribe.users_subscribed")
ul
each email in recipientSubs.justSubscribed
li= email
else if recipientSubs.justSubscribed && recipientSubs.justSubscribed.length === 0
br
.alert.alert-success.alert-dismissible
div(data-i18n="subscribe.no_users_subscribed")
if recipientSubs.nextPaymentAmount > 0 && recipientSubs.sponsorSub
h4(data-i18n="account.next_payment")
p= moment(new Date(recipientSubs.sponsorSub.current_period_end * 1000)).format('l')
p $#{recipientSubs.nextPaymentAmount / 100}
p= recipientSubs.card
h4(data-i18n="subscribe.current_recipients")
table.table.table-striped.table-condensed.recipients-table
tr tr
th(data-i18n="account.cost") th(data-i18n="general.email")
td= cost th(data-i18n="general.name")
if card th
tr for recipient in recipientSubs.recipients
th(data-i18n="account.card") tr
td= card td.recipient-email= recipient.emailLower
td.recipient-name= recipient.name
td.recipient-unsubscribe
if recipient.cancel_at_period_end
div Ends #{moment(recipient.cancel_at_period_end).format('l')}
else if recipientSubs.unsubscribingRecipients.indexOf(recipient.emailLower) >= 0
div(data-i18n="subscribe.unsubscribing")
else
button.recipient-unsubscribe-button.btn.btn-sm.btn-warning Unsubscribe
button.confirm-recipient-unsubscribe-button.btn.btn-sm.btn-primary.hide(data-i18n="play.confirm")

View file

@ -3,6 +3,24 @@ template = require 'templates/account/subscription-view'
CocoCollection = require 'collections/CocoCollection' CocoCollection = require 'collections/CocoCollection'
SubscribeModal = require 'views/core/SubscribeModal' SubscribeModal = require 'views/core/SubscribeModal'
Payment = require 'models/Payment' Payment = require 'models/Payment'
stripeHandler = require 'core/services/stripe'
User = require 'models/User'
utils = require 'core/utils'
# TODO: Link to sponsor id /user/userID instead of plain text name
# TODO: Link to sponsor email instead of plain text email
# TODO: Conslidate the multiple class for personal and recipient subscription info into 2 simple server API calls
# TODO: Track purchase amount based on actual users subscribed for a recipient subscribe event
# TODO: Validate email address formatting
# TODO: i18n pluralization for Stripe dialog description
# TODO: Don't prompt for new card if we have one already, just confirm purchase
# TODO: bulk discount isn't applied to personal sub
# TODO: next payment amount incorrect if have an expiring personal sub
# TODO: consider hiding managed subscription body UI while things are updating to avoid brief legacy data
# TODO: Next payment info for personal sub displays most recent payment when resubscribing before trial end
# TODO: Get basic plan price dynamically
basicPlanPrice = 999
module.exports = class SubscriptionView extends RootView module.exports = class SubscriptionView extends RootView
id: "subscription-view" id: "subscription-view"
@ -13,41 +31,29 @@ module.exports = class SubscriptionView extends RootView
'click .end-subscription-button': 'onClickEndSubscription' 'click .end-subscription-button': 'onClickEndSubscription'
'click .cancel-end-subscription-button': 'onClickCancelEndSubscription' 'click .cancel-end-subscription-button': 'onClickCancelEndSubscription'
'click .confirm-end-subscription-button': 'onClickConfirmEndSubscription' 'click .confirm-end-subscription-button': 'onClickConfirmEndSubscription'
'click .recipients-subscribe-button': 'onClickRecipientsSubscribe'
'click .confirm-recipient-unsubscribe-button': 'onClickRecipientConfirmUnsubscribe'
'click .recipient-unsubscribe-button': 'onClickRecipientUnsubscribe'
subscriptions: subscriptions:
'subscribe-modal:subscribed': 'onSubscribed' 'subscribe-modal:subscribed': 'onSubscribed'
'stripe:received-token': 'onStripeReceivedToken'
constructor: (options) -> constructor: (options) ->
super(options) super(options)
if me.get('stripe') @personalSub = new PersonalSub(@supermodel)
options = { cache: false, url: "/db/user/#{me.id}/stripe" } @recipientSubs = new RecipientSubs(@supermodel)
options.success = (@stripeInfo) => @personalSub.update => @render?()
@supermodel.addRequestResource('payment_info', options).load() @recipientSubs.update => @render?()
@payments = new CocoCollection([], { url: '/db/payment', model: Payment, comparator:'_id' })
@supermodel.loadCollection(@payments, 'payments', {cache: false})
getRenderData: -> getRenderData: ->
c = super() c = super()
if @stripeInfo c.personalSub = @personalSub
if subscription = @stripeInfo.subscriptions?.data?[0] c.recipientSubs = @recipientSubs
periodEnd = new Date((subscription.trial_end or subscription.current_period_end) * 1000)
if subscription.cancel_at_period_end
c.activeUntil = periodEnd
else
c.nextPaymentDate = periodEnd
c.cost = "$#{(subscription.plan.amount/100).toFixed(2)}"
if card = @stripeInfo.sources?.data?[0]
c.card = "#{card.brand}: x#{card.last4}"
if @payments?.loaded
c.monthsSubscribed = (x for x in @payments.models when not x.get('productID')).length # productID is for gem purchases
else
c.monthsSubscribed = null
c.stripeInfo = @stripeInfo
c.subscribed = me.get('stripe')?.planID
c.active = me.isPremium()
c c
# Personal Subscriptions
onClickStartSubscription: (e) -> onClickStartSubscription: (e) ->
@openModalView new SubscribeModal() @openModalView new SubscribeModal()
window.tracker?.trackEvent 'Show subscription modal', category: 'Subscription', label: 'account subscription view' window.tracker?.trackEvent 'Show subscription modal', category: 'Subscription', label: 'account subscription view'
@ -67,15 +73,198 @@ module.exports = class SubscriptionView extends RootView
onClickConfirmEndSubscription: (e) -> onClickConfirmEndSubscription: (e) ->
message = @$el.find('.unsubscribe-feedback textarea').val().trim() message = @$el.find('.unsubscribe-feedback textarea').val().trim()
window.tracker?.trackEvent 'Unsubscribe End', message: message @personalSub.unsubscribe(message)
# Sponsored subscriptions
onClickRecipientsSubscribe: (e) ->
emails = @$el.find('.recipient-emails').val().split('\n')
@recipientSubs.startSubscribe(emails)
onClickRecipientUnsubscribe: (e) ->
$(e.target).addClass('hide')
$(e.target).parent().find('.confirm-recipient-unsubscribe-button').removeClass('hide')
onClickRecipientConfirmUnsubscribe: (e) ->
email = $(e.target).closest('tr').find('td.recipient-email').text()
@recipientSubs.unsubscribe(email, => @render?())
onStripeReceivedToken: (e) ->
@recipientSubs.finishSubscribe(e.token.id, => @render?())
# Helper classes for managing subscription actions and updating UI state
class PersonalSub
constructor: (@supermodel) ->
unsubscribe: (message) ->
removeStripe = => removeStripe = =>
stripe = _.clone(me.get('stripe')) stripeInfo = _.clone(me.get('stripe'))
delete stripe.planID delete stripeInfo.planID
me.set('stripe', stripe) me.set('stripe', stripeInfo)
me.once 'sync', ->
window.tracker?.trackEvent 'Unsubscribe End', message: message
document.location.reload()
me.patch({headers: {'X-Change-Plan': 'true'}}) me.patch({headers: {'X-Change-Plan': 'true'}})
@listenToOnce me, 'sync', -> document.location.reload()
if message if message
$.post '/contact', message: message, subject: 'Cancellation', (response) -> $.post '/contact', message: message, subject: 'Cancellation', (response) ->
removeStripe() removeStripe()
else else
removeStripe() removeStripe()
update: (render) ->
return unless stripeInfo = me.get('stripe')
@state = 'loading'
if stripeInfo.sponsorID
@sponsor = true
onSubSponsorSuccess = (sponsorInfo) =>
@sponsorEmail = sponsorInfo.email
@sponsorName = sponsorInfo.name
@sponsorID = stripeInfo.sponsorID
if sponsorInfo.subscription.cancel_at_period_end
@endDate = new Date(sponsorInfo.subscription.current_period_end * 1000)
delete @state
render()
@supermodel.addRequestResource('sub_sponsor', {
url: '/db/user/-/sub_sponsor'
method: 'POST'
success: onSubSponsorSuccess
}, 0).load()
else if stripeInfo.subscriptionID
@self = true
@active = me.isPremium()
@subscribed = stripeInfo.planID?
options = { cache: false, url: "/db/user/#{me.id}/stripe" }
options.success = (info) =>
if card = info.card
@card = "#{card.brand}: x#{card.last4}"
if sub = info.subscription
periodEnd = new Date((sub.trial_end or sub.current_period_end) * 1000)
if sub.cancel_at_period_end
@activeUntil = periodEnd
else
@nextPaymentDate = periodEnd
@cost = "$#{(sub.plan.amount/100).toFixed(2)}"
else
console.error "Could not find personal subscription #{me.get('stripe')?.customerID} #{me.get('stripe')?.subscriptionID}"
delete @state
render()
@supermodel.addRequestResource('personal_payment_info', options).load()
payments = new CocoCollection([], { url: '/db/payment', model: Payment, comparator:'_id' })
payments.once 'sync', ->
@monthsSubscribed = (x for x in payments.models when not x.get('productID')).length
render()
@supermodel.loadCollection(payments, 'payments', {cache: false})
else if stripeInfo.free
@free = stripeInfo.free
delete @state
render()
else
delete @state
render()
class RecipientSubs
constructor: (@supermodel) ->
@recipients = {}
@unsubscribingRecipients = []
addSubscribing: (email) ->
@unsubscribingRecipients.push email
removeSubscribing: (email) ->
_.remove(@unsubscribingRecipients, (recipientEmail) -> recipientEmail is email)
startSubscribe: (emails) ->
@recipientEmails = (email.trim().toLowerCase() for email in emails)
_.remove(@recipientEmails, (email) -> _.isEmpty(email))
return if @recipientEmails.length < 1
window.tracker?.trackEvent 'Start sponsored subscription'
# TODO: this sometimes shows a rounded amount (e.g. $8.00)
currentSubCount = me.get('stripe')?.recipients?.length ? 0
newSubCount = @recipientEmails.length + currentSubCount
amount = utils.getSponsoredSubsAmount(basicPlanPrice, newSubCount, me.get('stripe')?.subscriptionID?) - utils.getSponsoredSubsAmount(basicPlanPrice, currentSubCount, me.get('stripe')?.subscriptionID?)
options = {
description: "#{@recipientEmails.length} " + $.i18n.t('subscribe.stripe_description', defaultValue: 'Monthly Subscriptions')
amount: amount
}
@state = 'start subscribe'
@stateMessage = ''
stripeHandler.open(options)
finishSubscribe: (tokenID, render) ->
return unless @state is 'start subscribe' # Don't intercept personal subcribe process
@state = 'subscribing'
@stateMessage = ''
@justSubscribed = []
render()
stripeInfo = _.clone(me.get('stripe') ? {})
stripeInfo.token = tokenID
stripeInfo.subscribeEmails = @recipientEmails
me.set('stripe', stripeInfo)
me.once 'sync', =>
application.tracker?.trackEvent 'Finished sponsored subscription purchase'
@update(render)
me.once 'error', (user, response, options) =>
console.error 'We got an error subscribing with Stripe from our server:', response
stripeInfo = me.get('stripe') ? {}
delete stripeInfo.token
xhr = options.xhr
if xhr.status is 402
@state = 'declined'
@stateMessage = ''
else
@state = 'unknown_error'
@stateMessage = "#{xhr.status}: #{xhr.responseText}"
render()
me.patch({headers: {'X-Change-Plan': 'true'}})
unsubscribe: (email, render) ->
@addSubscribing(email)
render()
stripeInfo = _.clone(me.get('stripe'))
stripeInfo.unsubscribeEmail = email
me.set('stripe', stripeInfo)
me.once 'sync', =>
@removeSubscribing(email)
@update(render)
me.patch({headers: {'X-Change-Plan': 'true'}})
update: (render) ->
delete @state
delete @stateMessage
return unless me.get('stripe')?.recipients
@unsubscribingRecipients = []
options = { cache: false, url: "/db/user/#{me.id}/stripe" }
options.success = (info) =>
@sponsorSub = info.subscription
if card = info.card
@card = "#{card.brand}: x#{card.last4}"
render()
@supermodel.addRequestResource('recipients_payment_info', options).load()
onSubRecipientsSuccess = (recipientsMap) =>
@recipients = recipientsMap
count = 0
for userID, recipient of @recipients
count++ unless recipient.cancel_at_period_end
if @recipientEmails? and @justSubscribed? and recipient.emailLower in @recipientEmails
@justSubscribed.push recipient.emailLower
@nextPaymentAmount = utils.getSponsoredSubsAmount(basicPlanPrice, count, me.get('stripe')?.subscriptionID?)
render()
@supermodel.addRequestResource('sub_recipients', {
url: '/db/user/-/sub_recipients'
method: 'POST'
success: onSubRecipientsSuccess
}, 0).load()

View file

@ -17,7 +17,7 @@ module.exports = class UsersView extends RootView
# http://mongoosejs.com/docs/queries.html # http://mongoosejs.com/docs/queries.html
# Each list in conditions is a function call. # Each list in conditions is a function call.
# The first arg is the function name # The first arg is the function name
# The rest are the agrs for the function # The rest are the args for the function
conditions = [ conditions = [
['limit', 20] ['limit', 20]

View file

@ -4,6 +4,7 @@ mongoose = require 'mongoose'
module.exports = module.exports =
isID: (id) -> _.isString(id) and id.length is 24 and id.match(/[a-f0-9]/gi)?.length is 24 isID: (id) -> _.isString(id) and id.length is 24 and id.match(/[a-f0-9]/gi)?.length is 24
objectIdFromTimestamp: (timestamp) -> objectIdFromTimestamp: (timestamp) ->
# mongoDB ObjectId contains creation date in first 4 bytes # mongoDB ObjectId contains creation date in first 4 bytes
# So, it can be used instead of a redundant created field # So, it can be used instead of a redundant created field
@ -15,6 +16,36 @@ module.exports =
hexSeconds = Math.floor(timestamp/1000).toString(16) hexSeconds = Math.floor(timestamp/1000).toString(16)
# Create an ObjectId with that hex timestamp # Create an ObjectId with that hex timestamp
mongoose.Types.ObjectId(hexSeconds + "0000000000000000") mongoose.Types.ObjectId(hexSeconds + "0000000000000000")
findStripeSubscription: (customerID, options, done) ->
# Grabs latest subscription (e.g. in case of a resubscribe)
return done() unless customerID?
return done() unless options.subscriptionID? or options.userID?
subscriptionID = options.subscriptionID
userID = options.userID
subscription = null
nextBatch = (starting_after, done) ->
options = limit: 100
options.starting_after = starting_after if starting_after
stripe.customers.listSubscriptions customerID, options, (err, subscriptions) ->
return done(subscription) if err
return done(subscription) unless subscriptions?.data?.length > 0
for sub in subscriptions.data
if subscriptionID? and sub.id is subscriptionID
unless subscription?.cancel_at_period_end is false
subscription = sub
if userID? and sub.metadata?.id is userID
unless subscription?.cancel_at_period_end is false
subscription = sub
return done(subscription) if subscription?.cancel_at_period_end is false
if subscriptions.has_more
nextBatch(subscriptions.data[subscriptions.data.length - 1].id, done)
else
done(subscription)
nextBatch(null, done)
getAnalyticsStringID: (str, callback) -> getAnalyticsStringID: (str, callback) ->
unless str? unless str?
log.error "getAnalyticsStringID given invalid str param" log.error "getAnalyticsStringID given invalid str param"

View file

@ -1,12 +1,18 @@
# Not paired with a document in the DB, just handles coordinating between # 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. # the stripe property in the user with what's being stored in Stripe.
async = require 'async'
Handler = require '../commons/Handler' Handler = require '../commons/Handler'
discountHandler = require './discount_handler' discountHandler = require './discount_handler'
User = require '../users/User'
{findStripeSubscription} = require '../lib/utils'
{getSponsoredSubsAmount} = require '../../app/core/utils'
recipientCouponID = 'free'
subscriptions = { subscriptions = {
basic: { basic: {
gems: 3500 gems: 3500
amount: 999 # For calculating incremental quantity before sub creation
} }
} }
@ -24,28 +30,27 @@ class SubscriptionHandler extends Handler
@logSubscriptionError(user, 'Missing stripe token or customer ID.') @logSubscriptionError(user, 'Missing stripe token or customer ID.')
return done({res: 'Missing stripe token or customer ID.', code: 422}) return done({res: 'Missing stripe token or customer ID.', code: 422})
# Create/retrieve Stripe customer
if token if token
if customerID if customerID
stripe.customers.update customerID, { card: token }, (err, customer) => stripe.customers.update customerID, { card: token }, (err, customer) =>
if err or not customer if err or not customer
# should not happen outside of test and production polluting each other # should not happen outside of test and production polluting each other
@logSubscriptionError(user, 'Cannot find customer: ', +customer.id + '\n\n' + err) @logSubscriptionError(user, 'Cannot find customer: ' + customerID + '\n\n' + err)
return done({res: 'Cannot find customer.', code: 404}) return done({res: 'Cannot find customer.', code: 404})
@checkForExistingSubscription(req, user, customer, done) @checkForExistingSubscription(req, user, customer, done)
else else
newCustomer = { options =
card: token card: token
email: user.get('email') email: user.get('email')
metadata: { id: user._id + '', slug: user.get('slug') } metadata: { id: user._id + '', slug: user.get('slug') }
} stripe.customers.create options, (err, customer) =>
stripe.customers.create newCustomer, (err, customer) =>
if err if err
if err.type in ['StripeCardError', 'StripeInvalidRequestError'] if err.type in ['StripeCardError', 'StripeInvalidRequestError']
return done({res: 'Card error', code: 402}) return done({res: 'Card error', code: 402})
else else
@logSubscriptionError(user, 'Stripe customer creation error. '+err) @logSubscriptionError(user, 'Stripe customer creation error. ' + err)
return done({res: 'Database error.', code: 500}) return done({res: 'Database error.', code: 500})
stripeInfo = _.cloneDeep(user.get('stripe') ? {}) stripeInfo = _.cloneDeep(user.get('stripe') ? {})
@ -53,68 +58,78 @@ class SubscriptionHandler extends Handler
user.set('stripe', stripeInfo) user.set('stripe', stripeInfo)
user.save (err) => user.save (err) =>
if err if err
@logSubscriptionError(user, 'Stripe customer id save db error. '+err) @logSubscriptionError(user, 'Stripe customer id save db error. ' + err)
return done({res: 'Database error.', code: 500}) return done({res: 'Database error.', code: 500})
@checkForExistingSubscription(req, user, customer, done) @checkForExistingSubscription(req, user, customer, done)
else else
stripe.customers.retrieve(customerID, (err, customer) => stripe.customers.retrieve(customerID, (err, customer) =>
if err if err
@logSubscriptionError(user, 'Stripe customer creation error. '+err) @logSubscriptionError(user, 'Stripe customer retrieve error. ' + err)
return done({res: 'Database error.', code: 500}) return done({res: 'Database error.', code: 500})
@checkForExistingSubscription(req, user, customer, done) @checkForExistingSubscription(req, user, customer, done)
) )
checkForExistingSubscription: (req, user, customer, done) -> checkForExistingSubscription: (req, user, customer, done) ->
# Check if user is subscribing someone else
if req.body.stripe?.subscribeEmails?
return @updateStripeRecipientSubscriptions req, user, customer, done
if user.get('stripe')?.sponsorID
return done({res: 'You already have a sponsored subscription.', code: 403})
couponID = user.get('stripe')?.couponID couponID = user.get('stripe')?.couponID
# SALE LOGIC # SALE LOGIC
# overwrite couponID with another for everyone-sales # overwrite couponID with another for everyone-sales
#couponID = 'hoc_399' if not couponID #couponID = 'hoc_399' if not couponID
if subscription = customer.subscriptions?.data?[0] findStripeSubscription customer.id, subscriptionID: user.get('stripe')?.subscriptionID, (subscription) =>
if subscription.cancel_at_period_end if subscription
# Things are a little tricky here. Can't re-enable a cancelled subscription,
# so it needs to be deleted, but also don't want to charge for the new subscription immediately.
# So delete the cancelled subscription (no at_period_end given here) and give the new
# subscription a trial period that ends when the cancelled subscription would have ended.
stripe.customers.cancelSubscription subscription.customer, subscription.id, (err) =>
if err
@logSubscriptionError(user, 'Stripe cancel subscription error. '+err)
return done({res: 'Database error.', code: 500})
options = { plan: 'basic', trial_end: subscription.current_period_end } if subscription.cancel_at_period_end
options.coupon = couponID if couponID # Things are a little tricky here. Can't re-enable a cancelled subscription,
stripe.customers.update user.get('stripe').customerID, options, (err, customer) => # so it needs to be deleted, but also don't want to charge for the new subscription immediately.
# So delete the cancelled subscription (no at_period_end given here) and give the new
# subscription a trial period that ends when the cancelled subscription would have ended.
stripe.customers.cancelSubscription subscription.customer, subscription.id, (err) =>
if err if err
@logSubscriptionError(user, 'Stripe customer plan setting error. '+err) @logSubscriptionError(user, 'Stripe cancel subscription error. ' + err)
return done({res: 'Database error.', code: 500}) return done({res: 'Database error.', code: 500})
@updateUser(req, user, customer, false, done) options = { plan: 'basic', metadata: {id: user.id}, trial_end: subscription.current_period_end }
options.coupon = couponID if couponID
stripe.customers.createSubscription customer.id, options, (err, subscription) =>
if err
@logSubscriptionError(user, 'Stripe customer plan setting error. ' + err)
return done({res: 'Database error.', code: 500})
@updateUser(req, user, customer, subscription, false, done)
else
# can skip creating the subscription
return @updateUser(req, user, customer, subscription, false, done)
else else
# can skip creating the subscription options = { plan: 'basic', metadata: {id: user.id}}
return @updateUser(req, user, customer, false, done) options.coupon = couponID if couponID
stripe.customers.createSubscription customer.id, options, (err, subscription) =>
if err
@logSubscriptionError(user, 'Stripe customer plan setting error. ' + err)
return done({res: 'Database error.', code: 500})
else @updateUser(req, user, customer, subscription, true, done)
options = { plan: 'basic' }
options.coupon = couponID if couponID
stripe.customers.update user.get('stripe').customerID, options, (err, customer) =>
if err
@logSubscriptionError(user, 'Stripe customer plan setting error. '+err)
return done({res: 'Database error.', code: 500})
@updateUser(req, user, customer, true, done) updateUser: (req, user, customer, subscription, increment, done) ->
updateUser: (req, user, customer, increment, done) ->
subscription = customer.subscriptions.data[0]
stripeInfo = _.cloneDeep(user.get('stripe') ? {}) stripeInfo = _.cloneDeep(user.get('stripe') ? {})
stripeInfo.planID = 'basic' stripeInfo.planID = 'basic'
stripeInfo.subscriptionID = subscription.id stripeInfo.subscriptionID = subscription.id
stripeInfo.customerID = customer.id stripeInfo.customerID = customer.id
req.body.stripe = stripeInfo # to make sure things work for admins, who are mad with power
# To make sure things work for admins, who are mad with power
# And, so Handler.saveChangesToDocument doesn't undo all our saves here
req.body.stripe = stripeInfo
user.set('stripe', stripeInfo) user.set('stripe', stripeInfo)
if increment if increment
@ -126,25 +141,232 @@ class SubscriptionHandler extends Handler
user.save (err) => user.save (err) =>
if err if err
@logSubscriptionError(user, 'Stripe user plan saving error. '+err) @logSubscriptionError(user, 'Stripe user plan saving error. ' + err)
return done({res: 'Database error.', code: 500}) return done({res: 'Database error.', code: 500})
user?.saveActiveUser 'subscribe' user?.saveActiveUser 'subscribe'
return done() return done()
updateStripeRecipientSubscriptions: (req, user, customer, done) ->
return done({res: 'Database error.', code: 500}) unless req.body.stripe?.subscribeEmails?
emails = req.body.stripe.subscribeEmails.map((email) -> email.trim().toLowerCase() unless _.isEmpty(email))
_.remove(emails, (email) -> _.isEmpty(email))
User.find {emailLower: {$in: emails}}, (err, recipients) =>
if err
@logSubscriptionError(user, "User lookup error. " + err)
return done({res: 'Database error.', code: 500})
createUpdateFn = (recipient) =>
(done) =>
# Find existing recipient subscription
findStripeSubscription customer.id, userID: recipient.id, (subscription) =>
if subscription
if subscription.cancel_at_period_end
# Things are a little tricky here. Can't re-enable a cancelled subscription,
# so it needs to be deleted, but also don't want to charge for the new subscription immediately.
# So delete the cancelled subscription (no at_period_end given here) and give the new
# subscription a trial period that ends when the cancelled subscription would have ended.
stripe.customers.cancelSubscription subscription.customer, subscription.id, (err) =>
if err
@logSubscriptionError(user, 'Stripe cancel subscription error. ' + err)
return done({res: 'Database error.', code: 500})
options =
plan: 'basic'
coupon: recipientCouponID
metadata: {id: recipient.id}
trial_end: subscription.current_period_end
stripe.customers.createSubscription customer.id, options, (err, subscription) =>
if err
@logSubscriptionError(user, 'Stripe new subscription error. ' + err)
return done({res: 'Database error.', code: 500})
done(null, recipient: recipient, subscription: subscription, increment: false)
else
# Can skip creating the subscription
done(null, recipient: recipient, subscription: subscription, increment: false)
else
options =
plan: 'basic'
coupon: recipientCouponID
metadata: {id: recipient.id}
stripe.customers.createSubscription customer.id, options, (err, subscription) =>
if err
@logSubscriptionError(user, 'Stripe new subscription error. ' + err)
return done({res: 'Database error.', code: 500})
done(null, recipient: recipient, subscription: subscription, increment: true)
tasks = []
for recipient in recipients
continue if recipient.id is user.id
continue if recipient.get('stripe')?.subscriptionID?
continue if recipient.get('stripe')?.sponsorID? and recipient.get('stripe')?.sponsorID isnt user.id
tasks.push createUpdateFn(recipient)
# NOTE: async.parellel yields this error:
# Subscription Error: user23 (54fe3c8fea98978efa469f3b): 'Stripe new subscription error. Error: Request rate limit exceeded'
async.series tasks, (err, results) =>
return done(err) if err
@updateCocoRecipientSubscriptions(req, user, customer, results, done)
updateCocoRecipientSubscriptions: (req, user, customer, stripeRecipients, done) ->
# Update recipients list
stripeInfo = _.cloneDeep(user.get('stripe') ? {})
stripeInfo.recipients ?= []
stripeRecipientIDs = (sub.recipient.id for sub in stripeRecipients)
_.remove(stripeInfo.recipients, (s) -> s.userID in stripeRecipientIDs)
for sub in stripeRecipients
stripeInfo.recipients.push
userID: sub.recipient.id
subscriptionID: sub.subscription.id
couponID: recipientCouponID
# TODO: how does token get removed for personal subs?
delete stripeInfo.subscribeEmails
delete stripeInfo.token
req.body.stripe = stripeInfo
user.set('stripe', stripeInfo)
user.save (err) =>
if err
@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()
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, done)
updateStripeSponsorSubscription: (req, user, customer, done) ->
stripeInfo = user.get('stripe') ? {}
numSponsored = stripeInfo.recipients.length
quantity = getSponsoredSubsAmount(subscriptions.basic.amount, numSponsored, stripeInfo.subscriptionID?)
findStripeSubscription customer.id, subscriptionID: stripeInfo.sponsorSubscriptionID, (subscription) =>
if stripeInfo.sponsorSubscriptionID? and not subscription?
@logSubscriptionError(user, "Internal sponsor subscription #{stripeInfo.sponsorSubscriptionID} not found on Stripe customer #{customer.id}")
return done({res: 'Database error.', code: 500})
if subscription
return done() if quantity is subscription.quantity # E.g. cancelled sub has been resubbed
options = quantity: quantity
stripe.customers.updateSubscription customer.id, stripeInfo.sponsorSubscriptionID, options, (err, subscription) =>
if err
@logSubscriptionError(user, 'Stripe updating subscription quantity error. ' + err)
return done({res: 'Database error.', code: 500})
# Invoice proration immediately
stripe.invoices.create customer: customer.id, (err, invoice) =>
if err
@logSubscriptionError(user, 'Stripe proration invoice error. ' + err)
return done({res: 'Database error.', code: 500})
done()
else
options =
plan: 'incremental'
metadata: {id: user.id}
quantity: quantity
stripe.customers.createSubscription customer.id, options, (err, subscription) =>
if err
@logSubscriptionError(user, 'Stripe new subscription error. ' + err)
return done({res: 'Database error.', code: 500})
@updateCocoSponsorSubscription(req, user, subscription, done)
updateCocoSponsorSubscription: (req, user, subscription, done) ->
stripeInfo = _.cloneDeep(user.get('stripe') ? {})
stripeInfo.sponsorSubscriptionID = subscription.id
req.body.stripe = stripeInfo
user.set('stripe', stripeInfo)
user.save (err) =>
if err
@logSubscriptionError(user, 'Saving user stripe error. ' + err)
return done({res: 'Database error.', code: 500})
done()
unsubscribeUser: (req, user, done) -> unsubscribeUser: (req, user, done) ->
stripeInfo = _.cloneDeep(user.get('stripe')) # Check if user is subscribing someone else
return @unsubscribeRecipient(req, user, done) if req.body.stripe?.unsubscribeEmail?
stripeInfo = _.cloneDeep(user.get('stripe') ? {})
stripe.customers.cancelSubscription stripeInfo.customerID, stripeInfo.subscriptionID, { at_period_end: true }, (err) => stripe.customers.cancelSubscription stripeInfo.customerID, stripeInfo.subscriptionID, { at_period_end: true }, (err) =>
if err if err
@logSubscriptionError(user, 'Stripe cancel subscription error. '+err) @logSubscriptionError(user, 'Stripe cancel subscription error. ' + err)
return done({res: 'Database error.', code: 500}) return done({res: 'Database error.', code: 500})
delete stripeInfo.planID delete stripeInfo.planID
user.set('stripe', stripeInfo) user.set('stripe', stripeInfo)
req.body.stripe = stripeInfo req.body.stripe = stripeInfo
user.save (err) => user.save (err) =>
if err if err
@logSubscriptionError(user, 'User save unsubscribe error. '+err) @logSubscriptionError(user, 'User save unsubscribe error. ' + err)
return done({res: 'Database error.', code: 500}) return done({res: 'Database error.', code: 500})
user?.saveActiveUser 'unsubscribe' done()
return done()
unsubscribeRecipient: (req, user, done) ->
return done({res: 'Database error.', code: 500}) unless req.body.stripe?.unsubscribeEmail?
email = req.body.stripe.unsubscribeEmail.trim().toLowerCase()
return done({res: 'Database error.', code: 500}) if _.isEmpty(email)
User.findOne {emailLower: email}, (err, recipient) =>
if err
@logSubscriptionError(user, "User lookup error. " + err)
return done({res: 'Database error.', code: 500})
unless recipient
@logSubscriptionError(user, "Recipient #{req.body.stripe.recipient} not found. " + err)
return done({res: 'Database error.', code: 500})
# Check recipient is currently sponsored
stripeRecipient = recipient.get 'stripe' ? {}
if stripeRecipient.sponsorID isnt user.id
@logSubscriptionError(user, "Recipient #{req.body.stripe.recipient} not found. " + err)
return done({res: 'Can only unsubscribe sponsored subscriptions.', code: 403})
# Find recipient subscription
stripeInfo = _.cloneDeep(user.get('stripe') ? {})
for sponsored in stripeInfo.recipients
if sponsored.userID is recipient.id
sponsoredEntry = sponsored
break
unless sponsoredEntry?
@logSubscriptionError(user, 'Unable to find sponsored subscription. ' + err)
return done({res: 'Database error.', code: 500})
# Cancel Stripe subscription
stripe.customers.cancelSubscription stripeInfo.customerID, sponsoredEntry.subscriptionID, { at_period_end: true }, (err) =>
if err or not recipient
@logSubscriptionError(user, "Stripe cancel sponsored subscription failed. " + err)
return done({res: 'Database error.', code: 500})
delete stripeInfo.unsubscribeEmail
user.set('stripe', stripeInfo)
req.body.stripe = stripeInfo
user.save (err) =>
if err
@logSubscriptionError(user, 'User save unsubscribe error. ' + err)
return done({res: 'Database error.', code: 500})
done()
module.exports = new SubscriptionHandler() module.exports = new SubscriptionHandler()

View file

@ -76,7 +76,7 @@ PurchaseHandler = class PurchaseHandler extends Handler
user.set('purchased', purchased) user.set('purchased', purchased)
#- deduct the gems from the user #- deduct the gems from the user
spent = hadSpent = user.get('spent') ? 0 spent = user.get('spent') ? 0
spent += item.get('gems') spent += item.get('gems')
user.set('spent', spent) user.set('spent', spent)

View file

@ -1,59 +1,112 @@
async = require 'async'
config = require '../../server_config' config = require '../../server_config'
stripe = require('stripe')(config.stripe.secretKey) stripe = require('stripe')(config.stripe.secretKey)
User = require '../users/User' User = require '../users/User'
Payment = require '../payments/Payment' Payment = require '../payments/Payment'
errors = require '../commons/errors' errors = require '../commons/errors'
mongoose = require 'mongoose'
utils = require '../../app/core/utils'
module.exports.setup = (app) -> module.exports.setup = (app) ->
# Cache customer -> user ID map (increases test perf considerably)
customerUserMap = {}
app.post '/stripe/webhook', (req, res) -> app.post '/stripe/webhook', (req, res) ->
if req.body.type is 'invoice.payment_succeeded' # if they actually paid, give em some gems
invoiceID = req.body.data.object.id # Subscription renewal events:
stripe.invoices.retrieve invoiceID, (err, invoice) => # https://support.stripe.com/questions/what-events-can-i-see-when-a-subscription-is-renewed
return res.send(500, '') if err
return res.send(200, '') unless invoice.total # invoices made when trialing, probably given for people who resubscribe after unsubscribing
stripe.customers.retrieve invoice.customer, (err, customer) =>
return res.send(500, '') if err
userID = customer.metadata.id
User.findById userID, (err, user) =>
return res.send(500, '') if err
return res.send(200) if not user # just for the sake of testing...
Payment.findOne {'stripe.invoiceID': invoiceID}, (err, payment) =>
return res.send(200, '') if payment
payment = new Payment({
'purchaser': user._id
'recipient': user._id
'created': new Date().toISOString()
'service': 'stripe'
'amount': invoice.total
'gems': 3500
'stripe': {
customerID: invoice.customer
invoiceID: invoice.id
subscriptionID: 'basic'
}
})
payment.save (err) =>
return res.send(500, '') if err
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 ?= {}
purchased.gems = gems
user.set('purchased', purchased)
user.save (err) ->
return res.send(500, '') if err
return res.send(201, '')
if req.body.type is 'invoice.payment_succeeded'
return handlePaymentSucceeded req, res
else if req.body.type is 'customer.subscription.deleted' else if req.body.type is 'customer.subscription.deleted'
User.findOne {'stripe.subscriptionID': req.body.data.object.id}, (err, user) -> return handleSubscriptionDeleted req, res
return res.send(200, '') unless user else # ignore all other notifications
return res.send(200, '')
stripeInfo = _.cloneDeep(user.get('stripe')) app.get '/stripe/coupons', (req, res) ->
return errors.forbidden(res) unless req.user?.isAdmin()
stripe.coupons.list {limit: 100}, (err, coupons) ->
return errors.serverError(res) if err
res.send(200, coupons.data)
return res.end()
handlePaymentSucceeded = (req, res) ->
# if they actually paid, give em some gems
getUserID = (customerID, done) =>
# Asumming Stripe customer never has a different userID
return done(null, customerUserMap[customerID]) if customerID of customerUserMap
stripe.customers.retrieve customerID, (err, customer) =>
return done(err) if err
customerUserMap[customerID] = customer.metadata.id
return done(null, customerUserMap[customerID])
invoiceID = req.body.data.object.id
stripe.invoices.retrieve invoiceID, (err, invoice) =>
return res.send(500, '') if err
unless invoice.total or invoice.discount?.coupon?.id is 'free'
# invoices made when trialing, probably given for people who resubscribe after unsubscribing
return res.send(200, '')
return res.send(200, '') unless invoice.lines?.data?.length > 0
getUserID invoice.customer, (err, userID) =>
return res.send(500, '') if err
# User is recipient if no metadata.id
recipientID = invoice.lines.data[0].metadata?.id or userID
# Subscription id location depends on invoice line_item type
subscriptionID = invoice.lines.data[0].subscription or invoice.lines.data[0].id
User.findById recipientID, (err, recipient) =>
return res.send(500, '') if err
return res.send(200) unless recipient # just for the sake of testing...
Payment.findOne {'stripe.invoiceID': invoiceID}, (err, payment) =>
return res.send(200, '') if payment
payment = new Payment({
'purchaser': mongoose.Types.ObjectId(userID)
'recipient': recipient._id
'created': new Date().toISOString()
'service': 'stripe'
'amount': invoice.total
'stripe': {
customerID: invoice.customer
invoiceID: invoice.id
subscriptionID: subscriptionID
}
})
payment.set 'gems', 3500 if invoice.lines.data[0].plan?.id is 'basic'
payment.save (err) =>
return res.send(500, '') if err
return res.send(201, '') if invoice.lines.data[0].plan?.id isnt 'basic'
# Update purchased gems
# TODO: is this correct for a resub?
Payment.find({recipient: recipient._id, gems: {$exists: true}}).select('gems').exec (err, payments) ->
gems = _.reduce payments, ((sum, p) -> sum + p.get('gems')), 0
purchased = _.clone(recipient.get('purchased'))
purchased ?= {}
purchased.gems = gems
recipient.set('purchased', purchased)
recipient.save (err) ->
return res.send(500, '') if err
return res.send(201, '')
handleSubscriptionDeleted = (req, res) ->
# Three variants:
# normal - Personal subscription deleted
# recipeint - Subscription sponsored by another user is being deleted.
# sponsor - Aggregate subscription used to pay for multiple recipient subscriptions. Ugh.
subscription = req.body.data.object
checkNormalSubscription = (done) ->
User.findOne {'stripe.subscriptionID': subscription.id}, (err, user) ->
return done() unless user
stripeInfo = _.cloneDeep(user.get('stripe') ? {})
delete stripeInfo.planID delete stripeInfo.planID
delete stripeInfo.subscriptionID delete stripeInfo.subscriptionID
user.set('stripe', stripeInfo) user.set('stripe', stripeInfo)
@ -61,14 +114,71 @@ module.exports.setup = (app) ->
return res.send(500, '') if err return res.send(500, '') if err
return res.send(200, '') return res.send(200, '')
else # ignore all other notifications checkRecipientSubscription = (done) ->
return res.send(200, '') return done() unless subscription.plan.id is 'basic'
User.findById subscription.metadata.id, (err, recipient) =>
app.get '/stripe/coupons', (req, res) -> return res.send(500, '') if err
return errors.forbidden(res) unless req.user?.isAdmin() return res.send(500, '') unless recipient
stripe.coupons.list {limit: 100}, (err, coupons) -> User.findById recipient.get('stripe').sponsorID, (err, sponsor) =>
return errors.serverError(res) if err return res.send(500, '') if err
res.send(200, coupons.data) return res.send(500, '') unless sponsor
return res.end()
# Update sponsor subscription
stripeInfo = _.cloneDeep(sponsor.get('stripe') ? {})
_.remove(stripeInfo.recipients, (s) -> s.userID is recipient.id)
options =
quantity: utils.getSponsoredSubsAmount(subscription.plan.amount, stripeInfo.recipients.length, stripeInfo.subscriptionID?)
stripe.customers.updateSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, options, (err, subscription) =>
return res.send(500, '') if err
# Update sponsor user
sponsor.set 'stripe', stripeInfo
sponsor.save (err) =>
return res.send(500, '') if err
# Update recipient user
stripeInfo = recipient.get('stripe')
delete stripeInfo.sponsorID
if _.isEmpty stripeInfo
recipient.set 'stripe', undefined
else
recipient.set 'stripe', stripeInfo
recipient.save (err) =>
return res.send(500, '') if err
return res.send(200, '')
checkSponsorSubscription = (done) ->
return done() unless subscription.plan.id is 'incremental'
customerID = subscription.customer
createUpdateFn = (sub) ->
(callback) ->
# Cancel Stripe recipient subscription
stripe.customers.cancelSubscription customerID, sub.subscriptionID, { at_period_end: true }, (err) ->
callback err
User.findById subscription.metadata.id, (err, sponsor) =>
return res.send(500, '') if err
stripeInfo = _.cloneDeep(sponsor.get('stripe') ? {})
# Cancel all recipient subscriptions
async.parallel (createUpdateFn(sub) for sub in stripeInfo.recipients), (err, results) =>
return res.send(500, '') if err
# Update sponsor user
delete stripeInfo.sponsorSubscriptionID
delete stripeInfo.recipients # Loses remaining credit on a re-subscribe for previous user
if _.isEmpty stripeInfo
sponsor.set 'stripe', undefined
else
sponsor.set 'stripe', stripeInfo
sponsor.save (err) =>
return res.send(500, '') if err
done()
# TODO: use async.series for this
checkNormalSubscription ->
checkRecipientSubscription ->
checkSponsorSubscription ->
res.send(200, '')

View file

@ -213,6 +213,7 @@ UserSchema.methods.isPremium = ->
return true if @isInGodMode() return true if @isInGodMode()
return true if @isAdmin() return true if @isAdmin()
return false unless stripeObject = @get('stripe') return false unless stripeObject = @get('stripe')
return true if stripeObject.sponsorID
return true if stripeObject.subscriptionID return true if stripeObject.subscriptionID
return true if stripeObject.free is true return true if stripeObject.free is true
return true if _.isString(stripeObject.free) and new Date() < new Date(stripeObject.free) return true if _.isString(stripeObject.free) and new Date() < new Date(stripeObject.free)

View file

@ -16,6 +16,7 @@ SubscriptionHandler = require '../payments/subscription_handler'
DiscountHandler = require '../payments/discount_handler' DiscountHandler = require '../payments/discount_handler'
EarnedAchievement = require '../achievements/EarnedAchievement' EarnedAchievement = require '../achievements/EarnedAchievement'
UserRemark = require './remarks/UserRemark' UserRemark = require './remarks/UserRemark'
{findStripeSubscription} = require '../lib/utils'
{isID} = require '../lib/utils' {isID} = require '../lib/utils'
hipchat = require '../hipchat' hipchat = require '../hipchat'
sendwithus = require '../sendwithus' sendwithus = require '../sendwithus'
@ -115,22 +116,35 @@ UserHandler = class UserHandler extends Handler
# Subscription setting # Subscription setting
(req, user, callback) -> (req, user, callback) ->
# TODO: Make subscribe vs. unsubscribe explicit. This property dance is confusing.
return callback(null, req, user) unless req.headers['x-change-plan'] # ensure only saves that are targeted at changing the subscription actually affect the subscription return callback(null, req, user) unless req.headers['x-change-plan'] # ensure only saves that are targeted at changing the subscription actually affect the subscription
return callback(null, req, user) unless req.body.stripe return callback(null, req, user) unless req.body.stripe
hasPlan = user.get('stripe')?.planID? finishSubscription = (hasPlan, wantsPlan) ->
wantsPlan = req.body.stripe.planID? return callback(null, req, user) if hasPlan is wantsPlan
if wantsPlan and not hasPlan
return callback(null, req, user) if hasPlan is wantsPlan SubscriptionHandler.subscribeUser(req, user, (err) ->
if wantsPlan and not hasPlan return callback(err) if err
return callback(null, req, user)
)
else if hasPlan and not wantsPlan
SubscriptionHandler.unsubscribeUser(req, user, (err) ->
return callback(err) if err
return callback(null, req, user)
)
if req.body.stripe.subscribeEmails?
SubscriptionHandler.subscribeUser(req, user, (err) -> SubscriptionHandler.subscribeUser(req, user, (err) ->
return callback(err) if err return callback(err) if err
return callback(null, req, user) return callback(null, req, user)
) )
else if hasPlan and not wantsPlan else if req.body.stripe.unsubscribeEmail?
SubscriptionHandler.unsubscribeUser(req, user, (err) -> SubscriptionHandler.unsubscribeUser(req, user, (err) ->
return callback(err) if err return callback(err) if err
return callback(null, req, user) return callback(null, req, user)
) )
else
wantsPlan = req.body.stripe.planID?
hasPlan = user.get('stripe')?.planID?
finishSubscription hasPlan, wantsPlan
# Discount setting # Discount setting
(req, user, callback) -> (req, user, callback) ->
@ -257,6 +271,8 @@ UserHandler = class UserHandler extends Handler
return @getRemark(req, res, args[0]) if args[1] is 'remark' return @getRemark(req, res, args[0]) if args[1] is 'remark'
return @searchForUser(req, res) if args[1] is 'admin_search' return @searchForUser(req, res) if args[1] is 'admin_search'
return @getStripeInfo(req, res, args[0]) if args[1] is 'stripe' return @getStripeInfo(req, res, args[0]) if args[1] is 'stripe'
return @getSubRecipients(req, res) if args[1] is 'sub_recipients'
return @getSubSponsor(req, res) if args[1] is 'sub_sponsor'
return @sendOneTimeEmail(req, res, args[0]) if args[1] is 'send_one_time_email' return @sendOneTimeEmail(req, res, args[0]) if args[1] is 'send_one_time_email'
return @sendNotFoundError(res) return @sendNotFoundError(res)
super(arguments...) super(arguments...)
@ -268,7 +284,67 @@ UserHandler = class UserHandler extends Handler
return @sendNotFoundError(res) if not customerID = user.get('stripe')?.customerID return @sendNotFoundError(res) if not customerID = user.get('stripe')?.customerID
stripe.customers.retrieve customerID, (err, customer) => stripe.customers.retrieve customerID, (err, customer) =>
return @sendDatabaseError(res, err) if err return @sendDatabaseError(res, err) if err
@sendSuccess(res, JSON.stringify(customer, null, '\t')) info = card: customer.sources?.data?[0]
findStripeSubscription customerID, subscriptionID: user.get('stripe').subscriptionID, (subscription) =>
info.subscription = subscription
findStripeSubscription customerID, subscriptionID: user.get('stripe').sponsorSubscriptionID, (subscription) =>
info.sponsorSubscription = subscription
@sendSuccess(res, JSON.stringify(info, null, '\t'))
getSubRecipients: (req, res) ->
# Return map of userIDs to name/email/cancel date
# TODO: Add test for this API
return @sendSuccess(res, {}) if _.isEmpty(req.user?.get('stripe')?.recipients ? [])
return @sendSuccess(res, {}) unless req.user.get('stripe')?.customerID?
# Get recipients User info
ids = (recipient.userID for recipient in req.user.get('stripe').recipients)
User.find({'_id': { $in: ids} }, 'name emailLower').exec (err, users) =>
info = {}
_.each users, (user) -> info[user.id] = user.toObject()
customerID = req.user.get('stripe').customerID
nextBatch = (starting_after, done) ->
options = limit: 100
options.starting_after = starting_after if starting_after
stripe.customers.listSubscriptions customerID, options, (err, subscriptions) ->
return done(err) if err
return done() unless subscriptions?.data?.length > 0
for sub in subscriptions.data
userID = sub.metadata?.id
continue unless userID of info
if sub.cancel_at_period_end and info[userID]['cancel_at_period_end'] isnt false
info[userID]['cancel_at_period_end'] = new Date(sub.current_period_end * 1000)
else
info[userID]['cancel_at_period_end'] = false
if subscriptions.has_more
return nextBatch(subscriptions.data[subscriptions.data.length - 1].id, done)
else
return done()
nextBatch null, (err) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, info)
getSubSponsor: (req, res) ->
# TODO: Add test for this API
return @sendSuccess(res, {}) unless req.user?.get('stripe')?.sponsorID?
# Get sponsor User info
User.findById req.user.get('stripe').sponsorID, (err, sponsor) =>
return @sendDatabaseError(res, err) if err
return @sendDatabaseError(res, 'No sponsor customerID') unless sponsor?.get('stripe')?.customerID?
info =
email: sponsor.get('emailLower')
name: sponsor.get('name')
# Get recipient subscription info
findStripeSubscription sponsor.get('stripe').customerID, userID: req.user.id, (subscription) =>
info.subscription = subscription
@sendDatabaseError(res, 'No sponsored subscription found') unless info.subscription?
@sendSuccess(res, info)
sendOneTimeEmail: (req, res) -> sendOneTimeEmail: (req, res) ->
# TODO: Should this API be somewhere else? # TODO: Should this API be somewhere else?

View file

@ -4,6 +4,7 @@
console.log 'IT BEGINS' console.log 'IT BEGINS'
require 'jasmine-spec-reporter' require 'jasmine-spec-reporter'
jasmine.getEnv().defaultTimeoutInterval = 300000
jasmine.getEnv().reporter.subReporters_ = [] jasmine.getEnv().reporter.subReporters_ = []
jasmine.getEnv().addReporter(new jasmine.SpecReporter({ jasmine.getEnv().addReporter(new jasmine.SpecReporter({
displaySuccessfulSpec: true, displaySuccessfulSpec: true,
@ -21,7 +22,6 @@ mongoose.connect('mongodb://localhost/coco_unittest')
path = require 'path' path = require 'path'
GLOBAL.testing = true GLOBAL.testing = true
GLOBAL.tv4 = require 'tv4' # required for TreemaUtils to work GLOBAL.tv4 = require 'tv4' # required for TreemaUtils to work
# _.str = require 'underscore.string'
models_path = [ models_path = [
'../../server/analytics/AnalyticsUsersActive' '../../server/analytics/AnalyticsUsersActive'
@ -113,6 +113,25 @@ wrapUpGetUser = (email, user, done) ->
GLOBAL.getURL = (path) -> GLOBAL.getURL = (path) ->
return 'http://localhost:3001' + path return 'http://localhost:3001' + path
newUserCount = 0
GLOBAL.createNewUser = (done) ->
name = password = "user#{newUserCount++}"
email = "#{name}@foo.bar"
unittest.getUser name, email, password, done, true
GLOBAL.loginNewUser = (done) ->
name = password = "user#{newUserCount++}"
email = "#{name}@me.com"
request.post getURL('/auth/logout'), ->
unittest.getUser name, email, password, (user) ->
req = request.post(getURL('/auth/login'), (error, response) ->
expect(response.statusCode).toBe(200)
done(user)
)
form = req.form()
form.append('username', email)
form.append('password', password)
, true
GLOBAL.loginJoe = (done) -> GLOBAL.loginJoe = (done) ->
request.post getURL('/auth/logout'), -> request.post getURL('/auth/logout'), ->
unittest.getNormalJoe (user) -> unittest.getNormalJoe (user) ->
@ -147,6 +166,16 @@ GLOBAL.loginAdmin = (done) ->
form.append('password', '80yqxpb38j') form.append('password', '80yqxpb38j')
# find some other way to make the admin object an admin... maybe directly? # find some other way to make the admin object an admin... maybe directly?
GLOBAL.loginUser = (user, done) ->
request.post getURL('/auth/logout'), ->
req = request.post(getURL('/auth/login'), (error, response) ->
expect(response.statusCode).toBe(200)
done(user)
)
form = req.form()
form.append('username', user.get('email'))
form.append('password', user.get('name'))
GLOBAL.dropGridFS = (done) -> GLOBAL.dropGridFS = (done) ->
if mongoose.connection.readyState is 2 if mongoose.connection.readyState is 2
mongoose.connection.once 'open', -> mongoose.connection.once 'open', ->

View file

@ -66,7 +66,7 @@ describe '/db/user, editing stripe.couponID property', ->
expect(body.stripe.couponID).toBe('500off') expect(body.stripe.couponID).toBe('500off')
done() done()
it 'applies a discount to the newly created customer when a plan is set', (done) -> it 'applies a discount to the newly created subscription when a plan is set', (done) ->
stripe.tokens.create { stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) -> }, (err, token) ->
@ -77,9 +77,9 @@ describe '/db/user, editing stripe.couponID property', ->
request.put {uri: userURL, json: joeData, headers: {'X-Change-Plan': 'true'} }, (err, res, body) -> request.put {uri: userURL, json: joeData, headers: {'X-Change-Plan': 'true'} }, (err, res, body) ->
joeData = body joeData = body
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
stripe.customers.retrieve joeData.stripe.customerID, (err, customer) -> stripe.customers.retrieveSubscription joeData.stripe.customerID, joeData.stripe.subscriptionID, (err, subscription) ->
expect(customer.discount).toBeDefined() expect(subscription.discount).toBeDefined()
expect(customer.discount?.coupon.id).toBe('500off') expect(subscription.discount?.coupon.id).toBe('500off')
done() done()

View file

@ -1,9 +1,11 @@
async = require 'async'
config = require '../../../server_config' config = require '../../../server_config'
require '../common' require '../common'
utils = require '../../../app/core/utils' # Must come after require /common
mongoose = require 'mongoose'
# sample data that comes in through the webhook when you subscribe # sample data that comes in through the webhook when you subscribe
invoiceChargeSampleEvent = { invoiceChargeSampleEvent = {
id: 'evt_155TBeKaReE7xLUdrKM72O5R', id: 'evt_155TBeKaReE7xLUdrKM72O5R',
created: 1417574898, created: 1417574898,
livemode: false, livemode: false,
@ -38,13 +40,13 @@ invoiceChargeSampleEvent = {
metadata: {}, metadata: {},
statement_description: null, statement_description: null,
description: null, description: null,
receipt_number: null receipt_number: null
} }
}, },
object: 'event', object: 'event',
pending_webhooks: 1, pending_webhooks: 1,
request: 'iar_5Fz9c4BZJyNNsM', request: 'iar_5Fz9c4BZJyNNsM',
api_version: '2014-11-05' api_version: '2015-02-18'
} }
customerSubscriptionDeletedSampleEvent = { customerSubscriptionDeletedSampleEvent = {
@ -70,13 +72,13 @@ customerSubscriptionDeletedSampleEvent = {
quantity: 1, quantity: 1,
application_fee_percent: null, application_fee_percent: null,
discount: null, discount: null,
metadata: {} metadata: {}
} }
}, },
object: 'event', object: 'event',
pending_webhooks: 1, pending_webhooks: 1,
request: 'iar_5FziYQJ4oQdL6w', request: 'iar_5FziYQJ4oQdL6w',
api_version: '2014-11-05' api_version: '2015-02-18'
} }
@ -103,7 +105,7 @@ describe '/db/user, editing stripe property', ->
#- shared data between tests #- shared data between tests
joeData = null joeData = null
firstSubscriptionID = null firstSubscriptionID = null
it 'returns client error when a token fails to charge', (done) -> it 'returns client error when a token fails to charge', (done) ->
stripe.tokens.create { stripe.tokens.create {
card: { number: '4000000000000002', exp_month: 12, exp_year: 2020, cvc: '123' } card: { number: '4000000000000002', exp_month: 12, exp_year: 2020, cvc: '123' }
@ -118,7 +120,7 @@ describe '/db/user, editing stripe property', ->
request.put {uri: userURL, json: joeData, headers: headers }, (err, res, body) -> request.put {uri: userURL, json: joeData, headers: headers }, (err, res, body) ->
expect(res.statusCode).toBe(402) expect(res.statusCode).toBe(402)
done() done()
it 'creates a subscription when you put a token and plan', (done) -> it 'creates a subscription when you put a token and plan', (done) ->
stripe.tokens.create { stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
@ -139,14 +141,14 @@ describe '/db/user, editing stripe property', ->
expect(joeData.stripe.planID).toBe('basic') expect(joeData.stripe.planID).toBe('basic')
expect(joeData.stripe.token).toBeUndefined() expect(joeData.stripe.token).toBeUndefined()
done() done()
it 'records a payment through the webhook', (done) -> it 'records a payment through the webhook', (done) ->
# Don't even want to think about hooking in tests to webhooks, so... put in some data manually # Don't even want to think about hooking in tests to webhooks, so... put in some data manually
stripe.invoices.list {customer: joeData.stripe.customerID}, (err, invoices) -> stripe.invoices.list {customer: joeData.stripe.customerID}, (err, invoices) ->
expect(invoices.data.length).toBe(1) expect(invoices.data.length).toBe(1)
event = _.cloneDeep(invoiceChargeSampleEvent) event = _.cloneDeep(invoiceChargeSampleEvent)
event.data.object = invoices.data[0] event.data.object = invoices.data[0]
request.post {uri: webhookURL, json: event}, (err, res, body) -> request.post {uri: webhookURL, json: event}, (err, res, body) ->
expect(res.statusCode).toBe(201) expect(res.statusCode).toBe(201)
Payment.find {}, (err, payments) -> Payment.find {}, (err, payments) ->
@ -154,7 +156,7 @@ describe '/db/user, editing stripe property', ->
User.findById joeData._id, (err, user) -> User.findById joeData._id, (err, user) ->
expect(user.get('purchased').gems).toBe(3500) expect(user.get('purchased').gems).toBe(3500)
done() done()
it 'schedules the stripe subscription to be cancelled when stripe.planID is removed from the user', (done) -> it 'schedules the stripe subscription to be cancelled when stripe.planID is removed from the user', (done) ->
delete joeData.stripe.planID delete joeData.stripe.planID
request.put {uri: userURL, json: joeData, headers: headers }, (err, res, body) -> request.put {uri: userURL, json: joeData, headers: headers }, (err, res, body) ->
@ -168,19 +170,18 @@ describe '/db/user, editing stripe property', ->
expect(customer.subscriptions.data[0].cancel_at_period_end).toBe(true) expect(customer.subscriptions.data[0].cancel_at_period_end).toBe(true)
done() done()
it 'allows you to sign up again using the same customer ID as before, no token necessary', (done) -> it 'allows you to sign up again using the same customer ID as before, no token necessary', (done) ->
joeData.stripe.planID = 'basic' joeData.stripe.planID = 'basic'
request.put {uri: userURL, json: joeData, headers: headers }, (err, res, body) -> request.put {uri: userURL, json: joeData, headers: headers }, (err, res, body) ->
joeData = body joeData = body
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
expect(joeData.stripe.customerID).toBeDefined() expect(joeData.stripe.customerID).toBeDefined()
expect(joeData.stripe.subscriptionID).toBeDefined() expect(joeData.stripe.subscriptionID).toBeDefined()
expect(joeData.stripe.subscriptionID).not.toBe(firstSubscriptionID) expect(joeData.stripe.subscriptionID).not.toBe(firstSubscriptionID)
expect(joeData.stripe.planID).toBe('basic') expect(joeData.stripe.planID).toBe('basic')
done() done()
it 'will not have immediately created new payments when signing back up from a cancelled subscription', (done) -> it 'will not have immediately created new payments when signing back up from a cancelled subscription', (done) ->
stripe.invoices.list {customer: joeData.stripe.customerID}, (err, invoices) -> stripe.invoices.list {customer: joeData.stripe.customerID}, (err, invoices) ->
expect(invoices.data.length).toBe(2) expect(invoices.data.length).toBe(2)
@ -206,7 +207,7 @@ describe '/db/user, editing stripe property', ->
expect(user.get('stripe').subscriptionID).toBeUndefined() expect(user.get('stripe').subscriptionID).toBeUndefined()
expect(user.get('stripe').planID).toBeUndefined() expect(user.get('stripe').planID).toBeUndefined()
done() done()
it "updates the customer's email when you change the user's email", (done) -> it "updates the customer's email when you change the user's email", (done) ->
joeData.email = 'newEmail@gmail.com' joeData.email = 'newEmail@gmail.com'
request.put {uri: userURL, json: joeData, headers: headers }, (err, res, body) -> request.put {uri: userURL, json: joeData, headers: headers }, (err, res, body) ->
@ -214,3 +215,762 @@ describe '/db/user, editing stripe property', ->
expect(customer.email).toBe('newEmail@gmail.com') expect(customer.email).toBe('newEmail@gmail.com')
done() done()
setTimeout(f, 500) # bit of a race condition here, response returns before stripe has been updated setTimeout(f, 500) # bit of a race condition here, response returns before stripe has been updated
describe 'Sponsored subscriptions', ->
# TODO: Test recurring billing via webhooks
# TODO: Test error rollbacks, Stripe is authority
stripe = require('stripe')(config.stripe.secretKey)
userURL = getURL('/db/user')
webhookURL = getURL('/stripe/webhook')
headers = {'X-Change-Plan': 'true'}
subPrice = 999
subGems = 3500
invoicesWebHooked = {}
# Start helpers
getSubscribedQuantity = (numSponsored) ->
return 0 if numSponsored < 1
if numSponsored <= 10
Math.round(numSponsored * subPrice * 0.8)
else
Math.round(10 * subPrice * 0.8 + (numSponsored - 10) * subPrice * 0.6)
getUnsubscribedQuantity = (numSponsored) ->
return 0 if numSponsored < 1
if numSponsored <= 1
subPrice
else if numSponsored <= 11
Math.round(subPrice + (numSponsored - 1) * subPrice * 0.8)
else
Math.round(subPrice + 10 * subPrice * 0.8 + (numSponsored - 11) * subPrice * 0.6)
verifyNotRecipient = (userID, done) ->
User.findById userID, (err, user) ->
expect(err).toBeNull()
if stripeInfo = user.get('stripe')
expect(stripeInfo.sponsorID).toBeUndefined()
done()
verifyNotSponsoring = (sponsorID, recipientID, done) ->
# console.log 'verifyNotSponsoring', sponsorID, recipientID
User.findById sponsorID, (err, sponsor) ->
expect(err).toBeNull()
stripeInfo = sponsor.get('stripe')
return done() unless stripeInfo?.customerID?
checkSubscriptions = (starting_after, done) ->
options = {}
options.starting_after = starting_after if starting_after
stripe.customers.listSubscriptions stripeInfo.customerID, options, (err, subscriptions) ->
expect(err).toBeNull()
for subscription in subscriptions.data
if subscription.plan.id is 'basic'
expect(subscription.metadata.id).not.toEqual(recipientID)
if subscription.plan.id is 'incremental'
expect(subscription.metadata.id).toEqual(sponsorID)
if subscriptions.has_more
checkSubscriptions subscriptions.data[subscriptions.data.length - 1].id, done
else
done()
checkSubscriptions null, done
verifySponsorship = (sponsorUserID, sponsoredUserID, done) ->
# console.log 'verifySponsorship', sponsorUserID, sponsoredUserID
User.findById sponsorUserID, (err, user) ->
expect(err).toBeNull()
expect(user).not.toBeNull()
sponsorStripe = user.get('stripe')
sponsorCustomerID = sponsorStripe.customerID
numSponsored = sponsorStripe.recipients.length
expect(sponsorCustomerID).toBeDefined()
expect(sponsorStripe.sponsorSubscriptionID).toBeDefined()
expect(sponsorStripe.token).toBeUndefined()
expect(numSponsored).toBeGreaterThan(0)
# Verify Stripe sponsor subscription data
stripe.customers.retrieveSubscription sponsorCustomerID, sponsorStripe.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull()
expect(subscription.plan.amount).toEqual(1)
expect(subscription.customer).toEqual(sponsorCustomerID)
expect(subscription.quantity).toEqual(utils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?))
# Verify sponsor payment
# May be greater than expected amount due to multiple subscribes and unsubscribes
paymentQuery =
purchaser: mongoose.Types.ObjectId(sponsorUserID)
recipient: mongoose.Types.ObjectId(sponsorUserID)
"stripe.customerID": sponsorCustomerID
"stripe.subscriptionID": sponsorStripe.sponsorSubscriptionID
expectedAmount = utils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?)
Payment.find paymentQuery, (err, payments) ->
expect(err).toBeNull()
expect(payments).not.toBeNull()
amount = 0
for payment in payments
amount += payment.get('amount')
expect(payment.get('gems')).toBeUndefined()
# NOTE: this amount may be greater than the expected amount due to proration accumlation
# NOTE: during localy execution, this is usually only 1-2 cents
expect(amount).toBeGreaterThan(expectedAmount - 50)
# Find recipient info from sponsor stripe data
for r in sponsorStripe.recipients
if r.userID is sponsoredUserID
recipientInfo = r
break
expect(recipientInfo).toBeDefined()
expect(recipientInfo.subscriptionID).toBeDefined()
expect(recipientInfo.subscriptionID).toNotEqual(sponsorStripe.sponsorSubscriptionID)
expect(recipientInfo.couponID).toEqual('free')
# Verify Stripe recipient subscription data
stripe.customers.retrieveSubscription sponsorCustomerID, recipientInfo.subscriptionID, (err, subscription) ->
expect(err).toBeNull()
expect(subscription.plan.amount).toEqual(subPrice)
expect(subscription.customer).toEqual(sponsorCustomerID)
expect(subscription.quantity).toEqual(1)
expect(subscription.metadata.id).toEqual(sponsoredUserID)
expect(subscription.discount.coupon.id).toEqual(recipientInfo.couponID)
# Verify recipient internal data
User.findById sponsoredUserID, (err, recipient) ->
expect(err).toBeNull()
stripeInfo = recipient.get('stripe')
expect(stripeInfo.sponsorID).toEqual(sponsorUserID)
unless stripeInfo.sponsorSubscriptionID?
expect(stripeInfo.customerID).toBeUndefined()
expect(stripeInfo.token).toBeUndefined()
expect(recipient.get('purchased').gems).toBeGreaterThan(subGems - 1)
expect(recipient.isPremium()).toEqual(true)
# Verify recipient payment
# TODO: Not accurate enough when resubscribing a user
paymentQuery =
purchaser: mongoose.Types.ObjectId(sponsorUserID)
recipient: mongoose.Types.ObjectId(sponsoredUserID)
"stripe.customerID": sponsorCustomerID
Payment.findOne paymentQuery, (err, payment) ->
expect(err).toBeNull()
expect(payment).not.toBeNull()
expect(payment.get('amount')).toEqual(0)
expect(payment.get('gems')).toBeGreaterThan(subGems - 1)
done()
subscribeUser = (user, token, done) ->
requestBody = user.toObject()
requestBody.stripe =
planID: 'basic'
requestBody.stripe.token = token.id if token?
request.put {uri: userURL, json: requestBody, headers: headers }, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
expect(body.stripe.customerID).toBeDefined()
expect(body.stripe.planID).toBe('basic')
expect(body.stripe.token).toBeUndefined()
expect(body.purchased.gems).toBeGreaterThan(subGems - 1)
done()
unsubscribeUser = (user, done) ->
requestBody = user.toObject()
delete requestBody.stripe.planID
request.put {uri: userURL, json: requestBody, headers: headers }, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
User.findById user.id, (err, user) ->
expect(user.get('stripe').customerID).toBeDefined()
expect(user.get('stripe').planID).toBeUndefined()
expect(user.get('stripe').token).toBeUndefined()
done()
subscribeRecipients = (sponsor, recipients, token, done) ->
# console.log 'subscribeRecipients', sponsor.id, (recipient.id for recipient in recipients), token?
requestBody = sponsor.toObject()
requestBody.stripe =
subscribeEmails: (recipient.get('email') for recipient in recipients)
requestBody.stripe.token = token.id if token?
request.put {uri: userURL, json: requestBody, headers: headers }, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
expect(body.stripe.customerID).toBeDefined()
updatedUser = body
# Call webhooks for invoices
options = customer: body.stripe.customerID, limit: 100
stripe.invoices.list options, (err, invoices) ->
expect(err).toBeNull()
expect(invoices.has_more).toEqual(false)
makeWebhookCall = (invoice) ->
(callback) ->
event = _.cloneDeep(invoiceChargeSampleEvent)
event.data.object = invoice
# console.log 'Calling webhook', event.type, invoice.id
request.post {uri: webhookURL, json: event}, (err, res, body) ->
callback err
webhookTasks = []
for invoice in invoices.data
unless invoice.id of invoicesWebHooked
invoicesWebHooked[invoice.id] = true
webhookTasks.push makeWebhookCall(invoice)
async.parallel webhookTasks, (err, results) ->
expect(err?).toEqual(false)
done(updatedUser)
unsubscribeRecipient = (sponsor, recipient, immediately, done) ->
# console.log 'unsubscribeRecipient', sponsor.id, recipient.id
stripeInfo = sponsor.get('stripe')
customerID = stripeInfo.customerID
for r in stripeInfo.recipients
if r.userID is recipient.id
subscriptionID = r.subscriptionID
break
expect(customerID).toBeDefined()
expect(subscriptionID).toBeDefined()
# Find Stripe subscription
stripe.customers.retrieveSubscription customerID, subscriptionID, (err, subscription) ->
expect(err).toBeNull()
expect(subscription).not.toBeNull()
# Call unsubscribe API
requestBody = sponsor.toObject()
requestBody.stripe = unsubscribeEmail: recipient.get('email')
request.put {uri: userURL, json: requestBody, headers: headers }, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
# Simulate subscription ending after cancellation
return done() unless immediately
# Simulate subscription cancelling a trial end
stripe.customers.cancelSubscription customerID, subscriptionID, (err) ->
expect(err).toBeNull()
# Simulate customer.subscription.deleted webhook event
event = _.cloneDeep(customerSubscriptionDeletedSampleEvent)
event.data.object = subscription
request.post {uri: webhookURL, json: event}, (err, res, body) ->
expect(err).toBeNull()
done()
# Subscribe a bunch of recipients at once, used for bulk discount testing
class SubbedRecipients
constructor: (@count, @toVerify) ->
@index = 0
@recipients = []
length: ->
@recipients.length
get: (i) ->
@recipients[i]
createRecipients: (done) ->
return done() if @recipients.length is @count
createNewUser (user) =>
@recipients.push user
@createRecipients done
subRecipients: (user1, token=null, done) ->
# console.log 'subRecipients', user1.id, @recipients.length
User.findById user1.id, (err, user1) =>
subscribeRecipients user1, @recipients, token, (updatedUser) =>
verifyIndex = 0
verify = =>
return done(updatedUser) if verifyIndex >= @toVerify.length
verifySponsorship user1.id, @recipients[verifyIndex].id, =>
verifyIndex++
verify()
verify()
# End helpers
# TODO: Use beforeAll()
it 'Clear database users and payments', (done) ->
clearModels [User, Payment], (err) ->
throw err if err
done()
describe 'Basic', ->
it 'Unsubscribed user1 subscribes user2', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeRecipients user1, [user2], token, (updatedUser) ->
verifySponsorship user1.id, user2.id, done
it 'Unsubscribed user1 unsubscribes user2 and their sub ends', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeRecipients user1, [user2], token, (updatedUser) ->
User.findById user1.id, (err, user1) ->
unsubscribeRecipient user1, user2, true, ->
verifyNotSponsoring user1.id, user2.id, ->
verifyNotRecipient user2.id, done
it 'Unsubscribed user1 immediately resubscribes user2, one token', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeRecipients user1, [user2], token, (updatedUser) ->
User.findById user1.id, (err, user1) ->
unsubscribeRecipient user1, user2, false, ->
subscribeRecipients user1, [user2], null, (updatedUser) ->
verifySponsorship user1.id, user2.id, done
it 'Sponsored user2 subscribes their sponsor user1', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeRecipients user1, [user2], token, (updatedUser) ->
loginUser user2, (user2) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
subscribeRecipients user2, [user1], token, (updatedUser) ->
verifySponsorship user1.id, user2.id, ->
verifySponsorship user2.id, user1.id, done
it 'Unsubscribed user1 subscribes user1', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
loginNewUser (user1) ->
requestBody = user1.toObject()
requestBody.stripe =
subscribeEmails: [user1.get('email')]
token: token.id
request.put {uri: userURL, json: requestBody, headers: headers }, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
User.findById user1.id, (err, user) ->
expect(err).toBeNull()
stripeInfo = user.get('stripe')
expect(stripeInfo.customerID).toBeDefined()
expect(stripeInfo.planID).toBeUndefined()
expect(stripeInfo.subscriptionID).toBeUndefined()
expect(stripeInfo.recipients.length).toEqual(0)
done()
it 'Subscribed user1 subscribes user2, one token', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeUser user1, token, (updatedUser) ->
User.findById user1.id, (err, user1) ->
expect(err).toBeNull()
subscribeRecipients user1, [user2], null, (updatedUser) ->
User.findById user1.id, (err, user1) ->
expect(err).toBeNull()
expect(user1.get('stripe').subscriptionID).toBeDefined()
expect(user1.isPremium()).toEqual(true)
verifySponsorship user1.id, user2.id, done
it 'Subscribed user1 unsubscribes user2', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeUser user1, token, (updatedUser) ->
User.findById user1.id, (err, user1) ->
expect(err).toBeNull()
subscribeRecipients user1, [user2], null, (updatedUser) ->
User.findById user1.id, (err, user1) ->
unsubscribeRecipient user1, user2, true, ->
User.findById user1.id, (err, user1) ->
expect(err).toBeNull()
expect(user1.get('stripe').subscriptionID).toBeDefined()
expect(user1.isPremium()).toEqual(true)
User.findById user2.id, (err, user2) ->
verifyNotSponsoring user1.id, user2.id, ->
verifyNotRecipient user2.id, done
it 'Subscribed user1 subscribes user2, unsubscribes themselves', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeUser user1, token, (updatedUser) ->
User.findById user1.id, (err, user1) ->
expect(err).toBeNull()
subscribeRecipients user1, [user2], null, (updatedUser) ->
User.findById user1.id, (err, user1) ->
unsubscribeUser user1, (updatedUser) ->
verifySponsorship user1.id, user2.id, done
it 'Sponsored user2 tries to subscribe', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeRecipients user1, [user2], token, (updatedUser) ->
loginUser user2, (user2) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
requestBody = user2.toObject()
requestBody.stripe =
token: token.id
planID: 'basic'
request.put {uri: userURL, json: requestBody, headers: headers }, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(403)
done()
it 'Sponsored user2 tries to unsubscribe', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeRecipients user1, [user2], token, (updatedUser) ->
loginUser user2, (user2) ->
requestBody = user2.toObject()
requestBody.stripe =
recipient: user2.id
request.put {uri: userURL, json: requestBody, headers: headers }, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
verifySponsorship user1.id, user2.id, done
it 'Cancel sponsor subscription with 2 recipient subscriptions, then subscribe 1 old and 1 new', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user3) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeRecipients user1, [user2, user3], token, (updatedUser) ->
customerID = updatedUser.stripe.customerID
subscriptionID = updatedUser.stripe.sponsorSubscriptionID
# Find Stripe sponsor subscription
stripe.customers.retrieveSubscription customerID, subscriptionID, (err, subscription) ->
expect(err).toBeNull()
expect(subscription).not.toBeNull()
# Cancel Stripe sponsor subscription
stripe.customers.cancelSubscription customerID, subscriptionID, (err) ->
expect(err).toBeNull()
# Simulate customer.subscription.deleted webhook event for sponsor subscription
event = _.cloneDeep(customerSubscriptionDeletedSampleEvent)
event.data.object = subscription
request.post {uri: webhookURL, json: event}, (err, res, body) ->
expect(err).toBeNull()
# Should have 2 cancelled recipient subs with cancel_at_period_end = true
User.findById user1.id, (err, user1) ->
expect(err).toBeNull()
stripeInfo = user1.get('stripe')
expect(stripeInfo.sponsorSubscriptionID).toBeUndefined()
expect(stripeInfo.recipients).toBeUndefined()
stripe.customers.listSubscriptions stripeInfo.customerID, (err, subscriptions) ->
expect(err).toBeNull()
expect(subscriptions.data.length).toEqual(2)
for sub in subscriptions.data
expect(sub.plan.id).toEqual('basic')
expect(sub.cancel_at_period_end).toEqual(true)
# Subscribe user3 back
User.findById user1.id, (err, user1) ->
subscribeRecipients user1, [user3], null, (updatedUser) ->
verifySponsorship user1.id, user3.id, ->
# Subscribe new user4
createNewUser (user4) ->
loginUser user1, (user1) ->
User.findById user1.id, (err, user1) ->
subscribeRecipients user1, [user4], null, (updatedUser) ->
verifySponsorship user1.id, user4.id, done
it 'Subscribing two users separately yields proration payment', (done) ->
# TODO: Use test plan with low duration + setTimeout to test delay between 2 subscribes
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user3) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeRecipients user1, [user2], token, (updatedUser) ->
User.findById user1.id, (err, user1) ->
subscribeRecipients user1, [user3], null, (updatedUser) ->
# TODO: What do we expect invoices to show here?
stripe.invoices.list {customer: updatedUser.stripe.customerID}, (err, invoices) ->
expect(err).toBeNull()
# Verify for proration invoice
foundProratedInvoice = false
for invoice in invoices.data
line = invoice.lines.data[0]
if line.type is 'invoiceitem' and line.proration
totalAmount = utils.getSponsoredSubsAmount(subPrice, 2, false)
expect(invoice.total).toBeLessThan(totalAmount)
expect(invoice.total).toEqual(totalAmount - subPrice)
Payment.findOne "stripe.invoiceID": invoice.id, (err, payment) ->
expect(err).toBeNull()
expect(payment.get('amount')).toEqual(invoice.total)
done()
foundProratedInvoice = true
break
unless foundProratedInvoice
expect(foundProratedInvoice).toEqual(true)
done()
it 'Invalid subscribeEmails', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
loginNewUser (user1) ->
requestBody = user1.toObject()
requestBody.stripe =
subscribeEmails: ['invalid@user.com', 'notemailformat', '', null, undefined]
requestBody.stripe.token = token.id
request.put {uri: userURL, json: requestBody, headers: headers }, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
expect(body.stripe).toBeDefined()
User.findById user1.id, (err, sponsor) ->
expect(err).toBeNull()
expect(sponsor.get('stripe')).toBeDefined()
expect(sponsor.get('stripe').customerID).toBeDefined()
expect(sponsor.get('stripe').sponsorSubscriptionID).toBeDefined()
expect(sponsor.get('stripe').recipients?.length).toEqual(0)
done()
it 'User1 subscribes user2 then themselves', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeRecipients user1, [user2], token, (updatedUser) ->
User.findById user1.id, (err, user1) ->
expect(err).toBeNull()
verifySponsorship user1.id, user2.id, ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
subscribeUser user1, token, (updatedUser) ->
User.findById user1.id, (err, user1) ->
expect(err).toBeNull()
expect(user1.get('stripe').subscriptionID).toBeDefined()
expect(user1.isPremium()).toEqual(true)
stripe.customers.listSubscriptions user1.get('stripe').customerID, (err, subscriptions) ->
expect(err).toBeNull()
expect(subscriptions.data.length).toEqual(3)
for sub in subscriptions.data
if sub.plan.id is 'basic'
if sub.discount?.coupon?.id is 'free'
expect(sub.metadata?.id).toEqual(user2.id)
else
expect(sub.metadata?.id).toEqual(user1.id)
else
expect(sub.plan.id).toEqual('incremental')
expect(sub.metadata?.id).toEqual(user1.id)
done()
describe 'Bulk discounts', ->
# Bulk discount algorithm (includes personal sub):
# 1 100%
# 2-11 80%
# 12+ 60%
it 'Unsubscribed user1 subscribes two users', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
createNewUser (user3) ->
createNewUser (user2) ->
loginNewUser (user1) ->
subscribeRecipients user1, [user2, user3], token, (updatedUser) ->
verifySponsorship user1.id, user2.id, ->
verifySponsorship user1.id, user3.id, done
it 'Subscribed user1 subscribes 2 users, unsubscribes 2', (done) ->
recipientCount = 2
recipientsToVerify = [0, 1]
recipients = new SubbedRecipients recipientCount, recipientsToVerify
# Create recipients
recipients.createRecipients ->
expect(recipients.length()).toEqual(recipientCount)
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
# Create sponsor user
loginNewUser (user1) ->
subscribeUser user1, token, (updatedUser) ->
User.findById user1.id, (err, user1) ->
expect(err).toBeNull()
# Subscribe recipients
recipients.subRecipients user1, null, ->
User.findById user1.id, (err, user1) ->
# Unsubscribe recipient0
unsubscribeRecipient user1, recipients.get(0), true, ->
User.findById user1.id, (err, user1) ->
stripeInfo = user1.get('stripe')
expect(stripeInfo.recipients.length).toEqual(1)
verifyNotSponsoring user1.id, recipients.get(0).id, ->
verifyNotRecipient recipients.get(0).id, ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull()
expect(subscription).not.toBeNull()
expect(subscription.quantity).toEqual(getSubscribedQuantity(1))
# Unsubscribe recipient1
unsubscribeRecipient user1, recipients.get(1), true, ->
User.findById user1.id, (err, user1) ->
stripeInfo = user1.get('stripe')
expect(stripeInfo.recipients.length).toEqual(0)
verifyNotSponsoring user1.id, recipients.get(1).id, ->
verifyNotRecipient recipients.get(1).id, ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull()
expect(subscription).not.toBeNull()
expect(subscription.quantity).toEqual(0)
done()
it 'Subscribed user1 subscribes 3 users, unsubscribes 2, themselves, then 1', (done) ->
recipientCount = 3
recipientsToVerify = [0, 1, 2]
recipients = new SubbedRecipients recipientCount, recipientsToVerify
# Create recipients
recipients.createRecipients ->
expect(recipients.length()).toEqual(recipientCount)
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
# Create sponsor user
loginNewUser (user1) ->
subscribeUser user1, token, (updatedUser) ->
User.findById user1.id, (err, user1) ->
expect(err).toBeNull()
# Subscribe recipients
recipients.subRecipients user1, null, ->
User.findById user1.id, (err, user1) ->
# Unsubscribe first recipient
unsubscribeRecipient user1, recipients.get(0), true, ->
User.findById user1.id, (err, user1) ->
stripeInfo = user1.get('stripe')
expect(stripeInfo.recipients.length).toEqual(recipientCount - 1)
verifyNotSponsoring user1.id, recipients.get(0).id, ->
verifyNotRecipient recipients.get(0).id, ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull()
expect(subscription).not.toBeNull()
expect(subscription.quantity).toEqual(getSubscribedQuantity(recipientCount - 1))
# Unsubscribe second recipient
unsubscribeRecipient user1, recipients.get(1), true, ->
User.findById user1.id, (err, user1) ->
stripeInfo = user1.get('stripe')
expect(stripeInfo.recipients.length).toEqual(recipientCount - 2)
verifyNotSponsoring user1.id, recipients.get(1).id, ->
verifyNotRecipient recipients.get(1).id, ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull()
expect(subscription).not.toBeNull()
expect(subscription.quantity).toEqual(getSubscribedQuantity(recipientCount - 2))
# Unsubscribe self
User.findById user1.id, (err, user1) ->
unsubscribeUser user1, ->
User.findById user1.id, (err, user1) ->
stripeInfo = user1.get('stripe')
expect(stripeInfo.planID).toBeUndefined()
# Unsubscribe third recipient
verifySponsorship user1.id, recipients.get(2).id, ->
unsubscribeRecipient user1, recipients.get(2), true, ->
User.findById user1.id, (err, user1) ->
stripeInfo = user1.get('stripe')
expect(stripeInfo.recipients.length).toEqual(recipientCount - 3)
verifyNotSponsoring user1.id, recipients.get(2).id, ->
verifyNotRecipient recipients.get(2).id, ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull()
expect(subscription).not.toBeNull()
expect(subscription.quantity).toEqual(getSubscribedQuantity(recipientCount - 3))
done()
it 'Unsubscribed user1 subscribes 13 users, unsubcribes 2', (done) ->
# TODO: verify interim invoices?
recipientCount = 13
recipientsToVerify = [0, 1, 10, 11, 12]
recipients = new SubbedRecipients recipientCount, recipientsToVerify
# Create recipients
recipients.createRecipients ->
expect(recipients.length()).toEqual(recipientCount)
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
# Create sponsor user
loginNewUser (user1) ->
# Subscribe recipients
recipients.subRecipients user1, token, ->
User.findById user1.id, (err, user1) ->
# Unsubscribe first recipient
unsubscribeRecipient user1, recipients.get(0), true, ->
User.findById user1.id, (err, user1) ->
stripeInfo = user1.get('stripe')
expect(stripeInfo.recipients.length).toEqual(recipientCount - 1)
verifyNotSponsoring user1.id, recipients.get(0).id, ->
verifyNotRecipient recipients.get(0).id, ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull()
expect(subscription).not.toBeNull()
expect(subscription.quantity).toEqual(getUnsubscribedQuantity(recipientCount - 1))
# Unsubscribe last recipient
unsubscribeRecipient user1, recipients.get(recipientCount - 1), true, ->
User.findById user1.id, (err, user1) ->
stripeInfo = user1.get('stripe')
expect(stripeInfo.recipients.length).toEqual(recipientCount - 2)
verifyNotSponsoring user1.id, recipients.get(recipientCount - 1).id, ->
verifyNotRecipient recipients.get(recipientCount - 1).id, ->
stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
expect(err).toBeNull()
expect(subscription).not.toBeNull()
numSponsored = recipientCount - 2
if numSponsored <= 1
expect(subscription.quantity).toEqual(subPrice)
else if numSponsored <= 11
expect(subscription.quantity).toEqual(subPrice + (numSponsored - 1) * subPrice * 0.8)
else
expect(subscription.quantity).toEqual(subPrice + 10 * subPrice * 0.8 + (numSponsored - 11) * subPrice * 0.6)
done()