Usage command

Stephen Paul Weber created

Customer has a CustomerUsage which can fetch data to build a UsageReport
which returns a jabber:x:data form "table" as per spec for per-day in
the last month.

Minutes come from cdr table in postgresql.
Messages come from redis.

Change summary

lib/customer.rb       |  3 ++
lib/customer_usage.rb | 47 +++++++++++++++++++++++++++++++++++++++++++++
lib/form_table.rb     | 34 ++++++++++++++++++++++++++++++++
lib/usage_report.rb   | 44 ++++++++++++++++++++++++++++++++++++++++++
sgx_jmp.rb            | 24 ++++++++++++++++++++++
test/test_customer.rb | 29 +++++++++++++++++++++++++++
6 files changed, 181 insertions(+)

Detailed changes

lib/customer.rb 🔗

@@ -4,6 +4,7 @@ require "forwardable"
 
 require_relative "./blather_ext"
 require_relative "./customer_plan"
+require_relative "./customer_usage"
 require_relative "./backend_sgx"
 require_relative "./ibr"
 require_relative "./payment_methods"
@@ -47,6 +48,7 @@ class Customer
 	def_delegators :@plan, :active?, :activate_plan_starting_now, :bill_plan,
 	               :currency, :merchant_account, :plan_name
 	def_delegators :@sgx, :register!, :registered?
+	def_delegator :@usage, :report, :usage_report
 
 	def initialize(
 		customer_id,
@@ -60,6 +62,7 @@ class Customer
 			plan: plan_name && Plan.for(plan_name),
 			expires_at: expires_at
 		)
+		@usage = CustomerUsage.new(customer_id)
 		@customer_id = customer_id
 		@balance = balance
 		@sgx = sgx

lib/customer_usage.rb 🔗

@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require_relative "./usage_report"
+
+class CustomerUsage
+	def initialize(customer_id)
+		@customer_id = customer_id
+	end
+
+	def report(range)
+		EMPromise.all([
+			messages_by_day(range),
+			minutes_by_day(range)
+		]).then do |args|
+			UsageReport.new(range, *args)
+		end
+	end
+
+	def messages_by_day(range)
+		EMPromise.all(range.first.downto(range.last).map { |day|
+			REDIS.zscore(
+				"jmp_customer_outbound_messages-#{@customer_id}",
+				day.strftime("%Y%m%d")
+			).then { |c| [day, c.to_i] if c }
+		}).then { |r| Hash[r.compact].tap { |h| h.default = 0 } }
+	end
+
+	QUERY_FOR_MINUTES = <<~SQL
+		SELECT
+			date_trunc('day', start)::date as day,
+			CEIL(SUM(billsec)/60.0)::integer as minutes
+		FROM cdr
+		WHERE customer_id=$1 and start >= $3 and start < $2
+		GROUP BY date_trunc('day', start);
+	SQL
+
+	def minutes_by_day(range)
+		DB.query_defer(
+			QUERY_FOR_MINUTES,
+			[@customer_id, range.first, range.last]
+		).then do |result|
+			result.each_with_object(Hash.new(0)) do |row, minutes|
+				minutes[row["day"]] = row["minutes"]
+			end
+		end
+	end
+end

lib/form_table.rb 🔗

@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class FormTable
+	def initialize(rows, **cols)
+		@cols = cols
+		@rows = rows
+	end
+
+	def add_to_form(form)
+		Nokogiri::XML::Builder.with(form) do |xml|
+			xml.reported do
+				@cols.each do |var, label|
+					xml.field(var: var.to_s, label: label.to_s)
+				end
+			end
+
+			add_rows_to_xml(xml)
+		end
+	end
+
+protected
+
+	def add_rows_to_xml(xml)
+		@rows.each do |row|
+			xml.item do
+				row.each.with_index do |val, idx|
+					xml.field(var: @cols.keys[idx].to_s) do
+						xml.value val.to_s
+					end
+				end
+			end
+		end
+	end
+end

lib/usage_report.rb 🔗

@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require_relative "./form_table"
+
+class UsageReport
+	def initialize(report_for, messages, minutes)
+		@report_for = report_for
+		@messages = messages
+		@minutes = minutes
+	end
+
+	def ==(other)
+		report_for == other.report_for &&
+			messages == other.messages &&
+			minutes == other.minutes
+	end
+
+	def form
+		form = Blather::Stanza::X.new(:result)
+		form.title =
+			form.instructions =
+				"Usage from #{report_for.first} to #{report_for.last}"
+		form_table.add_to_form(form)
+		form
+	end
+
+	def form_table
+		total_messages = 0
+		total_minutes = 0
+
+		FormTable.new(
+			@report_for.first.downto(@report_for.last).map do |day|
+				total_messages += @messages[day]
+				total_minutes += @minutes[day]
+				[day, @messages[day], @minutes[day]]
+			end + [["Total", total_messages, total_minutes]],
+			day: "Day", messages: "Messages", minutes: "Minutes"
+		)
+	end
+
+protected
+
+	attr_reader :report_for, :messages, :minutes
+end

sgx_jmp.rb 🔗

@@ -253,6 +253,11 @@ disco_items node: "http://jabber.org/protocol/commands" do |iq|
 			iq.to,
 			"jabber:iq:register",
 			"Register"
+		),
+		Blather::Stanza::DiscoItems::Item.new(
+			iq.to,
+			"usage",
+			"Show Monthly Usage"
 		)
 	]
 	self << reply
@@ -316,6 +321,25 @@ command :execute?, node: "buy-credit", sessionid: nil do |iq|
 	}.catch { |e| panic(e, sentry_hub) }
 end
 
+command :execute?, node: "usage", sessionid: nil do |iq|
+	sentry_hub = new_sentry_hub(iq, name: iq.node)
+	report_for = (Date.today..(Date.today << 1))
+
+	Customer.for_jid(iq.from.stripped).then { |customer|
+		sentry_hub.current_scope.set_user(
+			id: customer.customer_id,
+			jid: iq.from.stripped.to_s
+		)
+
+		customer.usage_report(report_for)
+	}.then { |usage_report|
+		reply = iq.reply
+		reply.status = :completed
+		reply.command << usage_report.form
+		BLATHER << reply
+	}.catch { |e| panic(e, sentry_hub) }
+end
+
 command :execute?, node: "web-register", sessionid: nil do |iq|
 	sentry_hub = new_sentry_hub(iq, name: iq.node)
 

test/test_customer.rb 🔗

@@ -8,6 +8,8 @@ Customer::BRAINTREE = Minitest::Mock.new
 Customer::REDIS = Minitest::Mock.new
 Customer::DB = Minitest::Mock.new
 CustomerPlan::DB = Minitest::Mock.new
+CustomerUsage::REDIS = Minitest::Mock.new
+CustomerUsage::DB = Minitest::Mock.new
 
 class CustomerTest < Minitest::Test
 	def test_for_jid
@@ -176,4 +178,31 @@ class CustomerTest < Minitest::Test
 		Customer.new("test").stanza_from(m)
 		Customer::BLATHER.verify
 	end
+
+	def test_customer_usage_report
+		report_for = (Date.today..(Date.today - 1))
+		report_for.first.downto(report_for.last).each.with_index do |day, idx|
+			CustomerUsage::REDIS.expect(
+				:zscore,
+				EMPromise.resolve(idx),
+				["jmp_customer_outbound_messages-test", day.strftime("%Y%m%d")]
+			)
+		end
+		CustomerUsage::DB.expect(
+			:query_defer,
+			EMPromise.resolve([{ "day" => report_for.first, "minutes" => 123 }]),
+			[String, ["test", report_for.first, report_for.last]]
+		)
+		assert_equal(
+			UsageReport.new(
+				report_for, {
+					Date.today => 0,
+					(Date.today - 1) => 1
+				},
+				Date.today => 123
+			),
+			Customer.new("test").usage_report(report_for).sync
+		)
+	end
+	em :test_customer_usage_report
 end