.rubocop.yml 🔗
@@ -24,7 +24,7 @@ Metrics/AbcSize:
- test/*
Metrics/ParameterLists:
- Max: 7
+ Max: 8
Naming/MethodParameterName:
AllowNamesEndingInNumbers: false
Stephen Paul Weber created
.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(-)
@@ -24,7 +24,7 @@ Metrics/AbcSize:
- test/*
Metrics/ParameterLists:
- Max: 7
+ Max: 8
Naming/MethodParameterName:
AllowNamesEndingInNumbers: false
@@ -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
@@ -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 = "",
@@ -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."
+)
@@ -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"
+)
@@ -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"
+)
@@ -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
@@ -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"
+)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))