Allow ordering SIMs from the bot

Stephen Paul Weber created

Change summary

.rubocop.yml                     |   2 
config-schema.dhall              |  12 ++
config.dhall.sample              |   8 +
forms/order_sim/esim_complete.rb |  19 +++
forms/order_sim/header.rb        |  23 ++++
forms/order_sim/please_top_up.rb |   8 +
forms/order_sim/with_balance.rb  |  10 ++
forms/order_sim/with_top_up.rb   |   8 +
forms/sim_details.rb             |  32 +++++-
lib/form_template.rb             |  13 ++
lib/low_balance.rb               |   8 +
lib/sim.rb                       |   8 +
lib/sim_order.rb                 | 162 ++++++++++++++++++++++++++++++++++
lib/sim_repo.rb                  |  40 ++++++-
lib/transaction.rb               |   4 
sgx_jmp.rb                       |  48 +++++++--
16 files changed, 373 insertions(+), 32 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -24,7 +24,7 @@ Metrics/AbcSize:
     - test/*
 
 Metrics/ParameterLists:
-  Max: 7
+  Max: 8
 
 Naming/MethodParameterName:
   AllowNamesEndingInNumbers: false

config-schema.dhall 🔗

@@ -47,6 +47,18 @@
 , server : { host : Text, port : Natural }
 , sgx : Text
 , simpleswap_api_key : Text
+, sims :
+    { esim :
+        List
+          { mapKey : < CAD | USD >
+          , mapValue : { plan : Text, price : Natural }
+          }
+    , sim :
+        List
+          { mapKey : < CAD | USD >
+          , mapValue : { plan : Text, price : Natural }
+          }
+    }
 , sip : { app : Text, realm : Text }
 , sip_host : Text
 , snikket_hosting_api : Text

config.dhall.sample 🔗

@@ -59,6 +59,14 @@ in
 			>.unlimited
 		}
 	],
+	sims = {
+		sim = [
+			{ mapKey = <CAD|USD>.CAD, mapValue = { price = 1, plan = "$1 / GB + $1 / year" } }
+		],
+		esim = [
+			{ mapKey = <CAD|USD>.CAD, mapValue = { price = 1, plan = "$1 / GB + $1 / year" } }
+		]
+	},
 	electrum = {
 		rpc_uri = "",
 		rpc_username = "",

forms/order_sim/esim_complete.rb 🔗

@@ -0,0 +1,19 @@
+result!
+title "eSIM"
+
+field(
+	var: "qr",
+	label: "Scan this QR Code or copy and paste the LPA into your eSIM manager",
+	media: [{
+		type: "image/png",
+		uri:
+			"https://zxing.org/w/chart?cht=qr&chs=350x350&chld=H&choe=UTF-8&chl=" \
+			"#{ERB::Util.url_encode(@sim.lpa_code)}"
+	}],
+	value: @sim.lpa_code
+)
+
+instructions(
+	"An eSIM can only be downloaded and activated once, so DO NOT DELETE the " \
+	"eSIM under any circumstances."
+)

forms/order_sim/header.rb 🔗

@@ -0,0 +1,23 @@
+@fillable_fields.empty? ? result! : form!
+title "Order #{@label || '(e)SIM'}"
+
+instructions(
+	"Our #{@label || '(e)SIM'}s provide a plan that is prepay pay-as-you-go " \
+	"and purely LTE data roaming over all of the USA and Canada " \
+	"(on Rogers, Bell, Telus, AT&T, T-Mobile, and Union Telecom). " \
+	"Prepaid data never expires so long as your plan is active."
+)
+
+field(
+	label: "Plan Details",
+	value: @plan.to_s,
+	type: "fixed",
+	description: "Prepaid in increments of 5 GB"
+)
+
+field(
+	label: "Purchase Price",
+	value: "$%.2f" % @price,
+	type: "fixed",
+	description: "Includes first year and 1 GB data"
+)

forms/order_sim/please_top_up.rb 🔗

@@ -0,0 +1,8 @@
+instance_eval File.read("#{__dir__}/header.rb")
+
+field(
+	type: "fixed",
+	value:
+		"Your need to top up your account balance before " \
+		"this purchase can be completed"
+)

forms/order_sim/with_balance.rb 🔗

@@ -0,0 +1,10 @@
+instance_eval File.read("#{__dir__}/header.rb")
+
+field(
+	type: "fixed",
+	value: "This purchase will be paid out of your account balance"
+)
+
+@fillable_fields.each do |f|
+	field(**f)
+end

forms/order_sim/with_top_up.rb 🔗

@@ -0,0 +1,8 @@
+instance_eval File.read("#{__dir__}/header.rb")
+
+field(
+	label: "Your default payment method will be billed",
+	value: "$%.2f" % @top_up_amount,
+	type: "fixed",
+	description: "with the rest coming from your balance"
+)

forms/sim_details.rb 🔗

@@ -1,9 +1,27 @@
-result!
+form!
 title "(e)SIM Details"
 
-table(
-	@sims,
-	iccid: "ICCID",
-	remaining_usage_mb: "MB Remaining",
-	nickname: "Nickname"
-)
+if @sims.empty?
+	instructions "You have no (e)SIMs."
+else
+	field(
+		var: "iccid",
+		label: "(e)SIMs",
+		type: "fixed",
+		value: @sims.map(&:to_s)
+	)
+end
+
+if @buy
+	field(
+		var: "http://jabber.org/protocol/commands#actions",
+		label: "Action",
+		type: "list-single",
+		options: [
+			{ label: "Done", value: "complete" },
+			{ label: "Order new Physical SIM", value: "order-sim" },
+			{ label: "Order new eSIM", value: "order-esim" }
+		],
+		value: "complete"
+	)
+end

lib/form_template.rb 🔗

@@ -80,6 +80,16 @@ class FormTemplate
 			open || regex || range
 		end
 
+		def add_media(field, media)
+			Nokogiri::XML::Builder.with(field) do |xml|
+				xml.media(xmlns: "urn:xmpp:media-element") do
+					media.each do |item|
+						xml.uri(item[:uri], type: item[:type])
+					end
+				end
+			end
+		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
@@ -101,10 +111,11 @@ class FormTemplate
 
 		def field(
 			datatype: nil, open: false, regex: nil, range: nil,
-			suffix: nil, prefix: nil,
+			suffix: nil, prefix: nil, media: [],
 			**kwargs
 		)
 			f = Blather::Stanza::X::Field.new(kwargs)
+			add_media(f, media) unless media.empty?
 			if datatype || open || regex || range
 				validate(f, datatype: datatype, open: open, regex: regex, range: range)
 			end

lib/low_balance.rb 🔗

@@ -33,6 +33,10 @@ class LowBalance
 		@transaction_amount = transaction_amount
 	end
 
+	def can_top_up?
+		false
+	end
+
 	def notify!
 		m = Blather::Stanza::Message.new
 		m.from = CONFIG[:notify_from]
@@ -96,6 +100,10 @@ class LowBalance
 			].max
 		end
 
+		def can_top_up?
+			true
+		end
+
 		def sale
 			CreditCardSale.create(@customer, amount: top_up_amount)
 		end

lib/sim.rb 🔗

@@ -22,4 +22,12 @@ class SIM
 	def remaining_usage_mb
 		(remaining_usage_kb / 1024.0).round(2)
 	end
+
+	def to_s
+		if nickname
+			"#{nickname} (#{iccid}) : #{remaining_usage_mb} MB"
+		else
+			"#{iccid} : #{remaining_usage_mb} MB"
+		end
+	end
 end

lib/sim_order.rb 🔗

@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+require "bigdecimal/util"
+
+require_relative "low_balance"
+require_relative "transaction"
+
+class SIMOrder
+	def self.for(customer, price:, **kwargs)
+		price = price.to_i / 100.to_d
+		return new(customer, price: price, **kwargs) if customer.balance >= price
+
+		LowBalance::AutoTopUp.for(customer, price).then do |top_up|
+			if top_up.can_top_up?
+				WithTopUp.new(customer, self, price: price, top_up: top_up, **kwargs)
+			else
+				PleaseTopUp.new(price: price, **kwargs)
+			end
+		end
+	end
+
+	def self.label
+		"SIM"
+	end
+
+	def self.fillable_fields
+		[
+			{
+				type: "text-multi",
+				label: "Shipping Address",
+				var: "addr",
+				required: true
+			}
+		]
+	end
+
+	def initialize(customer, price:, plan:)
+		@customer = customer
+		@price = price
+		@plan = plan
+		@sim_repo = SIMRepo.new(db: DB)
+	end
+
+	def form
+		FormTemplate.render(
+			"order_sim/with_balance",
+			price: @price,
+			plan: @plan,
+			label: self.class.label,
+			fillable_fields: self.class.fillable_fields
+		)
+	end
+
+	def complete(iq)
+		addr = Array(iq.form.field("addr").value).join("\n")
+		EMPromise.resolve(nil).then { commit }.then do |sim|
+			@customer.stanza_from(Blather::Stanza::Message.new(
+				Blather::JID.new(""), # Doesn't matter, sgx is set to direct target
+				"SIM ORDER: #{sim.iccid}\n#{addr}"
+			))
+			Command.finish(
+				"You will receive an notice from support when your SIM ships."
+			)
+		end
+	end
+
+protected
+
+	def commit
+		DB.transaction do
+			sim = @sim_repo.available.sync
+			@sim_repo.put_owner(sim, @customer, self.class.label).sync
+			keepgo_tx = @sim_repo.refill(sim, amount_mb: 1024).sync
+			raise "SIM activation failed" unless keepgo_tx["ack"] == "success"
+
+			transaction(sim, keepgo_tx).insert_tx
+			sim
+		end
+	end
+
+	def transaction(sim, keepgo_tx)
+		Transaction.new(
+			customer_id: @customer.customer_id,
+			transaction_id: keepgo_tx["transaction_id"],
+			amount: -@price,
+			note: "#{self.class.label} Activation #{sim.iccid}"
+		)
+	end
+
+	class ESIM < SIMOrder
+		def self.label
+			"eSIM"
+		end
+
+		def self.fillable_fields
+			[]
+		end
+
+		def complete(_)
+			EMPromise.resolve(nil).then { commit }.then do |sim|
+				Command.finish do |reply|
+					oob = OOB.find_or_create(reply.command)
+					oob.url = sim.lpa_code
+					oob.desc = "LPA Activation Code"
+					reply.command << FormTemplate.render(
+						"order_sim/esim_complete", sim: sim
+					)
+				end
+			end
+		end
+	end
+
+	class WithTopUp
+		def initialize(customer, continue, price:, plan:, top_up:)
+			@customer = customer
+			@price = price
+			@plan = plan
+			@top_up = top_up
+			@continue = continue
+		end
+
+		def form
+			FormTemplate.render(
+				"order_sim/with_top_up",
+				price: @price,
+				plan: @plan,
+				top_up_amount: @top_up.top_up_amount,
+				label: @continue.label,
+				fillable_fields: @continue.fillable_fields
+			)
+		end
+
+		def complete(iq)
+			@top_up.notify!.then do |amount|
+				if amount.positive?
+					@continue.new(@customer, price: @price, plan: @plan).complete(iq)
+				else
+					Command.finish("Could not top up", type: :error)
+				end
+			end
+		end
+	end
+
+	class PleaseTopUp
+		def initialize(price:, plan:)
+			@price = price
+			@plan = plan
+		end
+
+		def form
+			FormTemplate.render(
+				"order_sim/please_top_up",
+				price: @price,
+				plan: @plan
+			)
+		end
+
+		def complete(_)
+			Command.finish
+		end
+	end
+end

lib/sim_repo.rb 🔗

@@ -10,17 +10,31 @@ class SIMRepo
 		db Anything(), default: LazyObject.new { DB }
 	end
 
+	KEEPGO_HEADERS = {
+		"Accept" => "application/json",
+		"apiKey" => CONFIG[:keepgo][:api_key],
+		"accessToken" => CONFIG[:keepgo][:access_token]
+	}.freeze
+
 	def find(iccid)
 		EM::HttpRequest.new(
 			"https://myaccount.keepgo.com/api/v2/line/#{iccid}/get_details",
 			tls: { verify_peer: true }
-		).aget(
-			head: {
-				"Accept" => "application/json",
-				"apiKey" => CONFIG[:keepgo][:api_key],
-				"accessToken" => CONFIG[:keepgo][:access_token]
-			}
-		).then { |req| SIM.extract(JSON.parse(req.response)&.dig("sim_card")) }
+		).aget(head: KEEPGO_HEADERS).then { |req|
+			SIM.extract(JSON.parse(req.response)&.dig("sim_card"))
+		}
+	end
+
+	def refill(sim, **kwargs)
+		iccid = sim.is_a?(String) ? sim : sim.iccid
+
+		EM::HttpRequest.new(
+			"https://myaccount.keepgo.com/api/v2/line/#{iccid}/refill",
+			tls: { verify_peer: true }
+		).apost(
+			head: KEEPGO_HEADERS,
+			body: kwargs.to_json
+		).then { |req| JSON.parse(req.response) }
 	end
 
 	def owned_by(customer)
@@ -34,4 +48,16 @@ class SIMRepo
 			})
 		}
 	end
+
+	def put_owner(sim, customer, nickname=nil)
+		db.query_defer(<<~SQL, [customer.customer_id, sim.iccid, nickname])
+			UPDATE sims SET customer_id=$1, nickname=COALESCE($3, nickname) WHERE iccid=$2 AND customer_id IS NULL
+		SQL
+	end
+
+	def available
+		db.query_defer(<<~SQL).then { |r| find(r.first["iccid"]) }
+			SELECT iccid FROM sims WHERE customer_id IS NULL LIMIT 1
+		SQL
+	end
 end

lib/transaction.rb 🔗

@@ -63,8 +63,6 @@ class Transaction
 		"$#{'%.2f' % amount}#{plus if bonus.positive?}"
 	end
 
-protected
-
 	def insert_tx
 		params = [
 			@customer_id, @transaction_id, @created_at, @settled_after, @amount, @note
@@ -77,6 +75,8 @@ protected
 		SQL
 	end
 
+protected
+
 	def insert_bonus
 		return if bonus <= 0
 

sgx_jmp.rb 🔗

@@ -102,6 +102,7 @@ require_relative "lib/registration"
 require_relative "lib/transaction"
 require_relative "lib/tel_selections"
 require_relative "lib/sim_repo"
+require_relative "lib/sim_order"
 require_relative "lib/snikket"
 require_relative "lib/welcome_message"
 require_relative "web"
@@ -727,24 +728,43 @@ Command.new(
 	end
 }.register(self).then(&CommandList.method(:register))
 
+# Assumes notify_from is a direct target
+notify_to = CONFIG[:direct_targets].fetch(
+	Blather::JID.new(CONFIG[:notify_from]).node.to_sym
+)
+
 Command.new(
 	"sims",
 	"📶 (e)SIM Details",
-	list_for: ->(customer:, **) { CONFIG[:keepgo] && !!customer&.currency }
+	list_for: ->(customer:, **) { CONFIG[:keepgo] && !!customer&.currency },
+	customer_repo: CustomerRepo.new(
+		sgx_repo: TrivialBackendSgxRepo.new(jid: notify_to)
+	)
 ) {
-	Command.customer.then(&SIMRepo.new.method(:owned_by)).then do |sims|
-		if sims.empty?
-			next Command.finish(
-				"You have no (e)SIMs, you can get on the waitlist at https://jmp.chat/sim"
-			)
-		end
-
-		Command.finish do |reply|
-			reply.command << FormTemplate.render(
-				"sim_details",
-				sims: sims
-			)
-		end
+	Command.customer.then { |customer|
+		EMPromise.all([customer, SIMRepo.new.owned_by(customer)])
+	}.then do |(customer, sims)|
+		Command.reply { |reply|
+			buy = customer.feature_flags.include?(:buy_sim)
+			reply.status = "completed" unless buy
+			reply.command << FormTemplate.render("sim_details", sims: sims, buy: buy)
+		}.then { |iq|
+			case iq.form.field("http://jabber.org/protocol/commands#actions")&.value
+			when "order-sim"
+				SIMOrder.for(customer, **CONFIG.dig(:sims, :sim, customer.currency))
+			when "order-esim"
+				SIMOrder::ESIM.for(
+					customer, **CONFIG.dig(:sims, :esim, customer.currency)
+				)
+			else
+				Command.finish
+			end
+		}.then { |order|
+			Command.reply { |reply|
+				reply.allowed_actions = [:complete]
+				reply.command << order.form
+			}.then(&order.method(:complete))
+		}
 	end
 }.register(self).then(&CommandList.method(:register))