mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-27 09:35:39 -05:00
Add current subscription counts view under the admin page
This commit is contained in:
parent
155eb4a4a8
commit
53de6c6134
11 changed files with 195 additions and 16 deletions
|
@ -33,7 +33,8 @@ module.exports = class CocoRouter extends Backbone.Router
|
||||||
'admin/clas': go('admin/CLAsView')
|
'admin/clas': go('admin/CLAsView')
|
||||||
'admin/employers': go('admin/EmployersListView')
|
'admin/employers': go('admin/EmployersListView')
|
||||||
'admin/files': go('admin/FilesView')
|
'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/level-sessions': go('admin/LevelSessionsView')
|
||||||
'admin/users': go('admin/UsersView')
|
'admin/users': go('admin/UsersView')
|
||||||
'admin/base': go('admin/BaseView')
|
'admin/base': go('admin/BaseView')
|
||||||
|
|
19
app/styles/admin/analytics-subscriptions.sass
Normal file
19
app/styles/admin/analytics-subscriptions.sass
Normal file
|
@ -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
|
|
@ -44,8 +44,12 @@ block content
|
||||||
li
|
li
|
||||||
a(href="/admin/clas", data-i18n="admin.clas") CLAs
|
a(href="/admin/clas", data-i18n="admin.clas") CLAs
|
||||||
if me.isAdmin()
|
if me.isAdmin()
|
||||||
li
|
li Analytics
|
||||||
a(href="/admin/growth", data-i18n="admin.growth") Growth
|
ul
|
||||||
|
li
|
||||||
|
a(href="/admin/analytics/subscriptions") Subscriptions
|
||||||
|
li
|
||||||
|
a(href="/admin/analytics/users") Users (needs updating)
|
||||||
|
|
||||||
if me.isAdmin()
|
if me.isAdmin()
|
||||||
hr
|
hr
|
||||||
|
|
35
app/templates/admin/analytics-subscriptions.jade
Normal file
35
app/templates/admin/analytics-subscriptions.jade
Normal file
|
@ -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
|
|
@ -2,7 +2,7 @@ extends /templates/base
|
||||||
|
|
||||||
block content
|
block content
|
||||||
|
|
||||||
h1(data-i18n="admin.growth_title") Growth
|
h1(data-i18n="admin.growth_title") Users
|
||||||
if me.isAdmin()
|
if me.isAdmin()
|
||||||
if crunchingData
|
if crunchingData
|
||||||
h4 Crunching Data..
|
h4 Crunching Data..
|
53
app/views/admin/AnalyticsSubscriptionsView.coffee
Normal file
53
app/views/admin/AnalyticsSubscriptionsView.coffee
Normal file
|
@ -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()
|
|
@ -1,5 +1,5 @@
|
||||||
RootView = require 'views/core/RootView'
|
RootView = require 'views/core/RootView'
|
||||||
template = require 'templates/admin/growth'
|
template = require 'templates/admin/analytics-users'
|
||||||
RealTimeCollection = require 'collections/RealTimeCollection'
|
RealTimeCollection = require 'collections/RealTimeCollection'
|
||||||
|
|
||||||
require 'vendor/d3'
|
require 'vendor/d3'
|
||||||
|
@ -16,8 +16,8 @@ require 'vendor/d3'
|
||||||
# TODO: aggregate recent data if missing?
|
# TODO: aggregate recent data if missing?
|
||||||
#
|
#
|
||||||
|
|
||||||
module.exports = class GrowthView extends RootView
|
module.exports = class AnalyticsUsersView extends RootView
|
||||||
id: 'admin-growth-view'
|
id: 'admin-analytics-users-view'
|
||||||
template: template
|
template: template
|
||||||
height: 300
|
height: 300
|
||||||
width: 1000
|
width: 1000
|
||||||
|
@ -55,7 +55,7 @@ module.exports = class GrowthView extends RootView
|
||||||
if me.isAdmin()
|
if me.isAdmin()
|
||||||
@createPerDayChart()
|
@createPerDayChart()
|
||||||
@createPerMonthChart()
|
@createPerMonthChart()
|
||||||
|
|
||||||
createPerDayChart: ->
|
createPerDayChart: ->
|
||||||
addedData = []
|
addedData = []
|
||||||
totalData = []
|
totalData = []
|
||||||
|
@ -76,7 +76,7 @@ module.exports = class GrowthView extends RootView
|
||||||
|
|
||||||
createLineChart: (selector, data, guidelineSpacing, sevenDayAverage=false) ->
|
createLineChart: (selector, data, guidelineSpacing, sevenDayAverage=false) ->
|
||||||
return unless data.length > 1
|
return unless data.length > 1
|
||||||
|
|
||||||
minVal = d3.min(data, (d) -> d.value)
|
minVal = d3.min(data, (d) -> d.value)
|
||||||
maxVal = d3.max(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("cx", (d) -> d.x )
|
||||||
.attr("cy", (d) -> d.y )
|
.attr("cy", (d) -> d.y )
|
||||||
.attr("r", "2px")
|
.attr("r", "2px")
|
||||||
.attr("fill", "black")
|
.attr("fill", "black")
|
||||||
|
|
||||||
chart.selectAll(".text")
|
chart.selectAll(".text")
|
||||||
.data(points)
|
.data(points)
|
||||||
.enter()
|
.enter()
|
||||||
.append("text")
|
.append("text")
|
||||||
.attr("dy", ".35em")
|
.attr("dy", ".35em")
|
||||||
.attr("transform", (d, i) => "translate(" + d.x + "," + @height + ") rotate(270)")
|
.attr("transform", (d, i) => "translate(" + d.x + "," + @height + ") rotate(270)")
|
||||||
.text((d) ->
|
.text((d) ->
|
||||||
if d.id.length is 8
|
if d.id.length is 8
|
||||||
return "#{parseInt(d.id[4..5])}/#{parseInt(d.id[6..7])}/#{d.id[0..3]}"
|
return "#{parseInt(d.id[4..5])}/#{parseInt(d.id[6..7])}/#{d.id[0..3]}"
|
||||||
else
|
else
|
||||||
return "#{parseInt(d.id[4..5])}/#{d.id[0..3]}"
|
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("cx", (d) -> d.x )
|
||||||
.attr("cy", (d) -> d.y )
|
.attr("cy", (d) -> d.y )
|
||||||
.attr("r", "2px")
|
.attr("r", "2px")
|
||||||
.attr("fill", "purple")
|
.attr("fill", "purple")
|
||||||
|
|
||||||
chart.selectAll('.line')
|
chart.selectAll('.line')
|
||||||
.data(sevenLinks)
|
.data(sevenLinks)
|
||||||
|
@ -191,4 +191,3 @@ module.exports = class GrowthView extends RootView
|
||||||
.attr("y", (d) -> d.start.y - 6)
|
.attr("y", (d) -> d.start.y - 6)
|
||||||
.attr("dy", ".35em")
|
.attr("dy", ".35em")
|
||||||
.text((d) -> d.start.id)
|
.text((d) -> d.start.id)
|
||||||
|
|
|
@ -29,7 +29,7 @@ getSubscriptions(null, function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
function getSubscriptions(starting_after, done)
|
function getSubscriptions(starting_after, done)
|
||||||
{
|
{
|
||||||
var options = {limit: 100};
|
var options = {limit: 100};
|
||||||
if (starting_after) options.starting_after = starting_after;
|
if (starting_after) options.starting_after = starting_after;
|
||||||
|
|
|
@ -24,6 +24,7 @@ module.exports.handlers =
|
||||||
'poll': 'polls/poll_handler'
|
'poll': 'polls/poll_handler'
|
||||||
'user_polls_record': 'polls/user_polls_record_handler'
|
'user_polls_record': 'polls/user_polls_record_handler'
|
||||||
'prepaid': 'prepaids/prepaid_handler'
|
'prepaid': 'prepaids/prepaid_handler'
|
||||||
|
'subscription': 'payments/subscription_handler'
|
||||||
|
|
||||||
module.exports.routes =
|
module.exports.routes =
|
||||||
[
|
[
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
# the stripe property in the user with what's being stored in Stripe.
|
# the stripe property in the user with what's being stored in Stripe.
|
||||||
|
|
||||||
async = require 'async'
|
async = require 'async'
|
||||||
|
config = require '../../server_config'
|
||||||
Handler = require '../commons/Handler'
|
Handler = require '../commons/Handler'
|
||||||
discountHandler = require './discount_handler'
|
discountHandler = require './discount_handler'
|
||||||
Prepaid = require '../prepaids/Prepaid'
|
Prepaid = require '../prepaids/Prepaid'
|
||||||
|
@ -21,6 +22,70 @@ class SubscriptionHandler extends Handler
|
||||||
logSubscriptionError: (user, msg) ->
|
logSubscriptionError: (user, msg) ->
|
||||||
console.warn "Subscription Error: #{user.get('slug')} (#{user._id}): '#{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) ->
|
subscribeUser: (req, user, done) ->
|
||||||
if (not req.user) or req.user.isAnonymous() or user.isAnonymous()
|
if (not req.user) or req.user.isAnonymous() or user.isAnonymous()
|
||||||
return done({res: 'You must be signed in to subscribe.', code: 403})
|
return done({res: 'You must be signed in to subscribe.', code: 403})
|
||||||
|
|
|
@ -419,6 +419,8 @@ describe 'Subscriptions', ->
|
||||||
options = customer: body.stripe.customerID, limit: 100
|
options = customer: body.stripe.customerID, limit: 100
|
||||||
stripe.invoices.list options, (err, invoices) ->
|
stripe.invoices.list options, (err, invoices) ->
|
||||||
expect(err).toBeNull()
|
expect(err).toBeNull()
|
||||||
|
expect(invoices).not.toBeNull()
|
||||||
|
return done(updatedUser) unless invoices?
|
||||||
expect(invoices.has_more).toEqual(false)
|
expect(invoices.has_more).toEqual(false)
|
||||||
makeWebhookCall = (invoice) ->
|
makeWebhookCall = (invoice) ->
|
||||||
(callback) ->
|
(callback) ->
|
||||||
|
|
Loading…
Reference in a new issue