diff --git a/app/core/Router.coffee b/app/core/Router.coffee index bbf2b4f4a..29ad86fb2 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -33,7 +33,8 @@ module.exports = class CocoRouter extends Backbone.Router 'admin/clas': go('admin/CLAsView') 'admin/employers': go('admin/EmployersListView') 'admin/files': go('admin/FilesView') - 'admin/growth': go('admin/GrowthView') + 'admin/analytics/users': go('admin/AnalyticsUsersView') + 'admin/analytics/subscriptions': go('admin/AnalyticsSubscriptionsView') 'admin/level-sessions': go('admin/LevelSessionsView') 'admin/users': go('admin/UsersView') 'admin/base': go('admin/BaseView') diff --git a/app/styles/admin/analytics-subscriptions.sass b/app/styles/admin/analytics-subscriptions.sass new file mode 100644 index 000000000..34206b2ca --- /dev/null +++ b/app/styles/admin/analytics-subscriptions.sass @@ -0,0 +1,19 @@ +#admin-analytics-subscriptions-view + + .total-count + width: 33% + float: left + color: green + .remaining-count + width: 33% + float: left + color: blue + .cancelled-count + width: 33% + float: left + color: red + + .count + font-size: 50pt + .description + font-size: 8pt diff --git a/app/templates/admin.jade b/app/templates/admin.jade index 2887717a6..ebf3cbc14 100644 --- a/app/templates/admin.jade +++ b/app/templates/admin.jade @@ -44,8 +44,12 @@ block content li a(href="/admin/clas", data-i18n="admin.clas") CLAs if me.isAdmin() - li - a(href="/admin/growth", data-i18n="admin.growth") Growth + li Analytics + ul + li + a(href="/admin/analytics/subscriptions") Subscriptions + li + a(href="/admin/analytics/users") Users (needs updating) if me.isAdmin() hr diff --git a/app/templates/admin/analytics-subscriptions.jade b/app/templates/admin/analytics-subscriptions.jade new file mode 100644 index 000000000..7369adf42 --- /dev/null +++ b/app/templates/admin/analytics-subscriptions.jade @@ -0,0 +1,35 @@ +extends /templates/base + +block content + + if !me.isAdmin() + div You must be logged in as an admin to view this page. + else + if total === 0 + h1 Fetching subscriptions data... + else + div + .total-count + div.description Total + div.count= total + .remaining-count + div.description Remaining + div.count= total - cancelled + .cancelled-count + div.description cancelled + div.count= cancelled + + table.table.table-condensed.concepts-table + thead + tr + th Day + th Total + th Started + th Cancelled + tbody + each sub in subs + tr + td= sub.day + td= sub.total + td= sub.started + td= sub.cancelled diff --git a/app/templates/admin/growth.jade b/app/templates/admin/analytics-users.jade similarity index 94% rename from app/templates/admin/growth.jade rename to app/templates/admin/analytics-users.jade index 39abf670a..982281a24 100644 --- a/app/templates/admin/growth.jade +++ b/app/templates/admin/analytics-users.jade @@ -2,7 +2,7 @@ extends /templates/base block content - h1(data-i18n="admin.growth_title") Growth + h1(data-i18n="admin.growth_title") Users if me.isAdmin() if crunchingData h4 Crunching Data.. diff --git a/app/views/admin/AnalyticsSubscriptionsView.coffee b/app/views/admin/AnalyticsSubscriptionsView.coffee new file mode 100644 index 000000000..5f905da48 --- /dev/null +++ b/app/views/admin/AnalyticsSubscriptionsView.coffee @@ -0,0 +1,53 @@ +RootView = require 'views/core/RootView' +template = require 'templates/admin/analytics-subscriptions' +RealTimeCollection = require 'collections/RealTimeCollection' + +require 'vendor/d3' + +module.exports = class AnalyticsSubscriptionsView extends RootView + id: 'admin-analytics-subscriptions-view' + template: template + + constructor: (options) -> + super options + @refreshData() + + getRenderData: -> + context = super() + context.subs = @subs ? [] + context.total = @total ? 0 + context.cancelled = @cancelled ? 0 + context + + refreshData: -> + @subs = [] + @total = 0 + @cancelled = 0 + onSuccess = (subs) => + subDayMap = {} + for sub in subs + startDay = sub.start.substring(0, 10) + subDayMap[startDay] ?= {} + subDayMap[startDay]['start'] ?= 0 + subDayMap[startDay]['start']++ + if cancelDay = sub?.cancel?.substring(0, 10) + subDayMap[cancelDay] ?= {} + subDayMap[cancelDay]['cancel'] ?= 0 + subDayMap[cancelDay]['cancel']++ + for day of subDayMap + @subs.push + day: day + started: subDayMap[day]['start'] + cancelled: subDayMap[day]['cancel'] or 0 + @subs.sort (a, b) -> -a.day.localeCompare(b.day) + + for i in [@subs.length - 1..0] + @total += @subs[i].started + @cancelled += @subs[i].cancelled + @subs[i].total = @total + @render() + @supermodel.addRequestResource('subscriptions', { + url: '/db/subscription/-/subscriptions' + method: 'GET' + success: onSuccess + }, 0).load() diff --git a/app/views/admin/GrowthView.coffee b/app/views/admin/AnalyticsUsersView.coffee similarity index 95% rename from app/views/admin/GrowthView.coffee rename to app/views/admin/AnalyticsUsersView.coffee index 1bc5957ab..063a8f6ab 100644 --- a/app/views/admin/GrowthView.coffee +++ b/app/views/admin/AnalyticsUsersView.coffee @@ -1,5 +1,5 @@ RootView = require 'views/core/RootView' -template = require 'templates/admin/growth' +template = require 'templates/admin/analytics-users' RealTimeCollection = require 'collections/RealTimeCollection' require 'vendor/d3' @@ -16,8 +16,8 @@ require 'vendor/d3' # TODO: aggregate recent data if missing? # -module.exports = class GrowthView extends RootView - id: 'admin-growth-view' +module.exports = class AnalyticsUsersView extends RootView + id: 'admin-analytics-users-view' template: template height: 300 width: 1000 @@ -55,7 +55,7 @@ module.exports = class GrowthView extends RootView if me.isAdmin() @createPerDayChart() @createPerMonthChart() - + createPerDayChart: -> addedData = [] totalData = [] @@ -76,7 +76,7 @@ module.exports = class GrowthView extends RootView createLineChart: (selector, data, guidelineSpacing, sevenDayAverage=false) -> return unless data.length > 1 - + minVal = d3.min(data, (d) -> d.value) maxVal = d3.max(data, (d) -> d.value) @@ -129,16 +129,16 @@ module.exports = class GrowthView extends RootView .attr("cx", (d) -> d.x ) .attr("cy", (d) -> d.y ) .attr("r", "2px") - .attr("fill", "black") - + .attr("fill", "black") + chart.selectAll(".text") .data(points) .enter() .append("text") .attr("dy", ".35em") .attr("transform", (d, i) => "translate(" + d.x + "," + @height + ") rotate(270)") - .text((d) -> - if d.id.length is 8 + .text((d) -> + if d.id.length is 8 return "#{parseInt(d.id[4..5])}/#{parseInt(d.id[6..7])}/#{d.id[0..3]}" else return "#{parseInt(d.id[4..5])}/#{d.id[0..3]}" @@ -161,7 +161,7 @@ module.exports = class GrowthView extends RootView .attr("cx", (d) -> d.x ) .attr("cy", (d) -> d.y ) .attr("r", "2px") - .attr("fill", "purple") + .attr("fill", "purple") chart.selectAll('.line') .data(sevenLinks) @@ -191,4 +191,3 @@ module.exports = class GrowthView extends RootView .attr("y", (d) -> d.start.y - 6) .attr("dy", ".35em") .text((d) -> d.start.id) - diff --git a/scripts/analytics/stripeSubscribers.js b/scripts/analytics/stripeSubscribers.js index a2db34029..5822518df 100644 --- a/scripts/analytics/stripeSubscribers.js +++ b/scripts/analytics/stripeSubscribers.js @@ -29,7 +29,7 @@ getSubscriptions(null, function () { }); -function getSubscriptions(starting_after, done) +function getSubscriptions(starting_after, done) { var options = {limit: 100}; if (starting_after) options.starting_after = starting_after; diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee index 8c7e333cc..40a5e9a9e 100644 --- a/server/commons/mapping.coffee +++ b/server/commons/mapping.coffee @@ -24,6 +24,7 @@ module.exports.handlers = 'poll': 'polls/poll_handler' 'user_polls_record': 'polls/user_polls_record_handler' 'prepaid': 'prepaids/prepaid_handler' + 'subscription': 'payments/subscription_handler' module.exports.routes = [ diff --git a/server/payments/subscription_handler.coffee b/server/payments/subscription_handler.coffee index fb3df7f51..9db6f8780 100644 --- a/server/payments/subscription_handler.coffee +++ b/server/payments/subscription_handler.coffee @@ -2,6 +2,7 @@ # the stripe property in the user with what's being stored in Stripe. async = require 'async' +config = require '../../server_config' Handler = require '../commons/Handler' discountHandler = require './discount_handler' Prepaid = require '../prepaids/Prepaid' @@ -21,6 +22,70 @@ class SubscriptionHandler extends Handler logSubscriptionError: (user, msg) -> console.warn "Subscription Error: #{user.get('slug')} (#{user._id}): '#{msg}'" + getByRelationship: (req, res, args...) -> + return @getSubscriptions(req, res) if args[1] is 'subscriptions' + super(arguments...) + + getSubscriptions: (req, res) -> + # Returns a list of active subscriptions + # TODO: does not handle customers with 11+ active subscriptions + # TODO: does not track sponsored subs, only basic + # TODO: does not return free subs + # TODO: add tests + # TODO: aggregate this data daily instead of providing it on demand + # TODO: take date range as input + + return @sendForbiddenError(res) unless req.user and req.user.isAdmin() + + @subs ?= [] + # return @sendSuccess(res, @subs) unless _.isEmpty(@subs) + + customersProcessed = 0 + nextBatch = (starting_after, done) => + options = limit: 100 + options.starting_after = starting_after if starting_after + stripe.customers.list options, (err, customers) => + return done(err) if err + customersProcessed += customers.data.length + + for customer in customers.data + continue unless customer?.subscriptions?.data?.length > 0 + for subscription in customer.subscriptions.data + continue unless subscription.plan.id is 'basic' + + + amount = subscription.plan.amount + if subscription?.discount?.coupon? + if subscription.discount.coupon.percent_off + amount = amount * (100 - subscription.discount.coupon.percent_off) / 100; + else if subscription.discount.coupon.amount_off + amount -= subscription.discount.coupon.amount_off + else if customer.discount?.coupon? + if customer.discount.coupon.percent_off + amount = amount * (100 - customer.discount.coupon.percent_off) / 100 + else if customer.discount.coupon.amount_off + amount -= customer.discount.coupon.amount_off + + continue unless amount > 0 + + sub = start: new Date(subscription.start * 1000) + if subscription.cancel_at_period_end + sub.cancel = new Date(subscription.canceled_at * 1000) + sub.end = new Date(sub.current_period_end * 1000) + @subs.push(sub) + + # Can't fetch all the test Stripe data + if customers.has_more and (config.isProduction or customersProcessed < 500) + return nextBatch(customers.data[customers.data.length - 1].id, done) + else + return done() + nextBatch null, (err) => + return @sendDatabaseError(res, err) if err + @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}) diff --git a/test/server/functional/subscription.spec.coffee b/test/server/functional/subscription.spec.coffee index c15fd3164..dc1cc55e2 100644 --- a/test/server/functional/subscription.spec.coffee +++ b/test/server/functional/subscription.spec.coffee @@ -419,6 +419,8 @@ describe 'Subscriptions', -> options = customer: body.stripe.customerID, limit: 100 stripe.invoices.list options, (err, invoices) -> expect(err).toBeNull() + expect(invoices).not.toBeNull() + return done(updatedUser) unless invoices? expect(invoices.has_more).toEqual(false) makeWebhookCall = (invoice) -> (callback) ->