diff --git a/.rubocop.yml b/.rubocop.yml index f3d7110b10d3e19506b8972a504a1ec99d636981..50c780c9089f087ae0276be81246a0eb963254b8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -24,7 +24,7 @@ Metrics/AbcSize: - test/* Metrics/ParameterLists: - Max: 7 + Max: 8 Naming/MethodParameterName: AllowNamesEndingInNumbers: false diff --git a/config-schema.dhall b/config-schema.dhall index 7630593a6330b2ef39069e8b60a4578051ea4e68..c996995980660f221baa0fd86696bac2fd74a677 100644 --- a/config-schema.dhall +++ b/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 diff --git a/config.dhall.sample b/config.dhall.sample index 5abf80b48cbcc70bd39ec8b8d94ebe716f7341c9..c4f88741d45c16893a5a74c7becaab11a008edfc 100644 --- a/config.dhall.sample +++ b/config.dhall.sample @@ -59,6 +59,14 @@ in >.unlimited } ], + sims = { + sim = [ + { mapKey = .CAD, mapValue = { price = 1, plan = "$1 / GB + $1 / year" } } + ], + esim = [ + { mapKey = .CAD, mapValue = { price = 1, plan = "$1 / GB + $1 / year" } } + ] + }, electrum = { rpc_uri = "", rpc_username = "", diff --git a/forms/order_sim/esim_complete.rb b/forms/order_sim/esim_complete.rb new file mode 100644 index 0000000000000000000000000000000000000000..3fddda010a4cc9047e8e89ab97388d1297fba44f --- /dev/null +++ b/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." +) diff --git a/forms/order_sim/header.rb b/forms/order_sim/header.rb new file mode 100644 index 0000000000000000000000000000000000000000..2d95c961efcc9e38a9db70398d2558071e551999 --- /dev/null +++ b/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" +) diff --git a/forms/order_sim/please_top_up.rb b/forms/order_sim/please_top_up.rb new file mode 100644 index 0000000000000000000000000000000000000000..88626aa1165da7062734bd9d8be53adf4e696892 --- /dev/null +++ b/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" +) diff --git a/forms/order_sim/with_balance.rb b/forms/order_sim/with_balance.rb new file mode 100644 index 0000000000000000000000000000000000000000..be276cbd2e6f992d7cfd84b05ee54a9bb7ef5f7e --- /dev/null +++ b/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 diff --git a/forms/order_sim/with_top_up.rb b/forms/order_sim/with_top_up.rb new file mode 100644 index 0000000000000000000000000000000000000000..bd0cd8af4bd3e0cf78065751a95a83fb0f66a3c2 --- /dev/null +++ b/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" +) diff --git a/forms/sim_details.rb b/forms/sim_details.rb index 3463f95fe2c319e726ce892276a43f603dc45b36..17e10c48a7321c7b496d2d5b49793df70bebff9e 100644 --- a/forms/sim_details.rb +++ b/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 diff --git a/lib/form_template.rb b/lib/form_template.rb index 0035f4b63895ceb90830c82c0df59551003ba378..598834f71049df8c968d12a9fcec7a4fd95a78a5 100644 --- a/lib/form_template.rb +++ b/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 diff --git a/lib/low_balance.rb b/lib/low_balance.rb index 399504fba710fc84e2fa785ad87fea8ecb3d2f95..8bb9c124f50f6f5caad2c2a4b0164c4a75435875 100644 --- a/lib/low_balance.rb +++ b/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 diff --git a/lib/sim.rb b/lib/sim.rb index 2d666b58f9ac8740f8acc508448f0298d90707d2..7318d2fe35874c01ce71e3a1b532773527e423a0 100644 --- a/lib/sim.rb +++ b/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 diff --git a/lib/sim_order.rb b/lib/sim_order.rb new file mode 100644 index 0000000000000000000000000000000000000000..c2d1c7972616bc1fd6e012d81f59d7878905d726 --- /dev/null +++ b/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 diff --git a/lib/sim_repo.rb b/lib/sim_repo.rb index 15412c7b5e1847124090d6e4483aa07d9c6594e0..d5519e5be614d2850b0b4ed173fa32207ab98338 100644 --- a/lib/sim_repo.rb +++ b/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 diff --git a/lib/transaction.rb b/lib/transaction.rb index 27172052e32e04865fdf7c20e1763c3ef12ac07b..c880df6206a75fca5b910d269893dec7c72bfdc9 100644 --- a/lib/transaction.rb +++ b/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 diff --git a/sgx_jmp.rb b/sgx_jmp.rb index 3e8b309f9f0149cfdee19504394759d247e0a299..2fc11dee6805ed8f81694a58b665072b684530f9 100644 --- a/sgx_jmp.rb +++ b/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))