🐛Fix subs dashboard monthly churn and perf

Use Stripe events API to calculate monthly churn.
Move Stripe API page handling to the client.
This commit is contained in:
Matt Lott 2015-07-08 17:34:31 -07:00
parent a8e59c5302
commit 6831355649
4 changed files with 325 additions and 232 deletions

View file

@ -7,7 +7,7 @@ block content
else
if total === 0
h4 Fetching dashboard data...
h4= refreshDataState
else
.container-fluid
.row
@ -16,10 +16,10 @@ block content
div.count= total
.col-md-5.big-stat.remaining-count
div.description Remaining
div.count= total - cancelled
div.count= total - outstandingCancels.length
.col-md-5.big-stat.cancelled-count
div.description Cancelled
div.count= cancelled
div.description Cancels Outstanding
div.count= outstandingCancels.length
.col-md-5.big-stat.growth-rate
div.description 30 Day Total Growth
div.count #{monthlyGrowth.toFixed(1)}%
@ -38,7 +38,7 @@ block content
h2 Recent Subscribers
if !subscribers || subscribers.length < 1
h4 Fetching recent subscribers...
h4= refreshDataState
else
table.table.table-striped.table-condensed
thead.subscribers-thead
@ -63,10 +63,10 @@ block content
td
a(href="https://dashboard.stripe.com/customers/#{subscriber.customerID}", target="_blank")= subscriber.subscriptionID
td= subscriber.user.dateCreated.substring(0, 10)
td= subscriber.start.substring(0, 10)
td= subscriber.start.toISOString().substring(0, 10)
td
if subscriber.cancel
span= subscriber.cancel.substring(0, 10)
span= subscriber.cancel.toISOString().substring(0, 10)
td
if subscriber.user.stripe && subscriber.user.stripe.sponsorID
span *sponsored*
@ -87,7 +87,7 @@ block content
h2 Recent Cancellations
if !cancellations || cancellations.length < 1
h4 Fetching recent cancellations...
h4= refreshDataState
else
table.table.table-striped.table-condensed
thead.subscribers-thead
@ -116,9 +116,9 @@ block content
td= cancellation.user.dateCreated.substring(0, 10)
else
td
td= cancellation.start.substring(0, 10)
td= cancellation.cancel.substring(0, 10)
td= moment.duration(new Date(cancellation.cancel) - new Date(cancellation.start)).humanize()
td= cancellation.start.toISOString().substring(0, 10)
td= cancellation.cancel.toISOString().substring(0, 10)
td= moment.duration(cancellation.cancel - cancellation.start).humanize()
td= cancellation.level
if cancellation.user
td= cancellation.user.ageRange
@ -135,7 +135,7 @@ block content
h2 Subscriptions
if !subs || subs.length < 1
h4 Fetching subscriptions...
h4= refreshDataState
else
table.table.table-condensed
thead

View file

@ -31,6 +31,8 @@ module.exports = class AnalyticsSubscriptionsView extends RootView
context.cancelled = @cancellations?.length ? @cancelled ? 0
context.monthlyChurn = @monthlyChurn ? 0.0
context.monthlyGrowth = @monthlyGrowth ? 0.0
context.outstandingCancels = @outstandingCancels ? []
context.refreshDataState = @refreshDataState
context
afterRender: ->
@ -44,35 +46,117 @@ module.exports = class AnalyticsSubscriptionsView extends RootView
@cancelled = 0
@monthlyChurn = 0.0
@monthlyGrowth = 0.0
@refreshDataState = 'Fetching dashboard data...'
refreshData: ->
return unless me.isAdmin()
@resetSubscriptionsData()
@getCancellations (cancellations) =>
@getSubscriptions cancellations, (subscriptions) =>
@getSubscribers(subscriptions)
@cancellations = cancellations
@render?()
@getOutstandingCancelledSubscriptions cancellations, (outstandingCancels) =>
@outstandingCancels = outstandingCancels
@getSubscriptions cancellations, (subscriptions) =>
@updateAnalyticsGraphData()
@render?()
@getSubscribers subscriptions, =>
@render?()
updateFetchDataState: (msg) ->
@refreshDataState = msg
@render?()
getCancellations: (done) ->
cancellations = []
@getCancellationEvents (cancelledSubscriptions) =>
# Get user objects for cancelled subscriptions
userIDs = _.map cancelledSubscriptions, (a) -> a.userID
options =
url: '/db/user/-/users'
method: 'POST'
data: {ids: userIDs}
options.error = (model, response, options) =>
return if @destroyed
console.error 'Failed to get cancelled users', response
options.success = (cancelledUsers, response, options) =>
return if @destroyed
userMap = {}
userMap[user._id] = user for user in cancelledUsers
for cancellation in cancelledSubscriptions when cancellation.userID of userMap
cancellation.user = userMap[cancellation.userID]
cancellation.level = User.levelFromExp(cancellation.user.points)
cancelledSubscriptions.sort (a, b) -> if a.cancel > b.cancel then -1 else 1
done(cancelledSubscriptions)
@updateFetchDataState 'Fetching cancellations...'
@supermodel.addRequestResource('get_cancelled_users', options, 0).load()
getCancellationEvents: (done) ->
cancellationEvents = []
earliestEventDate = new Date()
earliestEventDate.setUTCMonth(earliestEventDate.getUTCMonth() - 1)
earliestEventDate.setUTCDate(earliestEventDate.getUTCDate() - 8)
nextBatch = (starting_after, done) =>
@updateFetchDataState "Fetching cancellations #{cancellationEvents.length}..."
options =
url: '/db/subscription/-/stripe_events'
method: 'POST'
data: {options: {limit: 100}}
options.data.options.starting_after = starting_after if starting_after
options.data.options.type = 'customer.subscription.updated'
options.data.options.created = gte: Math.floor(earliestEventDate.getTime() / 1000)
options.error = (model, response, options) =>
return if @destroyed
console.error 'Failed to get cancelled events', response
options.success = (events, response, options) =>
return if @destroyed
for event in events.data
continue unless event.data?.object?.cancel_at_period_end is true and event.data?.previous_attributes.cancel_at_period_end is false
continue unless event.data?.object?.plan?.id is 'basic'
continue unless event.data?.object?.id?
cancellationEvents.push
cancel: new Date(event.created * 1000)
customerID: event.data.object.customer
start: new Date(event.data.object.start * 1000)
subscriptionID: event.data.object.id
userID: event.data.object.metadata?.id
if events.has_more
return nextBatch(events.data[events.data.length - 1].id, done)
done(cancellationEvents)
@supermodel.addRequestResource('get_cancellation_events', options, 0).load()
nextBatch null, done
getOutstandingCancelledSubscriptions: (cancellations, done) ->
@updateFetchDataState "Fetching oustanding cancellations..."
options =
url: '/db/subscription/-/cancellations'
method: 'GET'
url: '/db/subscription/-/stripe_subscriptions'
method: 'POST'
data: {subscriptions: cancellations}
options.error = (model, response, options) =>
return if @destroyed
console.error 'Failed to get cancellations', response
options.success = (cancellations, response, options) =>
console.error 'Failed to get outstanding cancellations', response
options.success = (subscriptions, response, options) =>
return if @destroyed
@cancellations = cancellations
@cancellations.sort (a, b) -> b.cancel.localeCompare(a.cancel)
for cancellation in @cancellations when cancellation.user?
cancellation.level = User.levelFromExp cancellation.user.points
done(cancellations)
@supermodel.addRequestResource('get_cancellations', options, 0).load()
outstandingCancelledSubscriptions = []
for subscription in subscriptions
continue unless subscription?.cancel_at_period_end
outstandingCancelledSubscriptions.push
cancel: new Date(subscription.canceled_at * 1000)
customerID: subscription.customerID
start: new Date(subscription.start * 1000)
subscriptionID: subscription.id
userID: subscription.metadata?.id
done(outstandingCancelledSubscriptions)
@supermodel.addRequestResource('get_outstanding_cancelled_subscriptions', options, 0).load()
getSubscribers: (subscriptions) ->
getSubscribers: (subscriptions, done) ->
# console.log 'getSubscribers', subscriptions.length
@updateFetchDataState "Fetching recipient subscriptions..."
@render?()
maxSubscribers = 40
subscribers = _.filter subscriptions, (a) -> a.userID?
subscribers.sort (a, b) -> b.start.localeCompare(a.start)
subscribers.sort (a, b) -> if a.start > b.start then -1 else 1
subscribers = subscribers.slice(0, maxSubscribers)
subscriberUserIDs = _.map subscribers, (a) -> a.userID
@ -92,64 +176,159 @@ module.exports = class AnalyticsSubscriptionsView extends RootView
if hero = subscriber.user.heroConfig?.thangType
subscriber.hero = _.invert(ThangType.heroes)[hero]
@subscribers = subscribers
@render?()
done()
@supermodel.addRequestResource('get_subscribers', options, 0).load()
getSubscriptions: (cancellations=[], done) ->
@getInvoices (invoices) =>
subMap = {}
for invoice in invoices
subID = invoice.subscriptionID
if subID of subMap
subMap[subID].first = new Date(invoice.date)
else
subMap[subID] =
first: new Date(invoice.date)
last: new Date(invoice.date)
customerID: invoice.customerID
subMap[subID].userID = invoice.userID if invoice.userID
@getSponsors (sponsors) =>
@getRecipientSubscriptions sponsors, (recipientSubscriptions) =>
@updateFetchDataState "Fetching recipient subscriptions..."
for subscription in recipientSubscriptions
subMap[subscription.id] =
first: new Date(subscription.start * 1000)
subMap[subscription.id].userID = subscription.metadata.id if subscription.metadata?.id?
if subscription.cancel_at_period_end
subMap[subscription.id].cancel = new Date(subscription.canceled_at * 1000)
subMap[subscription.id].end = new Date(subscription.current_period_end * 1000)
subs = []
for subID of subMap
sub =
customerID: subMap[subID].customerID
start: subMap[subID].first
subscriptionID: subID
sub.cancel = subMap[subID].cancel if subMap[subID].cancel
oneMonthAgo = new Date()
oneMonthAgo.setUTCMonth(oneMonthAgo.getUTCMonth() - 1)
if subMap[subID].end?
sub.end = subMap[subID].end
else if subMap[subID].last < oneMonthAgo
sub.end = subMap[subID].last
sub.end.setUTCMonth(sub.end.getUTCMonth() + 1)
sub.userID = subMap[subID].userID if subMap[subID].userID
subs.push sub
subDayMap = {}
for sub in subs
startDay = sub.start.toISOString().substring(0, 10)
subDayMap[startDay] ?= {}
subDayMap[startDay]['start'] ?= 0
subDayMap[startDay]['start']++
if endDay = sub?.end?.toISOString().substring(0, 10)
subDayMap[endDay] ?= {}
subDayMap[endDay]['end'] ?= 0
subDayMap[endDay]['end']++
for cancellation in cancellations
if cancellation.subscriptionID is sub.subscriptionID
sub.cancel = cancellation.cancel
cancelDay = cancellation.cancel.toISOString().substring(0, 10)
subDayMap[cancelDay] ?= {}
subDayMap[cancelDay]['cancel'] ?= 0
subDayMap[cancelDay]['cancel']++
break
today = new Date().toISOString().substring(0, 10)
for day of subDayMap
continue if day > today
@subs.push
day: day
started: subDayMap[day]['start'] or 0
cancelled: subDayMap[day]['cancel'] or 0
ended: subDayMap[day]['end'] or 0
@subs.sort (a, b) -> a.day.localeCompare(b.day)
totalLastMonth = 0
for sub, i in @subs
@total += sub.started
@total -= sub.ended
@cancelled += sub.cancelled
sub.total = @total
totalLastMonth = @total if @subs.length - i is 31
@monthlyChurn = @cancelled / totalLastMonth * 100.0 if totalLastMonth > 0
if @subs.length > 30 and @subs[@subs.length - 31].total > 0
startMonthTotal = @subs[@subs.length - 31].total
endMonthTotal = @subs[@subs.length - 1].total
@monthlyGrowth = (endMonthTotal / startMonthTotal - 1) * 100
done(subs)
getInvoices: (done) ->
invoices = {}
nextBatch = (starting_after, done) =>
@updateFetchDataState "Fetching invoices #{Object.keys(invoices).length}..."
options =
url: '/db/subscription/-/stripe_invoices'
method: 'POST'
data: {options: {limit: 100}}
options.data.options.starting_after = starting_after if starting_after
options.error = (model, response, options) =>
return if @destroyed
console.error 'Failed to get invoices', response
options.success = (invoiceData, response, options) =>
return if @destroyed
for invoice in invoiceData.data
continue unless invoice.paid
continue unless invoice.subscription
continue unless invoice.total > 0
continue unless invoice.lines?.data?[0]?.plan?.id is 'basic'
invoices[invoice.id] =
customerID: invoice.customer
subscriptionID: invoice.subscription
date: new Date(invoice.date * 1000)
invoices[invoice.id].userID = invoice.lines.data[0].metadata.id if invoice.lines?.data?[0]?.metadata?.id
if invoiceData.has_more
return nextBatch(invoiceData.data[invoiceData.data.length - 1].id, done)
else
invoices = (invoice for invoiceID, invoice of invoices)
invoices.sort (a, b) -> if a.date > b.date then -1 else 1
return done(invoices)
@supermodel.addRequestResource('get_invoices', options, 0).load()
nextBatch null, done
getRecipientSubscriptions: (sponsors, done) ->
@updateFetchDataState "Fetching recipient subscriptions..."
subscriptionsToFetch = []
for user in sponsors
for recipient in user.stripe?.recipients
subscriptionsToFetch.push
customerID: user.stripe.customerID
subscriptionID: recipient.subscriptionID
options =
url: '/db/subscription/-/subscriptions'
method: 'GET'
url: '/db/subscription/-/stripe_subscriptions'
method: 'POST'
data: {subscriptions: subscriptionsToFetch}
options.error = (model, response, options) =>
return if @destroyed
console.error 'Failed to get subscriptions', response
options.success = (subs, response, options) =>
console.error 'Failed to get recipient subscriptions', response
options.success = (subscriptions, response, options) =>
return if @destroyed
@resetSubscriptionsData()
subDayMap = {}
for sub in subs
startDay = sub.start.substring(0, 10)
subDayMap[startDay] ?= {}
subDayMap[startDay]['start'] ?= 0
subDayMap[startDay]['start']++
if endDay = sub?.end?.substring(0, 10)
subDayMap[endDay] ?= {}
subDayMap[endDay]['end'] ?= 0
subDayMap[endDay]['end']++
for cancellation in cancellations
if cancellation.subscriptionID is sub.subscriptionID
sub.cancel = cancellation.cancel
cancelDay = cancellation.cancel.substring(0, 10)
subDayMap[cancelDay] ?= {}
subDayMap[cancelDay]['cancel'] ?= 0
subDayMap[cancelDay]['cancel']++
break
done(subscriptions)
@supermodel.addRequestResource('get_recipient_subscriptions', options, 0).load()
today = new Date().toISOString().substring(0, 10)
for day of subDayMap
continue if day > today
@subs.push
day: day
started: subDayMap[day]['start'] or 0
cancelled: subDayMap[day]['cancel'] or 0
ended: subDayMap[day]['end'] or 0
@subs.sort (a, b) -> a.day.localeCompare(b.day)
totalLastMonth = 0
for sub, i in @subs
@total += sub.started
@total -= sub.ended
@cancelled += sub.cancelled
sub.total = @total
totalLastMonth = @total if @subs.length - i is 31
@monthlyChurn = @cancelled / totalLastMonth * 100.0 if totalLastMonth > 0
if @subs.length > 30 and @subs[@subs.length - 31].total > 0
startMonthTotal = @subs[@subs.length - 31].total
endMonthTotal = @subs[@subs.length - 1].total
@monthlyGrowth = (endMonthTotal / startMonthTotal - 1) * 100
@updateAnalyticsGraphData()
@render?()
done(subs)
@supermodel.addRequestResource('get_subscriptions', options, 0).load()
getSponsors: (done) ->
@updateFetchDataState "Fetching sponsors..."
options =
url: '/db/user/-/sub_sponsors'
method: 'POST'
options.error = (model, response, options) =>
return if @destroyed
console.error 'Failed to get sponsors', response
options.success = (sponsors, response, options) =>
return if @destroyed
done(sponsors)
@supermodel.addRequestResource('get_sponsors', options, 0).load()
updateAnalyticsGraphData: ->
# console.log 'updateAnalyticsGraphData'

View file

@ -26,74 +26,67 @@ class SubscriptionHandler extends Handler
console.warn "Subscription Error: #{user.get('slug')} (#{user._id}): '#{msg}'"
getByRelationship: (req, res, args...) ->
return @getCancellations(req, res) if args[1] is 'cancellations'
return @getRecentSubscribers(req, res) if args[1] is 'subscribers'
return @getActiveSubscriptions(req, res) if args[1] is 'subscriptions'
return @getStripeEvents(req, res) if args[1] is 'stripe_events'
return @getStripeInvoices(req, res) if args[1] is 'stripe_invoices'
return @getStripeSubscriptions(req, res) if args[1] is 'stripe_subscriptions'
return @getSubscribers(req, res) if args[1] is 'subscribers'
super(arguments...)
getCancellations: (req, res) =>
# console.log 'subscription_handler getCancellations'
getStripeEvents: (req, res) ->
# console.log 'subscription_handler getStripeEvents', req.body?.options
return @sendForbiddenError(res) unless req.user?.isAdmin()
stripe.events.list req.body.options, (err, events) =>
return done(err) if err
@sendSuccess(res, events)
earliestEventDate = new Date()
earliestEventDate.setUTCMonth(earliestEventDate.getUTCMonth() - 1)
earliestEventDate.setUTCDate(earliestEventDate.getUTCDate() - 8)
cancellationEvents = []
nextBatch = (starting_after, done) =>
options = limit: 100
options.starting_after = starting_after if starting_after
options.type = 'customer.subscription.updated'
options.created = gte: Math.floor(earliestEventDate.getTime() / 1000)
stripe.events.list options, (err, events) =>
return done(err) if err
for event in events.data
continue unless event.data?.object?.cancel_at_period_end is true and event.data?.previous_attributes.cancel_at_period_end is false
continue unless event.data?.object?.plan?.id is 'basic'
continue unless event.data?.object?.id?
cancellationEvents.push
subscriptionID: event.data.object.id
customerID: event.data.object.customer
if events.has_more
# console.log 'Fetching more cancellation events', cancellationEvents.length
return nextBatch(events.data[events.data.length - 1].id, done)
else
return done()
nextBatch null, (err) =>
getStripeInvoices: (req, res) ->
# console.log 'subscription_handler getStripeInvoices'
return @sendForbiddenError(res) unless req.user?.isAdmin()
@oldInvoices ?= {}
buildInvoicesFromCache = (newInvoices) =>
data = (invoice for invoiceID, invoice of @oldInvoices)
data = data.concat(newInvoices)
data.sort (a, b) -> if a.date > b.date then -1 else 1
{has_more: false, data: data}
oldInvoiceCutoffDays = 16 # Dependent on Stripe subscription payment retries
oldInvoiceCutoffDate = new Date()
oldInvoiceCutoffDate.setUTCDate(oldInvoiceCutoffDate.getUTCDate() - oldInvoiceCutoffDays)
stripe.invoices.list req.body.options, (err, invoices) =>
return @sendDatabaseError(res, err) if err
newInvoices = []
for invoice, i in invoices.data
if new Date(invoice.date * 1000) < oldInvoiceCutoffDate
if invoice.id of @oldInvoices
# Rest of the invoices should be cached, return from cache
cachedInvoices = buildInvoicesFromCache(newInvoices)
return @sendSuccess(res, cachedInvoices)
else
# Cache older invoices
@oldInvoices[invoice.id] = invoice
else
# Keep track of new invoices for this page of invoices
newInvoices.push(invoice)
@sendSuccess(res, invoices)
cancellations = []
createCheckSubFn = (customerID, subscriptionID) =>
(done) =>
stripe.customers.retrieveSubscription customerID, subscriptionID, (err, subscription) =>
return done() if err
return done() unless subscription?.cancel_at_period_end
cancellations.push
cancel: new Date(subscription.canceled_at * 1000)
customerID: customerID
start: new Date(subscription.start * 1000)
subscriptionID: subscriptionID
userID: subscription.metadata?.id
done()
tasks = []
for cancellationEvent in cancellationEvents
tasks.push createCheckSubFn(cancellationEvent.customerID, cancellationEvent.subscriptionID)
async.parallel tasks, (err, results) =>
return @sendDatabaseError(res, err) if err
getStripeSubscriptions: (req, res) ->
# console.log 'subscription_handler getStripeSubscriptions'
return @sendForbiddenError(res) unless req.user?.isAdmin()
subscriptions = []
createGetSubFn = (customerID, subscriptionID) =>
(done) =>
stripe.customers.retrieveSubscription customerID, subscriptionID, (err, subscription) =>
# TODO: return error instead of ignore?
subscriptions.push(subscription) unless err
done()
tasks = []
for subscription in req.body.subscriptions
tasks.push createGetSubFn(subscription.customerID, subscription.subscriptionID)
async.parallel tasks, (err, results) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, subscriptions)
# TODO: Lookup userID via customer object, for cancellations that are missing them
userIDs = _.map cancellations, (a) -> a.userID
User.find {_id: {$in: userIDs}}, (err, users) =>
return @sendDatabaseError(res, err) if err
userMap = {}
userMap[user.id] = user.toObject() for user in users
for cancellation in cancellations
cancellation.user = userMap[cancellation.userID] if cancellation.userID of userMap
@sendSuccess(res, cancellations)
getRecentSubscribers: (req, res) ->
# console.log 'subscription_handler getRecentSubscribers'
getSubscribers: (req, res) ->
# console.log 'subscription_handler getSubscribers'
return @sendForbiddenError(res) unless req.user?.isAdmin()
subscriberUserIDs = req.body.ids or []
@ -137,101 +130,6 @@ class SubscriptionHandler extends Handler
log.debug 'Analytics error:\n' + err
@sendSuccess(res, userMap)
getActiveSubscriptions: (req, res) ->
# console.log 'subscription_handler getActiveSubscriptions'
# TODO: does not return free subs
# TODO: add tests
# TODO: take date range as input
return @sendForbiddenError(res) unless req.user?.isAdmin()
@invoices ?= {}
newInvoices = []
oldInvoiceDate = new Date()
oldInvoiceDate.setUTCDate(oldInvoiceDate.getUTCDate() - 20)
processInvoices = (starting_after, done) =>
options = limit: 100
options.starting_after = starting_after if starting_after
stripe.invoices.list options, (err, invoices) =>
return done(err) if err
for invoice in invoices.data
invoiceDate = new Date(invoice.date * 1000)
# Assume we've cached all older invoices if we find a cached one that's old enough
return done() if invoice.id of @invoices and invoiceDate < oldInvoiceDate
continue unless invoice.paid
continue unless invoice.subscription
continue unless invoice.total > 0
continue unless invoice.lines?.data?[0]?.plan?.id is 'basic'
@invoices[invoice.id] =
customerID: invoice.customer
subscriptionID: invoice.subscription
date: invoiceDate
@invoices[invoice.id].userID = invoice.lines.data[0].metadata.id if invoice.lines?.data?[0]?.metadata?.id
if invoices.has_more
return processInvoices(invoices.data[invoices.data.length - 1].id, done)
else
return done()
processInvoices null, (err) =>
return @sendDatabaseError(res, err) if err
subMap = {}
invoices = (invoice for invoiceID, invoice of @invoices)
invoices.sort (a, b) -> if a.date > b.date then -1 else 1
for invoice in invoices
subID = invoice.subscriptionID
if subID of subMap
subMap[subID].first = invoice.date
else
subMap[subID] =
first: invoice.date
last: invoice.date
customerID: invoice.customerID
subMap[subID].userID = invoice.userID if invoice.userID
# Check sponsored subscriptions
User.find {"stripe.sponsorSubscriptionID": {$exists: true}}, (err, sponsors) =>
return @sendDatabaseError(res, err) if err
createCheckSubFn = (customerID, subscriptionID) =>
(done) =>
stripe.customers.retrieveSubscription customerID, subscriptionID, (err, subscription) =>
return done() if err
return done() unless subscription?
subMap[subscription.id] =
first: new Date(subscription.start * 1000)
subMap[subscription.id].userID = subscription.metadata.id if subscription.metadata?.id?
if subscription.cancel_at_period_end
subMap[subscription.id].cancel = new Date(subscription.canceled_at * 1000)
subMap[subscription.id].end = new Date(subscription.current_period_end * 1000)
done()
tasks = []
for user in sponsors
for recipient in user.get("stripe")?.recipients
tasks.push createCheckSubFn(user.get('stripe')?.customerID, recipient.subscriptionID)
async.parallel tasks, (err, results) =>
return @sendDatabaseError(res, err) if err
subs = []
for subID of subMap
sub =
customerID: subMap[subID].customerID
start: subMap[subID].first
subscriptionID: subID
sub.cancel = subMap[subID].cancel if subMap[subID].cancel
oneMonthAgo = new Date()
oneMonthAgo.setUTCMonth(oneMonthAgo.getUTCMonth() - 1)
if subMap[subID].end?
sub.end = subMap[subID].end
else if subMap[subID].last < oneMonthAgo
sub.end = new Date(subMap[subID].last)
sub.end.setUTCMonth(sub.end.getUTCMonth() + 1)
sub.userID = subMap[subID].userID if subMap[subID].userID
subs.push sub
@sendSuccess(res, subs)
subscribeUser: (req, user, done) ->
if (not req.user) or req.user.isAnonymous() or user.isAnonymous()
return done({res: 'You must be signed in to subscribe.', code: 403})

View file

@ -173,6 +173,13 @@ UserHandler = class UserHandler extends Handler
return @sendSuccess(res, @formatEntity(req, req.user, 256))
super(req, res, id)
getByIDs: (req, res) ->
return @sendForbiddenError(res) unless req.user?.isAdmin()
User.find {_id: {$in: req.body.ids}}, (err, users) =>
return @sendDatabaseError(res, err) if err
cleandocs = (@formatEntity(req, doc) for doc in users)
@sendSuccess(res, cleandocs)
getNamesByIDs: (req, res) ->
ids = req.query.ids or req.body.ids
returnWizard = req.query.wizard or req.body.wizard
@ -294,6 +301,7 @@ UserHandler = class UserHandler extends Handler
return @agreeToCLA(req, res) if args[1] is 'agreeToCLA'
return @agreeToEmployerAgreement(req, res) if args[1] is 'agreeToEmployerAgreement'
return @avatar(req, res, args[0]) if args[1] is 'avatar'
return @getByIDs(req, res) if args[1] is 'users'
return @getNamesByIDs(req, res) if args[1] is 'names'
return @nameToID(req, res, args[0]) if args[1] is 'nameToID'
return @getLevelSessionsForEmployer(req, res, args[0]) if args[1] is 'level.sessions' and args[2] is 'employer'
@ -311,6 +319,7 @@ UserHandler = class UserHandler extends Handler
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 @getSubSponsors(req, res) if args[1] is 'sub_sponsors'
return @sendOneTimeEmail(req, res, args[0]) if args[1] is 'send_one_time_email'
return @sendNotFoundError(res)
super(arguments...)
@ -384,6 +393,13 @@ UserHandler = class UserHandler extends Handler
@sendDatabaseError(res, 'No sponsored subscription found') unless info.subscription?
@sendSuccess(res, info)
getSubSponsors: (req, res) ->
return @sendForbiddenError(res) unless req.user?.isAdmin()
User.find {"stripe.sponsorSubscriptionID": {$exists: true}}, (err, sponsors) =>
return @sendDatabaseError(res, err) if err
cleandocs = (@formatEntity(req, doc) for doc in sponsors)
@sendSuccess(res, cleandocs)
sendOneTimeEmail: (req, res) ->
# TODO: Should this API be somewhere else?
# TODO: Where should email types be stored?