Merge branch 'tx-list'

Stephen Paul Weber created

* tx-list:
  Admin Command Menu + Admin Financial View
  Transactions List
  Customer Financials
  Telephone Link

Change summary

forms/admin_financial_info.rb        |  9 +++
forms/admin_info.rb                  |  8 +++
forms/admin_menu.rb                  | 14 +++++
forms/admin_payment_methods.rb       | 10 +++
forms/admin_transaction_list.rb      | 10 +++
forms/transactions.rb                |  9 +++
lib/admin_command.rb                 | 79 ++++++++++++++++++++++++++++++
lib/alt_top_up_form.rb               |  5 +
lib/customer.rb                      | 36 +++----------
lib/customer_finacials.rb            | 74 ++++++++++++++++++++++++++++
lib/customer_info.rb                 |  9 +++
lib/financial_info.rb                | 25 +++++++++
lib/form_template.rb                 | 11 ++++
lib/payment_methods.rb               | 12 ++++
lib/transaction.rb                   |  6 -
sgx_jmp.rb                           | 19 +++++-
test/test_alt_top_up_form.rb         |  6 +-
test/test_buy_account_credit_form.rb |  2 
test/test_customer.rb                | 14 +++--
test/test_low_balance.rb             |  4 
test/test_registration.rb            |  8 +-
test/test_transaction.rb             | 10 ++-
22 files changed, 320 insertions(+), 60 deletions(-)

Detailed changes

forms/admin_financial_info.rb 🔗

@@ -0,0 +1,9 @@
+result!
+title "Customer Financial Info"
+
+field(
+	var: "declines",
+	label: "Declines",
+	description: "out of 2",
+	value: @info.declines.to_s
+)

forms/admin_info.rb 🔗

@@ -23,6 +23,14 @@ field(
 	value: @admin_info.customer_id
 )
 
+if @admin_info.info.tel
+	field(
+		var: "tel_link",
+		label: "Phone Number Info",
+		value: @admin_info.tel_link
+	)
+end
+
 if @admin_info.fwd.uri
 	field(
 		var: "fwd",

forms/admin_menu.rb 🔗

@@ -0,0 +1,14 @@
+form!
+title "Menu"
+
+field(
+	var: "action",
+	type: "list-single",
+	open: true,
+	label: "Pick an action",
+	description: "or put a new customer info",
+	options: [
+		{ value: "info", label: "Customer Info" },
+		{ value: "financial", label: "Customer Billing Information" }
+	]
+)

forms/admin_payment_methods.rb 🔗

@@ -0,0 +1,10 @@
+result!
+title "Customer Payment Methods"
+
+unless @payment_methods.empty?
+	field @payment_methods.to_list_single(label: "Credit Cards")
+end
+
+AltTopUpForm::HasBitcoinAddresses.new(@btc_addresses, desc: nil).each do |spec|
+	field spec
+end

forms/admin_transaction_list.rb 🔗

@@ -0,0 +1,10 @@
+result!
+title "Transactions"
+
+table(
+	@transactions,
+	formatted_amount: "Amount",
+	note: "Note",
+	created_at: "Date",
+	transaction_id: "Transaction ID"
+)

forms/transactions.rb 🔗

@@ -0,0 +1,9 @@
+result!
+title "Transactions"
+
+table(
+	@transactions,
+	formatted_amount: "Amount",
+	note: "Note",
+	created_at: "Date"
+)

lib/admin_command.rb 🔗

@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require_relative "customer_info_form"
+require_relative "financial_info"
+require_relative "form_template"
+
+class AdminCommand
+	def initialize(target_customer)
+		@target_customer = target_customer
+	end
+
+	def start
+		action_info.then { menu }
+	end
+
+	def reply(form)
+		Command.reply { |reply|
+			reply.allowed_actions = [:next]
+			reply.command << form
+		}
+	end
+
+	def menu
+		reply(FormTemplate.render("admin_menu")).then do |response|
+			handle(response.form.field("action").value)
+		end
+	end
+
+	def handle(action)
+		if respond_to?("action_#{action}")
+			send("action_#{action}")
+		else
+			new_context(action)
+		end.then { menu }
+	end
+
+	def new_context(q)
+		CustomerInfoForm.new.parse_something(q).then do |new_customer|
+			if new_customer.respond_to?(:customer_id)
+				AdminCommand.new(new_customer).start
+			else
+				reply(new_customer.form)
+			end
+		end
+	end
+
+	def action_info
+		@target_customer.admin_info.then do |info|
+			reply(info.form)
+		end
+	end
+
+	def action_financial
+		AdminFinancialInfo.for(@target_customer).then do |financial_info|
+			reply(FormTemplate.render(
+				"admin_financial_info",
+				info: financial_info
+			)).then {
+				pay_methods(financial_info)
+			}.then {
+				transactions(financial_info)
+			}
+		end
+	end
+
+	def pay_methods(financial_info)
+		reply(FormTemplate.render(
+			"admin_payment_methods",
+			**financial_info.to_h
+		))
+	end
+
+	def transactions(financial_info)
+		reply(FormTemplate.render(
+			"admin_transaction_list",
+			transactions: financial_info.transactions
+		))
+	end
+end

lib/alt_top_up_form.rb 🔗

@@ -101,8 +101,9 @@ class AltTopUpForm
 	end
 
 	class HasBitcoinAddresses
-		def initialize(addrs)
+		def initialize(addrs, desc: DESCRIPTION)
 			@addrs = addrs
+			@desc = desc
 		end
 
 		DESCRIPTION =
@@ -116,7 +117,7 @@ class AltTopUpForm
 				var: "btc_address",
 				type: "fixed",
 				label: "Bitcoin Addresses",
-				description: DESCRIPTION,
+				description: @desc,
 				value: @addrs
 			)
 		end

lib/customer.rb 🔗

@@ -4,10 +4,11 @@ require "forwardable"
 
 require_relative "./api"
 require_relative "./blather_ext"
-require_relative "./customer_info"
-require_relative "./customer_ogm"
-require_relative "./customer_plan"
 require_relative "./customer_usage"
+require_relative "./customer_plan"
+require_relative "./customer_ogm"
+require_relative "./customer_info"
+require_relative "./customer_finacials"
 require_relative "./backend_sgx"
 require_relative "./ibr"
 require_relative "./payment_methods"
@@ -27,6 +28,9 @@ class Customer
 	def_delegators :@sgx, :register!, :registered?, :set_ogm_url,
 	               :fwd, :transcription_enabled
 	def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage
+	def_delegators :@financials, :payment_methods, :btc_addresses,
+	               :add_btc_address, :declines, :mark_decline,
+	               :transactions
 
 	def initialize(
 		customer_id,
@@ -38,6 +42,7 @@ class Customer
 	)
 		@plan = plan
 		@usage = CustomerUsage.new(customer_id)
+		@financials = CustomerFinancials.new(customer_id)
 		@customer_id = customer_id
 		@jid = jid
 		@balance = balance
@@ -61,14 +66,6 @@ class Customer
 		)
 	end
 
-	def payment_methods
-		BRAINTREE
-			.customer
-			.find(@customer_id)
-			.catch { OpenStruct.new(payment_methods: []) }
-			.then(PaymentMethods.method(:for_braintree_customer))
-	end
-
 	def unused_invites
 		promise = DB.query_defer(<<~SQL, [customer_id])
 			SELECT code FROM unused_invites WHERE creator_id=$1
@@ -105,23 +102,6 @@ class Customer
 		sip_account.with_random_password.put
 	end
 
-	def btc_addresses
-		REDIS.smembers("jmp_customer_btc_addresses-#{customer_id}")
-	end
-
-	def add_btc_address
-		REDIS.spopsadd([
-			"jmp_available_btc_addresses",
-			"jmp_customer_btc_addresses-#{customer_id}"
-		]).then do |addr|
-			ELECTRUM.notify(
-				addr,
-				CONFIG[:electrum_notify_url].call(addr, customer_id)
-			)
-			addr
-		end
-	end
-
 	def admin?
 		CONFIG[:admins].include?(jid.to_s)
 	end

lib/customer_finacials.rb 🔗

@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+class CustomerFinancials
+	def initialize(customer_id)
+		@customer_id = customer_id
+	end
+
+	def payment_methods
+		BRAINTREE
+			.customer
+			.find(@customer_id)
+			.catch { OpenStruct.new(payment_methods: []) }
+			.then(PaymentMethods.method(:for_braintree_customer))
+	end
+
+	def btc_addresses
+		REDIS.smembers("jmp_customer_btc_addresses-#{@customer_id}")
+	end
+
+	def add_btc_address
+		REDIS.spopsadd([
+			"jmp_available_btc_addresses",
+			"jmp_customer_btc_addresses-#{@customer_id}"
+		]).then do |addr|
+			ELECTRUM.notify(
+				addr,
+				CONFIG[:electrum_notify_url].call(addr, @customer_id)
+			)
+			addr
+		end
+	end
+
+	def declines
+		REDIS.get("jmp_pay_decline-#{@customer_id}")
+	end
+
+	def mark_decline
+		REDIS.incr("jmp_pay_decline-#{@customer_id}").then do
+			REDIS.expire("jmp_pay_decline-#{@customer_id}", 60 * 60 * 24)
+		end
+	end
+
+	class TransactionInfo
+		value_semantics do
+			transaction_id String
+			created_at Time
+			amount BigDecimal
+			note String
+		end
+
+		def formatted_amount
+			"$%.4f" % amount
+		end
+	end
+
+	TRANSACTIONS_SQL = <<~SQL
+		SELECT
+			transaction_id,
+			created_at,
+			amount,
+			note
+		FROM transactions WHERE customer_id = $1;
+	SQL
+
+	def transactions
+		txns = DB.query_defer(TRANSACTIONS_SQL, [@customer_id])
+
+		txns.then do |rows|
+			rows.map { |row|
+				TransactionInfo.new(**row.transform_keys(&:to_sym))
+			}
+		end
+	end
+end

lib/customer_info.rb 🔗

@@ -118,4 +118,13 @@ class AdminInfo
 	def form
 		FormTemplate.render("admin_info", admin_info: self)
 	end
+
+	def tel_link
+		[
+			"https://dashboard.bandwidth.com/portal/r/a",
+			CONFIG[:creds][:account],
+			"numbers/details",
+			info.tel.gsub(/\A\+1/, "")
+		].join("/")
+	end
 end

lib/financial_info.rb 🔗

@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require "value_semantics/monkey_patched"
+
+class AdminFinancialInfo
+	value_semantics do
+		transactions ArrayOf(CustomerFinancials::TransactionInfo)
+		declines Integer
+		btc_addresses ArrayOf(String)
+		payment_methods PaymentMethods
+	end
+
+	def self.for(customer)
+		EMPromise.all([
+			customer.transactions, customer.declines,
+			customer.payment_methods, customer.btc_addresses
+		]).then do |transactions, declines, payment_methods, btc_addresses|
+			new(
+				transactions: transactions,
+				declines: declines || 0,
+				payment_methods: payment_methods, btc_addresses: btc_addresses
+			)
+		end
+	end
+end

lib/form_template.rb 🔗

@@ -80,6 +80,17 @@ class FormTemplate
 			open || regex || range
 		end
 
+		# Given a map of fields to labels, and a list of objects this will
+		# produce a table from calling each field's method on every object in the
+		# list. So, this list is value_semantics / OpenStruct style
+		def table(list, **fields)
+			keys = fields.keys
+			FormTable.new(
+				list.map { |x| keys.map { |k| x.public_send(k) } },
+				**fields
+			).add_to_form(@__form)
+		end
+
 		def field(datatype: nil, open: false, regex: nil, range: nil, **kwargs)
 			f = Blather::Stanza::X::Field.new(kwargs)
 			if datatype || open || regex || range

lib/payment_methods.rb 🔗

@@ -50,7 +50,13 @@ class PaymentMethods
 		false
 	end
 
-	class Empty
+	def to_a
+		@methods
+	end
+
+	class Empty < PaymentMethods
+		def initialize; end
+
 		def default_payment_method; end
 
 		def to_list_single(*)
@@ -60,5 +66,9 @@ class PaymentMethods
 		def empty?
 			true
 		end
+
+		def to_a
+			[]
+		end
 	end
 end

lib/transaction.rb 🔗

@@ -4,7 +4,7 @@ require "bigdecimal"
 
 class Transaction
 	def self.sale(customer, amount:, payment_method: nil)
-		REDIS.get("jmp_pay_decline-#{customer.customer_id}").then do |declines|
+		customer.declines.then do |declines|
 			raise "too many declines" if declines.to_i >= 2
 
 			BRAINTREE.transaction.sale(
@@ -20,9 +20,7 @@ class Transaction
 	def self.decline_guard(customer, response)
 		return if response.success?
 
-		REDIS.incr("jmp_pay_decline-#{customer.customer_id}").then do
-			REDIS.expire("jmp_pay_decline-#{customer.customer_id}", 60 * 60 * 24)
-		end
+		customer.mark_decline
 		raise response.message
 	end
 

sgx_jmp.rb 🔗

@@ -67,6 +67,7 @@ end
 
 require_relative "lib/polyfill"
 require_relative "lib/alt_top_up_form"
+require_relative "lib/admin_command"
 require_relative "lib/add_bitcoin_address"
 require_relative "lib/backend_sgx"
 require_relative "lib/bwmsgsv2_repo"
@@ -472,6 +473,18 @@ Command.new(
 	end
 }.register(self).then(&CommandList.method(:register))
 
+Command.new(
+	"transactions",
+	"Show Transactions",
+	list_for: ->(customer:, **) { !!customer&.currency }
+) {
+	Command.customer.then(&:transactions).then do |txs|
+		Command.finish do |reply|
+			reply.command << FormTemplate.render("transactions", transactions: txs)
+		end
+	end
+}.register(self).then(&CommandList.method(:register))
+
 Command.new(
 	"configure calls",
 	"Configure Calls",
@@ -714,11 +727,7 @@ Command.new(
 		}.then { |response|
 			CustomerInfoForm.new.find_customer(response)
 		}.then do |target_customer|
-			target_customer.admin_info.then do |info|
-				Command.finish do |reply|
-					reply.command << info.form
-				end
-			end
+			AdminCommand.new(target_customer).start
 		end
 	end
 }.register(self).then(&CommandList.method(:register))

test/test_alt_top_up_form.rb 🔗

@@ -6,7 +6,7 @@ require "customer"
 
 class AltTopUpFormTest < Minitest::Test
 	def test_for
-		Customer::REDIS.expect(
+		CustomerFinancials::REDIS.expect(
 			:smembers,
 			EMPromise.resolve([]),
 			["jmp_customer_btc_addresses-test"]
@@ -19,7 +19,7 @@ class AltTopUpFormTest < Minitest::Test
 	em :test_for
 
 	def test_for_addresses
-		Customer::REDIS.expect(
+		CustomerFinancials::REDIS.expect(
 			:smembers,
 			EMPromise.resolve(["testaddr"]),
 			["jmp_customer_btc_addresses-test"]
@@ -32,7 +32,7 @@ class AltTopUpFormTest < Minitest::Test
 	em :test_for_addresses
 
 	def test_for_cad
-		Customer::REDIS.expect(
+		CustomerFinancials::REDIS.expect(
 			:smembers,
 			EMPromise.resolve([]),
 			["jmp_customer_btc_addresses-test"]

test/test_buy_account_credit_form.rb 🔗

@@ -17,7 +17,7 @@ class BuyAccountCreditFormTest < Minitest::Test
 
 	def test_for
 		braintree_customer = Minitest::Mock.new
-		Customer::BRAINTREE.expect(:customer, braintree_customer)
+		CustomerFinancials::BRAINTREE.expect(:customer, braintree_customer)
 		braintree_customer.expect(
 			:find,
 			EMPromise.resolve(OpenStruct.new(payment_methods: [])),

test/test_customer.rb 🔗

@@ -5,13 +5,15 @@ require "customer"
 
 Customer::BLATHER = Minitest::Mock.new
 Customer::BRAINTREE = Minitest::Mock.new
-Customer::ELECTRUM = Minitest::Mock.new
 Customer::REDIS = Minitest::Mock.new
 Customer::DB = Minitest::Mock.new
 Customer::IQ_MANAGER = Minitest::Mock.new
 CustomerPlan::DB = Minitest::Mock.new
 CustomerUsage::REDIS = Minitest::Mock.new
 CustomerUsage::DB = Minitest::Mock.new
+CustomerFinancials::REDIS = Minitest::Mock.new
+CustomerFinancials::ELECTRUM = Minitest::Mock.new
+CustomerFinancials::BRAINTREE = Minitest::Mock.new
 
 class CustomerTest < Minitest::Test
 	def test_bill_plan_activate
@@ -191,7 +193,7 @@ class CustomerTest < Minitest::Test
 	em :test_sip_account_error
 
 	def test_btc_addresses
-		Customer::REDIS.expect(
+		CustomerFinancials::REDIS.expect(
 			:smembers,
 			EMPromise.resolve(["testaddr"]),
 			["jmp_customer_btc_addresses-test"]
@@ -202,19 +204,19 @@ class CustomerTest < Minitest::Test
 	em :test_btc_addresses
 
 	def test_add_btc_address
-		Customer::REDIS.expect(
+		CustomerFinancials::REDIS.expect(
 			:spopsadd,
 			EMPromise.resolve("testaddr"),
 			[["jmp_available_btc_addresses", "jmp_customer_btc_addresses-test"]]
 		)
-		Customer::ELECTRUM.expect(
+		CustomerFinancials::ELECTRUM.expect(
 			:notify,
 			EMPromise.resolve(nil),
 			["testaddr", "http://notify.example.com"]
 		)
 		assert_equal "testaddr", customer.add_btc_address.sync
-		assert_mock Customer::REDIS
-		assert_mock Customer::ELECTRUM
+		assert_mock CustomerFinancials::REDIS
+		assert_mock CustomerFinancials::ELECTRUM
 	end
 	em :test_add_btc_address
 end

test/test_low_balance.rb 🔗

@@ -5,7 +5,7 @@ require "low_balance"
 
 ExpiringLock::REDIS = Minitest::Mock.new
 CustomerPlan::REDIS = Minitest::Mock.new
-Customer::REDIS = Minitest::Mock.new
+CustomerFinancials::REDIS = Minitest::Mock.new
 
 class LowBalanceTest < Minitest::Test
 	def test_for_locked
@@ -24,7 +24,7 @@ class LowBalanceTest < Minitest::Test
 			EMPromise.resolve(0),
 			["jmp_customer_low_balance-test"]
 		)
-		Customer::REDIS.expect(
+		CustomerFinancials::REDIS.expect(
 			:smembers,
 			EMPromise.resolve([]),
 			["jmp_customer_btc_addresses-test"]

test/test_registration.rb 🔗

@@ -254,7 +254,7 @@ class RegistrationTest < Minitest::Test
 	end
 
 	class PaymentTest < Minitest::Test
-		Customer::BRAINTREE = Minitest::Mock.new
+		CustomerFinancials::BRAINTREE = Minitest::Mock.new
 
 		def test_for_bitcoin
 			cust = Minitest::Mock.new(customer)
@@ -273,7 +273,7 @@ class RegistrationTest < Minitest::Test
 
 		def test_for_credit_card
 			braintree_customer = Minitest::Mock.new
-			Customer::BRAINTREE.expect(
+			CustomerFinancials::BRAINTREE.expect(
 				:customer,
 				braintree_customer
 			)
@@ -313,7 +313,7 @@ class RegistrationTest < Minitest::Test
 
 		class BitcoinTest < Minitest::Test
 			Registration::Payment::Bitcoin::BTC_SELL_PRICES = Minitest::Mock.new
-			Customer::REDIS = Minitest::Mock.new
+			CustomerFinancials::REDIS = Minitest::Mock.new
 
 			def setup
 				@customer = Minitest::Mock.new(
@@ -330,7 +330,7 @@ class RegistrationTest < Minitest::Test
 			end
 
 			def test_write
-				Customer::REDIS.expect(
+				CustomerFinancials::REDIS.expect(
 					:smembers,
 					EMPromise.resolve([]),
 					["jmp_customer_btc_addresses-test"]

test/test_transaction.rb 🔗

@@ -18,17 +18,17 @@ class TransactionTest < Minitest::Test
 		)
 
 	def test_sale_fails
-		Transaction::REDIS.expect(
+		CustomerFinancials::REDIS.expect(
 			:get,
 			EMPromise.resolve("1"),
 			["jmp_pay_decline-test"]
 		)
-		Transaction::REDIS.expect(
+		CustomerFinancials::REDIS.expect(
 			:incr,
 			EMPromise.resolve(nil),
 			["jmp_pay_decline-test"]
 		)
-		Transaction::REDIS.expect(
+		CustomerFinancials::REDIS.expect(
 			:expire,
 			EMPromise.resolve(nil),
 			["jmp_pay_decline-test", 60 * 60 * 24]
@@ -49,11 +49,12 @@ class TransactionTest < Minitest::Test
 				payment_method: OpenStruct.new(token: "token")
 			).sync
 		end
+		assert_mock CustomerFinancials::REDIS
 	end
 	em :test_sale_fails
 
 	def test_sale
-		Transaction::REDIS.expect(
+		CustomerFinancials::REDIS.expect(
 			:get,
 			EMPromise.resolve("1"),
 			["jmp_pay_decline-test"]
@@ -81,6 +82,7 @@ class TransactionTest < Minitest::Test
 			payment_method: OpenStruct.new(token: "token")
 		).sync
 		assert_kind_of Transaction, result
+		assert_mock CustomerFinancials::REDIS
 	end
 	em :test_sale