From 95b61c2f8355f9ae6c18ab5d4b77983487fb5ca6 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Sun, 8 Nov 2015 17:00:24 -0800 Subject: [PATCH] Add recurring revenue to admin analytics page https://app.asana.com/0/54276215890539/59638739614287 --- app/styles/admin/analytics.sass | 6 +- app/templates/admin/analytics.jade | 16 +++++ app/views/admin/AnalyticsView.coffee | 65 +++++++++++++++---- .../mongodb/queries/insertPerDayAnalytics.js | 60 ++++++++++++++++- .../analytics/analytics_perday_handler.coffee | 30 +++++++++ 5 files changed, 160 insertions(+), 17 deletions(-) diff --git a/app/styles/admin/analytics.sass b/app/styles/admin/analytics.sass index ee6e17085..04f72ee5e 100644 --- a/app/styles/admin/analytics.sass +++ b/app/styles/admin/analytics.sass @@ -6,9 +6,11 @@ width: auto .active-classes color: blue - .active-users + .recurring-revenue color: green + .active-users + color: red .count - font-size: 50pt + font-size: 70pt .description font-size: 8pt diff --git a/app/templates/admin/analytics.jade b/app/templates/admin/analytics.jade index d92d55bfc..6c2fc8f4a 100644 --- a/app/templates/admin/analytics.jade +++ b/app/templates/admin/analytics.jade @@ -9,6 +9,10 @@ block content if activeClasses.length > 0 div.description 30-day Active Classes div.count= activeClasses[0].groups[activeClasses[0].groups.length - 1] + .col-md-5.big-stat.recurring-revenue + if revenue.length > 0 + div.description 30-day Monthly Recurring Revenue + div.count $#{Math.round((revenue[0].groups[revenue[0].groups.length - 1]) / 100)} .col-md-5.big-stat.active-users if activeUsers.length > 0 div.description 30-day Active Users @@ -26,6 +30,18 @@ block content each val in activeClass.groups td= val + h1 Recurring Revenue + table.table.table-striped.table-condensed + tr + th Day + for group in revenueGroups + th= group.replace('DRR ', '') + each entry in revenue + tr + td= entry.day + each val in entry.groups + td $#{(val / 100).toFixed(2)} + h1 Active Users table.table.table-striped.table-condensed tr diff --git a/app/views/admin/AnalyticsView.coffee b/app/views/admin/AnalyticsView.coffee index 612b521ce..390c39673 100644 --- a/app/views/admin/AnalyticsView.coffee +++ b/app/views/admin/AnalyticsView.coffee @@ -9,22 +9,8 @@ module.exports = class AnalyticsView extends RootView constructor: (options) -> super options - startDay = utils.getUTCDay(-30).replace(/-/g, '') - endDay = utils.getUTCDay(-30).replace(/-/g, '') - - @supermodel.addRequestResource('active_users', { - url: '/db/analytics_perday/-/active_users' - data: {startDay: startDay, endDay: endDay} - method: 'POST' - success: (data) => - @activeUsers = data - @activeUsers.sort (a, b) -> b.day.localeCompare(a.day) - @render?() - }, 0).load() - @supermodel.addRequestResource('active_classes', { url: '/db/analytics_perday/-/active_classes' - data: {startDay: startDay, endDay: endDay} method: 'POST' success: (data) => @activeClassGroups = {} @@ -51,9 +37,60 @@ module.exports = class AnalyticsView extends RootView @render?() }, 0).load() + @supermodel.addRequestResource('active_users', { + url: '/db/analytics_perday/-/active_users' + method: 'POST' + success: (data) => + @activeUsers = data + @activeUsers.sort (a, b) -> b.day.localeCompare(a.day) + @render?() + }, 0).load() + + @supermodel.addRequestResource('recurring_revenue', { + url: '/db/analytics_perday/-/recurring_revenue' + method: 'POST' + success: (data) => + @revenueGroups = {} + dayGroupCountMap = {} + for dailyRevenue in data + dayGroupCountMap[dailyRevenue.day] ?= {} + dayGroupCountMap[dailyRevenue.day]['Daily'] = 0 + for group, val of dailyRevenue.groups + @revenueGroups[group] = true + dayGroupCountMap[dailyRevenue.day][group] = val + dayGroupCountMap[dailyRevenue.day]['Daily'] += val + @revenueGroups = Object.keys(@revenueGroups) + @revenueGroups.push 'Daily' + @revenueGroups.push 'Monthly' + for day of dayGroupCountMap + for group in @revenueGroups + dayGroupCountMap[day][group] ?= 0 + @revenue = [] + for day of dayGroupCountMap + data = day: day, groups: [] + for group in @revenueGroups + data.groups.push(dayGroupCountMap[day][group] ? 0) + @revenue.push data + @revenue.sort (a, b) -> b.day.localeCompare(a.day) + monthlyValues = [] + + return unless @revenue.length > 0 + + for i in [@revenue.length-1..0] + dailyTotal = @revenue[i].groups[@revenue[i].groups.length - 2] + monthlyValues.push(dailyTotal) + monthlyValues.shift() if monthlyValues.length > 30 + if monthlyValues.length is 30 + monthlyIndex = @revenue[i].groups.length - 1 + @revenue[i].groups[monthlyIndex] = _.reduce(monthlyValues, (s, num) -> s + num) + @render?() + }, 0).load() + getRenderData: -> context = super() context.activeClasses = @activeClasses ? [] context.activeClassGroups = @activeClassGroups ? {} context.activeUsers = @activeUsers ? [] + context.revenue = @revenue ? [] + context.revenueGroups = @revenueGroups ? {} context diff --git a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js index c6dd6217a..913709a17 100644 --- a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js +++ b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js @@ -17,7 +17,7 @@ try { var scriptStartTime = new Date(); var analyticsStringCache = {}; - var numDays = 32; + var numDays = 40; var daysInMonth = 30; var startDay = new Date(); @@ -101,6 +101,17 @@ try { } } + log("Getting monthly recurring revenue counts..."); + var recurringRevenueCounts = getRecurringRevenueCounts(startDay); + // printjson(recurringRevenueCounts); + log("Inserting monthly recurring revenue counts..."); + for (var event in recurringRevenueCounts) { + for (var day in recurringRevenueCounts[event]) { + if (today === day) continue; // Never save data for today because it's incomplete + insertEventCount(event, day, recurringRevenueCounts[event][day]); + } + } + log("Script runtime: " + (new Date() - scriptStartTime)); } catch(err) { @@ -623,6 +634,53 @@ function getActiveClassCounts(startDay) { return activeClassCounts; } +function getRecurringRevenueCounts(startDay) { + if (!startDay) return {}; + + var dailyRevenueCounts = {}; + var startObj = objectIdWithTimestamp(ISODate(startDay + "T00:00:00.000Z")); + var cursor = db.payments.find({_id: {$gte: startObj}}); + while (cursor.hasNext()) { + var doc = cursor.next(); + var day = doc._id.getTimestamp().toISOString().substring(0, 10); + + if (doc.service === 'ios' || doc.service === 'bitcoin') continue; + + if (doc.productID && doc.productID.indexOf('gems_') === 0) { + if (!dailyRevenueCounts['DRR gems']) dailyRevenueCounts['DRR gems'] = {}; + if (!dailyRevenueCounts['DRR gems'][day]) dailyRevenueCounts['DRR gems'][day] = 0; + dailyRevenueCounts['DRR gems'][day] += doc.amount + } + else if (doc.productID === 'custom' || doc.service === 'external' || doc.service === 'invoice') { + if (!dailyRevenueCounts['DRR school sales']) dailyRevenueCounts['DRR school sales'] = {}; + if (!dailyRevenueCounts['DRR school sales'][day]) dailyRevenueCounts['DRR school sales'][day] = 0; + dailyRevenueCounts['DRR school sales'][day] += doc.amount + } + else if (doc.service === 'stripe' && doc.gems === 42000) { + if (!dailyRevenueCounts['DRR yearly subs']) dailyRevenueCounts['DRR yearly subs'] = {}; + if (!dailyRevenueCounts['DRR yearly subs'][day]) dailyRevenueCounts['DRR yearly subs'][day] = 0; + dailyRevenueCounts['DRR yearly subs'][day] += doc.amount + } + else if (doc.service === 'stripe') { + // Catches prepaids, and assumes all are type terminal_subscription + if (!dailyRevenueCounts['DRR monthly subs']) dailyRevenueCounts['DRR monthly subs'] = {}; + if (!dailyRevenueCounts['DRR monthly subs'][day]) dailyRevenueCounts['DRR monthly subs'][day] = 0; + dailyRevenueCounts['DRR monthly subs'][day] += doc.amount + } + else if (doc.service === 'paypal') { + if (!dailyRevenueCounts['DRR paypal']) dailyRevenueCounts['DRR paypal'] = {}; + if (!dailyRevenueCounts['DRR paypal'][day]) dailyRevenueCounts['DRR paypal'][day] = 0; + dailyRevenueCounts['DRR paypal'][day] += doc.amount + } + // else { + // // printjson(doc); + // // print(doc.service, doc.amount, doc.description, JSON.stringify(doc.stripe)); + // } + } + + return dailyRevenueCounts; +} + function insertEventCount(event, day, count) { // analytics.perdays schema in server/analytics/AnalyticsPeryDay.coffee day = day.replace(/-/g, ''); diff --git a/server/analytics/analytics_perday_handler.coffee b/server/analytics/analytics_perday_handler.coffee index 859f11a2a..6998dd0ca 100644 --- a/server/analytics/analytics_perday_handler.coffee +++ b/server/analytics/analytics_perday_handler.coffee @@ -21,6 +21,7 @@ class AnalyticsPerDayHandler extends Handler return @getLevelDropsBySlugs(req, res) if args[1] is 'level_drops' return @getLevelHelpsBySlugs(req, res) if args[1] is 'level_helps' return @getLevelSubscriptionsBySlugs(req, res) if args[1] is 'level_subscriptions' + return @getRecurringRevenue(req, res) if args[1] is 'recurring_revenue' super(arguments...) getActiveClasses: (req, res) -> @@ -465,4 +466,33 @@ class AnalyticsPerDayHandler extends Handler @levelSubscriptionsCache[cacheKey] = subscriptions @sendSuccess res, subscriptions + getRecurringRevenue: (req, res) -> + events = [ + 'DRR gems', + 'DRR school sales', + 'DRR yearly subs', + 'DRR monthly subs', + 'DRR paypal'] + + AnalyticsString.find({v: {$in: events}}).exec (err, documents) => + return @sendDatabaseError(res, err) if err + eventIDs = [] + eventStringMap = {} + for doc in documents + eventStringMap[doc._id.valueOf()] = doc.v + eventIDs.push doc._id + return @sendSuccess res, [] unless eventIDs.length is events.length + + AnalyticsPerDay.find({e: {$in: eventIDs}}).exec (err, documents) => + return @sendDatabaseError(res, err) if err + dayCountsMap = {} + for doc in documents + dayCountsMap[doc.d] ?= {} + dayCountsMap[doc.d][eventStringMap[doc.e.valueOf()]] = doc.c + recurringRevenue = [] + for key, val of dayCountsMap + recurringRevenue.push day: key, groups: dayCountsMap[key] ? {} + @sendSuccess(res, recurringRevenue) + + module.exports = new AnalyticsPerDayHandler()