mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2025-04-27 14:33:59 -04:00
🐛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:
parent
a8e59c5302
commit
6831355649
4 changed files with 325 additions and 232 deletions
app
server
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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?
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue