From af89452b930574042557dc163c266d68b3bd8dec Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Fri, 13 Mar 2015 15:19:20 -0700 Subject: [PATCH] Merge branch 'sponsored-subs' --- app/core/utils.coffee | 14 + app/locale/en.coffee | 25 + app/models/User.coffee | 1 + app/schemas/models/user.coffee | 18 +- app/styles/account/subscription-view.sass | 22 + app/templates/account/subscription-view.jade | 201 +++-- app/views/account/SubscriptionView.coffee | 247 +++++- app/views/admin/UsersView.coffee | 2 +- server/lib/utils.coffee | 31 + server/payments/subscription_handler.coffee | 310 ++++++- server/purchases/purchase_handler.coffee | 2 +- server/routes/stripe.coffee | 220 +++-- server/users/User.coffee | 1 + server/users/user_handler.coffee | 90 +- test/server/common.coffee | 31 +- .../functional/discount_handler.spec.coffee | 8 +- .../functional/subscription.spec.coffee | 794 +++++++++++++++++- 17 files changed, 1807 insertions(+), 210 deletions(-) diff --git a/app/core/utils.coffee b/app/core/utils.coffee index cd6d079df..f5a846e59 100644 --- a/app/core/utils.coffee +++ b/app/core/utils.coffee @@ -184,3 +184,17 @@ module.exports.getQueryVariable = getQueryVariable = (param, defaultValue) -> for pair in pairs when pair[0] is param return {'true': true, 'false': false}[pair[1]] ? decodeURIComponent(pair[1]) 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) diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 8a0c52a63..88c3b0119 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -432,6 +432,31 @@ subscription_required_to_play: "You'll need a subscription to play this level." 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 Your Hero" programming_language: "Programming Language" diff --git a/app/models/User.coffee b/app/models/User.coffee index 2a3904ac3..75d081685 100644 --- a/app/models/User.coffee +++ b/app/models/User.coffee @@ -139,6 +139,7 @@ module.exports = class User extends CocoModel return true if me.isInGodMode() return true if me.isAdmin() return false unless stripe = @get('stripe') + return true if stripe.sponsorID return true if stripe.subscriptionID return true if stripe.free is true return true if _.isString(stripe.free) and new Date() < new Date(stripe.free) diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 7c8c2431e..933ab9179 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -283,12 +283,22 @@ _.extend UserSchema.properties, stripe: c.object {}, { customerID: { type: 'string' } - planID: { enum: ['basic'] } - subscriptionID: { type: 'string' } + planID: { enum: ['basic'], description: 'Determines if a user has or wants to subscribe' } + subscriptionID: { type: 'string', description: 'Determines if a user is subscribed' } token: { type: 'string' } couponID: { type: 'string' } - discountID: { type: 'string' } - free: { type: ['boolean', 'string'], format: 'date-time' } + free: { type: ['boolean', 'string'], format: 'date-time', description: 'Type string is subscription end date' } + + # 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' } diff --git a/app/styles/account/subscription-view.sass b/app/styles/account/subscription-view.sass index 875d798c3..8dbfe70b8 100644 --- a/app/styles/account/subscription-view.sass +++ b/app/styles/account/subscription-view.sass @@ -16,3 +16,25 @@ button.btn width: 100% 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 diff --git a/app/templates/account/subscription-view.jade b/app/templates/account/subscription-view.jade index 95e3e6952..e57a78dba 100644 --- a/app/templates/account/subscription-view.jade +++ b/app/templates/account/subscription-view.jade @@ -10,54 +10,161 @@ block content a(href="/account", data-i18n="nav.account") li.active(data-i18n="account.subscription") - .panel.panel-default - .panel-heading - if subscribed - button.end-subscription-button.btn.btn-lg.btn-warning(data-i18n="subscribe.unsubscribe") Unsubscribe - .unsubscribe-feedback.row.secret - .col-lg-7 - h3 - if monthsSubscribed > 1 - span(data-i18n="subscribe.thank_you_months_prefix") Thank you for supporting us these last - span.spl.spr= 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 - else if !me.isAnonymous() - button.start-subscription-button.btn.btn-lg.btn-success(data-i18n="subscribe.subscribe_title") Subscribe - - .panel-body - table.table.table-striped - tr - th(data-i18n="user.status") Status - td - if subscribed - strong(data-i18n="account.subscribed") + if me.get('anonymous') + p(data-i18n="account_settings.not_logged_in") + else + + //- Personal Subscriptions + + .panel.panel-default + .panel-heading + h3(data-i18n="subscribe.personal_sub") + .panel-body + if personalSub.state === 'loading' + .alert.alert-info(data-i18n="subscribe.loading_info") + else if personalSub.sponsor + div + span(data-i18n="subscribe.managed_by") + span.spl.spr #{personalSub.sponsorName} (#{personalSub.sponsorEmail}) + if personalSub.endDate + div + span(data-i18n="subscribe.will_be_cancelled") + span.spl.spr= moment(personalSub.endDate).format('l') + else if personalSub.self + if personalSub.subscribed + button.end-subscription-button.btn.btn-lg.btn-warning(data-i18n="subscribe.unsubscribe") Unsubscribe + else + button.start-subscription-button.btn.btn-lg.btn-success(data-i18n="subscribe.subscribe_title") Subscribe + .unsubscribe-feedback.row.secret + .col-lg-7 + 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 - if 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 activeUntil + span(data-i18n="subscribe.was_free_until") + span.spl.spr= moment(new Date(personalSub.free)).format('l') + + //- Sponsored Subscriptions + + .panel.panel-default + .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 - th(data-i18n="account.active_until") - td= moment(activeUntil).format('l') - if nextPaymentDate + td(data-i18n="subscribe.group_discounts_2nd") + td(data-i18n="subscribe.group_discounts_20") tr - th(data-i18n="account.next_payment") - td= moment(nextPaymentDate).format('l') - if cost + td(data-i18n="subscribe.group_discounts_12th") + td(data-i18n="subscribe.group_discounts_40") + .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") × + else if recipientSubs.state === 'unknown_error' + br + .alert.alert-danger.alert-dismissible + button.close(type="button" data-dismiss="alert") + span(aria-hidden="true") × + 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 - th(data-i18n="account.cost") - td= cost - if card - tr - th(data-i18n="account.card") - td= card + th(data-i18n="general.email") + th(data-i18n="general.name") + th + for recipient in recipientSubs.recipients + tr + 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") + diff --git a/app/views/account/SubscriptionView.coffee b/app/views/account/SubscriptionView.coffee index 4e3dafbe1..e0404a837 100644 --- a/app/views/account/SubscriptionView.coffee +++ b/app/views/account/SubscriptionView.coffee @@ -3,6 +3,24 @@ template = require 'templates/account/subscription-view' CocoCollection = require 'collections/CocoCollection' SubscribeModal = require 'views/core/SubscribeModal' 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 id: "subscription-view" @@ -13,41 +31,29 @@ module.exports = class SubscriptionView extends RootView 'click .end-subscription-button': 'onClickEndSubscription' 'click .cancel-end-subscription-button': 'onClickCancelEndSubscription' 'click .confirm-end-subscription-button': 'onClickConfirmEndSubscription' + 'click .recipients-subscribe-button': 'onClickRecipientsSubscribe' + 'click .confirm-recipient-unsubscribe-button': 'onClickRecipientConfirmUnsubscribe' + 'click .recipient-unsubscribe-button': 'onClickRecipientUnsubscribe' subscriptions: 'subscribe-modal:subscribed': 'onSubscribed' + 'stripe:received-token': 'onStripeReceivedToken' constructor: (options) -> super(options) - if me.get('stripe') - options = { cache: false, url: "/db/user/#{me.id}/stripe" } - options.success = (@stripeInfo) => - @supermodel.addRequestResource('payment_info', options).load() - @payments = new CocoCollection([], { url: '/db/payment', model: Payment, comparator:'_id' }) - @supermodel.loadCollection(@payments, 'payments', {cache: false}) + @personalSub = new PersonalSub(@supermodel) + @recipientSubs = new RecipientSubs(@supermodel) + @personalSub.update => @render?() + @recipientSubs.update => @render?() getRenderData: -> c = super() - if @stripeInfo - if subscription = @stripeInfo.subscriptions?.data?[0] - 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.personalSub = @personalSub + c.recipientSubs = @recipientSubs c + # Personal Subscriptions + onClickStartSubscription: (e) -> @openModalView new SubscribeModal() window.tracker?.trackEvent 'Show subscription modal', category: 'Subscription', label: 'account subscription view' @@ -67,15 +73,198 @@ module.exports = class SubscriptionView extends RootView onClickConfirmEndSubscription: (e) -> 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 = => - stripe = _.clone(me.get('stripe')) - delete stripe.planID - me.set('stripe', stripe) + stripeInfo = _.clone(me.get('stripe')) + delete stripeInfo.planID + me.set('stripe', stripeInfo) + me.once 'sync', -> + window.tracker?.trackEvent 'Unsubscribe End', message: message + document.location.reload() me.patch({headers: {'X-Change-Plan': 'true'}}) - @listenToOnce me, 'sync', -> document.location.reload() if message $.post '/contact', message: message, subject: 'Cancellation', (response) -> removeStripe() else 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() diff --git a/app/views/admin/UsersView.coffee b/app/views/admin/UsersView.coffee index abd55d4a8..4a4b35906 100644 --- a/app/views/admin/UsersView.coffee +++ b/app/views/admin/UsersView.coffee @@ -17,7 +17,7 @@ module.exports = class UsersView extends RootView # http://mongoosejs.com/docs/queries.html # Each list in conditions is a function call. # The first arg is the function name - # The rest are the agrs for the function + # The rest are the args for the function conditions = [ ['limit', 20] diff --git a/server/lib/utils.coffee b/server/lib/utils.coffee index 0aab4b036..84e544615 100644 --- a/server/lib/utils.coffee +++ b/server/lib/utils.coffee @@ -4,6 +4,7 @@ mongoose = require 'mongoose' module.exports = isID: (id) -> _.isString(id) and id.length is 24 and id.match(/[a-f0-9]/gi)?.length is 24 + objectIdFromTimestamp: (timestamp) -> # mongoDB ObjectId contains creation date in first 4 bytes # So, it can be used instead of a redundant created field @@ -15,6 +16,36 @@ module.exports = hexSeconds = Math.floor(timestamp/1000).toString(16) # Create an ObjectId with that hex timestamp 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) -> unless str? log.error "getAnalyticsStringID given invalid str param" diff --git a/server/payments/subscription_handler.coffee b/server/payments/subscription_handler.coffee index 4a7463be5..6d4c8f93a 100644 --- a/server/payments/subscription_handler.coffee +++ b/server/payments/subscription_handler.coffee @@ -1,12 +1,18 @@ # 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. +async = require 'async' Handler = require '../commons/Handler' discountHandler = require './discount_handler' +User = require '../users/User' +{findStripeSubscription} = require '../lib/utils' +{getSponsoredSubsAmount} = require '../../app/core/utils' +recipientCouponID = 'free' subscriptions = { basic: { 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.') return done({res: 'Missing stripe token or customer ID.', code: 422}) + # Create/retrieve Stripe customer if token if customerID stripe.customers.update customerID, { card: token }, (err, customer) => if err or not customer # 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}) @checkForExistingSubscription(req, user, customer, done) else - newCustomer = { + options = card: token email: user.get('email') metadata: { id: user._id + '', slug: user.get('slug') } - } - - stripe.customers.create newCustomer, (err, customer) => + stripe.customers.create options, (err, customer) => if err if err.type in ['StripeCardError', 'StripeInvalidRequestError'] return done({res: 'Card error', code: 402}) else - @logSubscriptionError(user, 'Stripe customer creation error. '+err) + @logSubscriptionError(user, 'Stripe customer creation error. ' + err) return done({res: 'Database error.', code: 500}) stripeInfo = _.cloneDeep(user.get('stripe') ? {}) @@ -53,68 +58,78 @@ class SubscriptionHandler extends Handler user.set('stripe', stripeInfo) user.save (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}) @checkForExistingSubscription(req, user, customer, done) else stripe.customers.retrieve(customerID, (err, customer) => if err - @logSubscriptionError(user, 'Stripe customer creation error. '+err) + @logSubscriptionError(user, 'Stripe customer retrieve error. ' + err) return done({res: 'Database error.', code: 500}) @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 # SALE LOGIC # overwrite couponID with another for everyone-sales #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 - # 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}) + if subscription - options = { plan: 'basic', trial_end: subscription.current_period_end } - options.coupon = couponID if couponID - stripe.customers.update user.get('stripe').customerID, options, (err, customer) => + 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 customer plan setting error. '+err) + @logSubscriptionError(user, 'Stripe cancel subscription error. ' + err) 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 - # can skip creating the subscription - return @updateUser(req, user, customer, false, done) + options = { plan: 'basic', metadata: {id: user.id}} + 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 - 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, subscription, true, done) - @updateUser(req, user, customer, true, done) - - updateUser: (req, user, customer, increment, done) -> - subscription = customer.subscriptions.data[0] + updateUser: (req, user, customer, subscription, increment, done) -> stripeInfo = _.cloneDeep(user.get('stripe') ? {}) stripeInfo.planID = 'basic' stripeInfo.subscriptionID = subscription.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) if increment @@ -126,25 +141,232 @@ class SubscriptionHandler extends Handler user.save (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}) user?.saveActiveUser 'subscribe' 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) -> - 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) => if err - @logSubscriptionError(user, 'Stripe cancel subscription error. '+err) + @logSubscriptionError(user, 'Stripe cancel subscription error. ' + err) return done({res: 'Database error.', code: 500}) delete stripeInfo.planID user.set('stripe', stripeInfo) req.body.stripe = stripeInfo user.save (err) => if err - @logSubscriptionError(user, 'User save unsubscribe error. '+err) + @logSubscriptionError(user, 'User save unsubscribe error. ' + err) return done({res: 'Database error.', code: 500}) - user?.saveActiveUser 'unsubscribe' - return done() + 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() diff --git a/server/purchases/purchase_handler.coffee b/server/purchases/purchase_handler.coffee index dbcd5408c..220252f05 100644 --- a/server/purchases/purchase_handler.coffee +++ b/server/purchases/purchase_handler.coffee @@ -76,7 +76,7 @@ PurchaseHandler = class PurchaseHandler extends Handler user.set('purchased', purchased) #- deduct the gems from the user - spent = hadSpent = user.get('spent') ? 0 + spent = user.get('spent') ? 0 spent += item.get('gems') user.set('spent', spent) diff --git a/server/routes/stripe.coffee b/server/routes/stripe.coffee index 8559ea3a8..e54cb7413 100644 --- a/server/routes/stripe.coffee +++ b/server/routes/stripe.coffee @@ -1,59 +1,112 @@ +async = require 'async' config = require '../../server_config' stripe = require('stripe')(config.stripe.secretKey) User = require '../users/User' Payment = require '../payments/Payment' errors = require '../commons/errors' +mongoose = require 'mongoose' +utils = require '../../app/core/utils' module.exports.setup = (app) -> + # Cache customer -> user ID map (increases test perf considerably) + customerUserMap = {} + 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 - stripe.invoices.retrieve invoiceID, (err, invoice) => - 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, '') + # Subscription renewal events: + # https://support.stripe.com/questions/what-events-can-i-see-when-a-subscription-is-renewed + if req.body.type is 'invoice.payment_succeeded' + return handlePaymentSucceeded req, res else if req.body.type is 'customer.subscription.deleted' - User.findOne {'stripe.subscriptionID': req.body.data.object.id}, (err, user) -> - return res.send(200, '') unless user + return handleSubscriptionDeleted req, res + 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.subscriptionID user.set('stripe', stripeInfo) @@ -61,14 +114,71 @@ module.exports.setup = (app) -> return res.send(500, '') if err return res.send(200, '') - else # ignore all other notifications - return res.send(200, '') - - 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() - - \ No newline at end of file + checkRecipientSubscription = (done) -> + return done() unless subscription.plan.id is 'basic' + User.findById subscription.metadata.id, (err, recipient) => + return res.send(500, '') if err + return res.send(500, '') unless recipient + User.findById recipient.get('stripe').sponsorID, (err, sponsor) => + return res.send(500, '') if err + return res.send(500, '') unless sponsor + + # 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, '') diff --git a/server/users/User.coffee b/server/users/User.coffee index 50340281c..f6dcb5784 100644 --- a/server/users/User.coffee +++ b/server/users/User.coffee @@ -213,6 +213,7 @@ UserSchema.methods.isPremium = -> return true if @isInGodMode() return true if @isAdmin() return false unless stripeObject = @get('stripe') + return true if stripeObject.sponsorID return true if stripeObject.subscriptionID return true if stripeObject.free is true return true if _.isString(stripeObject.free) and new Date() < new Date(stripeObject.free) diff --git a/server/users/user_handler.coffee b/server/users/user_handler.coffee index 1822e30d2..1c92f746c 100644 --- a/server/users/user_handler.coffee +++ b/server/users/user_handler.coffee @@ -16,6 +16,7 @@ SubscriptionHandler = require '../payments/subscription_handler' DiscountHandler = require '../payments/discount_handler' EarnedAchievement = require '../achievements/EarnedAchievement' UserRemark = require './remarks/UserRemark' +{findStripeSubscription} = require '../lib/utils' {isID} = require '../lib/utils' hipchat = require '../hipchat' sendwithus = require '../sendwithus' @@ -115,22 +116,35 @@ UserHandler = class UserHandler extends Handler # Subscription setting (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.body.stripe - hasPlan = user.get('stripe')?.planID? - wantsPlan = req.body.stripe.planID? - - return callback(null, req, user) if hasPlan is wantsPlan - if wantsPlan and not hasPlan + finishSubscription = (hasPlan, wantsPlan) -> + return callback(null, req, user) if hasPlan is wantsPlan + if wantsPlan and not hasPlan + SubscriptionHandler.subscribeUser(req, user, (err) -> + 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) -> return callback(err) if err return callback(null, req, user) ) - else if hasPlan and not wantsPlan + else if req.body.stripe.unsubscribeEmail? SubscriptionHandler.unsubscribeUser(req, user, (err) -> return callback(err) if err return callback(null, req, user) ) + else + wantsPlan = req.body.stripe.planID? + hasPlan = user.get('stripe')?.planID? + finishSubscription hasPlan, wantsPlan # Discount setting (req, user, callback) -> @@ -257,6 +271,8 @@ UserHandler = class UserHandler extends Handler return @getRemark(req, res, args[0]) if args[1] is 'remark' return @searchForUser(req, res) if args[1] is 'admin_search' 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 @sendNotFoundError(res) super(arguments...) @@ -268,7 +284,67 @@ UserHandler = class UserHandler extends Handler return @sendNotFoundError(res) if not customerID = user.get('stripe')?.customerID stripe.customers.retrieve customerID, (err, customer) => 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) -> # TODO: Should this API be somewhere else? diff --git a/test/server/common.coffee b/test/server/common.coffee index ddde8dd3b..8d91a3db1 100644 --- a/test/server/common.coffee +++ b/test/server/common.coffee @@ -4,6 +4,7 @@ console.log 'IT BEGINS' require 'jasmine-spec-reporter' +jasmine.getEnv().defaultTimeoutInterval = 300000 jasmine.getEnv().reporter.subReporters_ = [] jasmine.getEnv().addReporter(new jasmine.SpecReporter({ displaySuccessfulSpec: true, @@ -21,7 +22,6 @@ mongoose.connect('mongodb://localhost/coco_unittest') path = require 'path' GLOBAL.testing = true GLOBAL.tv4 = require 'tv4' # required for TreemaUtils to work -# _.str = require 'underscore.string' models_path = [ '../../server/analytics/AnalyticsUsersActive' @@ -113,6 +113,25 @@ wrapUpGetUser = (email, user, done) -> GLOBAL.getURL = (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) -> request.post getURL('/auth/logout'), -> unittest.getNormalJoe (user) -> @@ -147,6 +166,16 @@ GLOBAL.loginAdmin = (done) -> form.append('password', '80yqxpb38j') # 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) -> if mongoose.connection.readyState is 2 mongoose.connection.once 'open', -> diff --git a/test/server/functional/discount_handler.spec.coffee b/test/server/functional/discount_handler.spec.coffee index 5fc2ec6fe..cffcad019 100644 --- a/test/server/functional/discount_handler.spec.coffee +++ b/test/server/functional/discount_handler.spec.coffee @@ -66,7 +66,7 @@ describe '/db/user, editing stripe.couponID property', -> expect(body.stripe.couponID).toBe('500off') 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 { card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } }, (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) -> joeData = body expect(res.statusCode).toBe(200) - stripe.customers.retrieve joeData.stripe.customerID, (err, customer) -> - expect(customer.discount).toBeDefined() - expect(customer.discount?.coupon.id).toBe('500off') + stripe.customers.retrieveSubscription joeData.stripe.customerID, joeData.stripe.subscriptionID, (err, subscription) -> + expect(subscription.discount).toBeDefined() + expect(subscription.discount?.coupon.id).toBe('500off') done() diff --git a/test/server/functional/subscription.spec.coffee b/test/server/functional/subscription.spec.coffee index 68711a9cf..7a39713f3 100644 --- a/test/server/functional/subscription.spec.coffee +++ b/test/server/functional/subscription.spec.coffee @@ -1,9 +1,11 @@ - +async = require 'async' config = require '../../../server_config' 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 -invoiceChargeSampleEvent = { +invoiceChargeSampleEvent = { id: 'evt_155TBeKaReE7xLUdrKM72O5R', created: 1417574898, livemode: false, @@ -38,13 +40,13 @@ invoiceChargeSampleEvent = { metadata: {}, statement_description: null, description: null, - receipt_number: null - } + receipt_number: null + } }, object: 'event', pending_webhooks: 1, request: 'iar_5Fz9c4BZJyNNsM', - api_version: '2014-11-05' + api_version: '2015-02-18' } customerSubscriptionDeletedSampleEvent = { @@ -70,13 +72,13 @@ customerSubscriptionDeletedSampleEvent = { quantity: 1, application_fee_percent: null, discount: null, - metadata: {} - } + metadata: {} + } }, object: 'event', pending_webhooks: 1, 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 joeData = null firstSubscriptionID = null - + it 'returns client error when a token fails to charge', (done) -> stripe.tokens.create { 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) -> expect(res.statusCode).toBe(402) done() - + it 'creates a subscription when you put a token and plan', (done) -> stripe.tokens.create { 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.token).toBeUndefined() 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 stripe.invoices.list {customer: joeData.stripe.customerID}, (err, invoices) -> expect(invoices.data.length).toBe(1) event = _.cloneDeep(invoiceChargeSampleEvent) event.data.object = invoices.data[0] - + request.post {uri: webhookURL, json: event}, (err, res, body) -> expect(res.statusCode).toBe(201) Payment.find {}, (err, payments) -> @@ -154,7 +156,7 @@ describe '/db/user, editing stripe property', -> User.findById joeData._id, (err, user) -> expect(user.get('purchased').gems).toBe(3500) done() - + it 'schedules the stripe subscription to be cancelled when stripe.planID is removed from the user', (done) -> delete joeData.stripe.planID 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) done() - it 'allows you to sign up again using the same customer ID as before, no token necessary', (done) -> joeData.stripe.planID = 'basic' request.put {uri: userURL, json: joeData, headers: headers }, (err, res, body) -> joeData = body - + expect(res.statusCode).toBe(200) expect(joeData.stripe.customerID).toBeDefined() expect(joeData.stripe.subscriptionID).toBeDefined() expect(joeData.stripe.subscriptionID).not.toBe(firstSubscriptionID) expect(joeData.stripe.planID).toBe('basic') 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) -> 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').planID).toBeUndefined() done() - + it "updates the customer's email when you change the user's email", (done) -> joeData.email = 'newEmail@gmail.com' 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') done() 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()