From 5cdf6c0e91b13c7dda117273af95731dd5f01841 Mon Sep 17 00:00:00 2001 From: Phillip Davis Date: Mon, 3 Nov 2025 10:12:32 -0500 Subject: [PATCH] Support data-only registration --- Gemfile | 1 + config-schema.dhall | 1 + config.dhall.sample | 1 + forms/registration/buy_number.rb | 30 --- forms/registration/choose_sim_kind.rb | 12 ++ forms/registration/pay_without_code.rb | 28 +++ forms/tn_search.rb | 3 +- lib/edit_sim_nicknames.rb | 4 + lib/onboarding.rb | 11 + lib/registration.rb | 165 ++++++++++++-- lib/sim_kind.rb | 24 +++ lib/sim_order.rb | 67 +++++- lib/tel_selections.rb | 51 +++-- sgx_jmp.rb | 7 +- test/test_helper.rb | 13 +- test/test_registration.rb | 211 ++++++++++++++++++ test/test_sim_kind.rb | 77 +++++++ test/test_sim_order.rb | 283 +++++++++++++------------ test/test_tel_selections.rb | 5 +- 19 files changed, 771 insertions(+), 223 deletions(-) delete mode 100644 forms/registration/buy_number.rb create mode 100644 forms/registration/choose_sim_kind.rb create mode 100644 forms/registration/pay_without_code.rb create mode 100644 lib/onboarding.rb create mode 100644 lib/sim_kind.rb create mode 100644 test/test_sim_kind.rb diff --git a/Gemfile b/Gemfile index ff653aabd3f36aca9a30c76a2981cbb0196b4f4c..22ec7ce5214f68fe612fe5604607562b18412af3 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ gem "bandwidth-sdk", "<= 6.1.0" gem "blather", git: "https://github.com/singpolyma/blather", branch: "better-ids" gem "braintree" gem "countries" +gem "csv" gem "dhall", ">= 0.5.3.fixed" gem "em-hiredis" gem "em-http-request", git: "https://github.com/singpolyma/em-http-request", branch: "fix-letsencrypt" diff --git a/config-schema.dhall b/config-schema.dhall index 7b3c24875ba72a9b73a98a35ad848a00892ecb31..4c4bb4fa49dab303a3f2380ffb6ded0b9f8696bd 100644 --- a/config-schema.dhall +++ b/config-schema.dhall @@ -88,6 +88,7 @@ , sip_host : Text , snikket_hosting_api : Text , support_link : forall (customer_jid : Text) -> Text +, public_onboarding_url : Text , upstream_domain : Text , web : < Inet : { interface : Text, port : Natural } | Unix : Text > , web_register : { from : Text, to : Text } diff --git a/config.dhall.sample b/config.dhall.sample index a2c8842c09bb3fb8587b557855daea2277d0eb9c..73e2673d5e135bf735e1bd3a6c4b7afdaeb2c814 100644 --- a/config.dhall.sample +++ b/config.dhall.sample @@ -115,6 +115,7 @@ in reachability_senders = [ "+14445556666" ], support_link = \(customer_jid: Text) -> "http://localhost:3002/app/accounts/2/contacts/custom_attributes/jid/${customer_jid}", + public_onboarding_url = "xmpp:example.com?register", churnbuster = { api_key = "", account_id = "" diff --git a/forms/registration/buy_number.rb b/forms/registration/buy_number.rb deleted file mode 100644 index aaaaecfd899928b253a2c096fc1b9f1a6d4f87dc..0000000000000000000000000000000000000000 --- a/forms/registration/buy_number.rb +++ /dev/null @@ -1,30 +0,0 @@ -form! - -title "Purchase Number" - -instructions <<~I - You've selected #{@tel} as your JMP number. - This number has a one-time price of $#{'%.2f' % @tel.price}. - (If you'd like to pay in another cryptocurrency, currently we recommend using a service like simpleswap.io, morphtoken.com, changenow.io, or godex.io.) -I - -field( - var: "activation_method", - type: "list-single", - label: "Activate using", - required: true, - options: [ - { - value: "credit_card", - label: "Credit Card" - }, - { - value: "bitcoin", - label: "Bitcoin" - }, - { - value: "bch", - label: "Bitcoin Cash" - } - ] -) diff --git a/forms/registration/choose_sim_kind.rb b/forms/registration/choose_sim_kind.rb new file mode 100644 index 0000000000000000000000000000000000000000..1b25dc5752d19bb31dc0db7fa2389df419fb9694 --- /dev/null +++ b/forms/registration/choose_sim_kind.rb @@ -0,0 +1,12 @@ +form! +title "Choose SIM Kind" + +field( + label: "SIM or eSIM Adapter", + var: "sim_kind", + type: "list-single", + options: [ + { label: "SIM", value: "sim" }, + { label: "eSIM Adapter", value: "esim" } + ] +) diff --git a/forms/registration/pay_without_code.rb b/forms/registration/pay_without_code.rb new file mode 100644 index 0000000000000000000000000000000000000000..5535d4e1ea420163364ad6167cc3d2fa3bfbcf96 --- /dev/null +++ b/forms/registration/pay_without_code.rb @@ -0,0 +1,28 @@ +form! + +title @title + +instructions @instructions + +field( + var: "activation_method", + type: "list-single", + label: "Activate using", + required: true, + options: [ + { + value: "credit_card", + label: "Credit Card" + }, + { + value: "bitcoin", + label: "Bitcoin" + }, + { + value: "bch", + label: "Bitcoin Cash" + } + ] +) + +instance_eval File.read("#{__dir__}/plan_name.rb") if @currency_required diff --git a/forms/tn_search.rb b/forms/tn_search.rb index 2e5ccfea33e70faf0e843dde47dde98926b09c3e..edca11ae116994866e35962919da1e79b20e2697 100644 --- a/forms/tn_search.rb +++ b/forms/tn_search.rb @@ -26,7 +26,8 @@ field( var: "http://jabber.org/protocol/commands#actions", options: [ { label: "Search", value: "next" }, - { label: "Just Show Me Some Numbers", value: "feelinglucky" } + { label: "Just Show Me Some Numbers", value: "feelinglucky" }, + { label: "Just Data, Please", value: "data_only" } ], value: "next" ) diff --git a/lib/edit_sim_nicknames.rb b/lib/edit_sim_nicknames.rb index b8c60471117fa0b69203bad4d5600a51bc02285f..4a34ce2c71065b1443d4aafc2e53cb7caa8c4922 100644 --- a/lib/edit_sim_nicknames.rb +++ b/lib/edit_sim_nicknames.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true +require_relative "sim_order" + class EditSimNicknames + include SIMAction + # @param [Customer] customer the customer editing the sims # @param [Array] sims the sims whose nicknames # are up for editing diff --git a/lib/onboarding.rb b/lib/onboarding.rb new file mode 100644 index 0000000000000000000000000000000000000000..bf33ddce81fa572fc0f646e93caf9dcfafcc695b --- /dev/null +++ b/lib/onboarding.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "blather" + +require_relative "proxied_jid" + +Blather::JID.class_eval do + def onboarding? + ProxiedJID.new(self).unproxied.domain == CONFIG[:onboarding_domain] + end +end diff --git a/lib/registration.rb b/lib/registration.rb index 274b9ecc578c953ea7bc9066141cf6743651fd39..be6bfee2aa9066f2d3d0fb1d50d085ad7a3943d9 100644 --- a/lib/registration.rb +++ b/lib/registration.rb @@ -15,6 +15,8 @@ require_relative "./parent_code_repo" require_relative "./proxied_jid" require_relative "./tel_selections" require_relative "./welcome_message" +require_relative "./sim_kind" +require_relative "./onboarding" def reload_customer(customer) EMPromise.resolve(nil).then do @@ -22,14 +24,111 @@ def reload_customer(customer) end end +def handle_prev(customer, googleplay_user_id, product) + if product.is_a?(SIMKind) + Registration::DataOnly.for(customer, product) + else + Registration::Activation.for(customer, googleplay_user_id, product) + end +end + class Registration + class PayForSim + def initialize(customer, sim_kind) + @customer = customer + @sim_kind = sim_kind + end + + def write + Command.reply { |reply| + reply.command << FormTemplate.render( + "registration/pay_without_code", + product: @sim_kind, + instructions: instructions, + currency_required: !@customer.currency, + title: "Pay for #{@sim_kind.klass.label}" + ) + }.then(&method(:parse)).then(&:write) + end + + def instructions + cfg = @sim_kind.cfg(@customer.currency) + return nil unless cfg + + <<~I + To activate your data SIM, you need to deposit $#{'%.2f' % (cfg[:price] / 100.0)} to your balance. + (If you'd like to pay in another cryptocurrency, currently we recommend using a service like simpleswap.io, morphtoken.com, changenow.io, or godex.io.) + I + end + + def parse(iq) + unless @customer.currency + plan = Plan.for_registration(iq.form.field("plan_name").value.to_s) + (@customer = @customer.with_plan(plan.name)).save_plan! + end.then { process_payment(iq) } + end + + def process_payment(iq) + Payment.for( + iq, @customer, @sim_kind, + price: @sim_kind.cfg(@customer.currency)[:price], + maybe_bill: Registration::Payment::JustCharge + ).write.then { reload_customer(@customer) }.then { |customer| + DataOnly.for(customer, @sim_kind) + } + end + end + + class DataOnly + def self.for(customer, sim_kind) + cfg = sim_kind.cfg(customer.currency) + unless cfg && customer.balance > cfg[:price] + return PayForSim.new(customer, sim_kind) + end + + new(customer, sim_kind) + end + + def initialize(customer, sim_kind) + @customer = customer + @sim_kind = sim_kind + end + + def write + @sim_kind.klass.for( + @customer, + **@sim_kind.cfg(@customer.currency) + ).then { |order| + # NOTE: cheogram will swallow any stanza + # with type `completed` + # `can_complete: false` is needed to prevent customer + # from (possibly) permanently losing their eSIM QR code + order.process(can_complete: false) + } + end + end + + class RegistrationType + def self.for(customer, google_play_userid, product) + if product.is_a?(SIMKind) + return Registration::DataOnly.for( + customer, product + ) + end + + Registration::FinishOrStartActivation.for( + customer, google_play_userid, product + ) + end + end + def self.for(customer, google_play_userid, tel_selections) if (reg = customer.registered?) Registered.for(customer, reg.phone) else - tel_selections[customer.jid].then(&:choose_tel).then do |tel| - reserve_and_continue(tel_selections, customer, tel).then do - FinishOrStartActivation.for(customer, google_play_userid, tel) + tel_selections[customer.jid].then(&:choose_tel_or_data).then do |product| + reserve_and_continue(tel_selections, customer, product).then do + RegistrationType.for(customer, google_play_userid, product) end end end @@ -40,7 +139,7 @@ class Registration tel_selections.delete(customer.jid).then { tel_selections[customer.jid] }.then { |choose| - choose.choose_tel( + choose.choose_tel_or_data( error: "The JMP number #{tel} is no longer available." ) }.then { |n_tel| reserve_and_continue(tel_selections, customer, n_tel) } @@ -49,8 +148,7 @@ class Registration class Registered def self.for(customer, tel) - jid = ProxiedJID.new(customer.jid).unproxied - if jid.domain == CONFIG[:onboarding_domain] + if customer.jid.onboarding? FinishOnboarding.for(customer, tel) else new(tel) @@ -260,14 +358,14 @@ class Registration end def self.for( - iq, customer, tel, + iq, customer, product, finish: Finish, maybe_bill: MaybeBill, - price: CONFIG[:activation_amount] + tel.price + price: CONFIG[:activation_amount] + product.price ) kinds.fetch(iq.form.field("activation_method")&.value&.to_s&.to_sym) { raise "Invalid activation method" }.call( - customer, tel, finish: finish, maybe_bill: maybe_bill, price: price + customer, product, finish: finish, maybe_bill: maybe_bill, price: price ) end @@ -285,20 +383,22 @@ class Registration end def initialize( - customer, tel, - price: CONFIG[:activation_amount] + tel.price, ** + customer, product, + price: CONFIG[:activation_amount] + product.price, ** ) @customer = customer @customer_id = customer.customer_id - @tel = tel + @product = product @price = price end def save - TEL_SELECTIONS.set(@customer.jid, @tel) + return if product.is_a?(SIMKind) + + TEL_SELECTIONS.set(@customer.jid, @product) end - attr_reader :customer_id, :tel + attr_reader :customer_id, :product def form(rate, addr) FormTemplate.render( @@ -323,7 +423,7 @@ class Registration def handle_possible_prev(iq) raise "Action not allowed" unless iq.prev? - Activation.for(@customer, nil, @tel).then(&:write) + handle_prev(@customer, nil, @product) end def addr_and_rate @@ -408,14 +508,14 @@ class Registration Payment.kinds[:credit_card] = method(:new) def initialize( - customer, tel, + customer, product, finish: Finish, maybe_bill: MaybeBill, - price: CONFIG[:activation_amount] + tel.price + price: CONFIG[:activation_amount] + product.price ) @customer = customer - @tel = tel + @product = product @finish = finish - @maybe_bill = maybe_bill.new(customer, tel, finish: finish) + @maybe_bill = maybe_bill.new(customer, product, finish: finish) @price = price end @@ -436,7 +536,7 @@ class Registration reply.note_type = :info reply.note_text = "#{toob.desc}: #{toob.url}" }.then do |iq| - next Activation.for(@customer, nil, @tel).then(&:write) if iq.prev? + handle_prev(@customer, nil, @product) if iq.prev? @maybe_bill.call { self }&.then(&:write) end @@ -586,11 +686,21 @@ class Registration @tel = tel end + def instructions + <<~I + You've selected #{@tel} as your JMP number. + To activate your account, you can either deposit $#{'%.2f' % (CONFIG[:activation_amount] + @tel.price)} to your balance or enter your referral code if you have one. + (If you'd like to pay in another cryptocurrency, currently we recommend using a service like simpleswap.io, morphtoken.com, changenow.io, or godex.io.) + I + end + def write Command.reply { |reply| reply.command << FormTemplate.render( - "registration/buy_number", - tel: @tel + "registration/pay_without_code", + product: @tel, + instructions: instructions, + title: "Purchase Number" ) }.then(&method(:parse)).then(&:write) end @@ -637,7 +747,7 @@ class Registration TEL_SELECTIONS.delete(@customer.jid).then { TEL_SELECTIONS[@customer.jid] }.then { |choose| - choose.choose_tel( + choose.choose_tel_or_data( error: "The JMP number #{@tel} is no longer available." ) }.then { |tel| Finish.new(@customer, tel).write } @@ -903,4 +1013,13 @@ class Registration end end end + + def self.public_onboarding_invite + Command.finish { |reply| + reply.allowed_actions = [:prev] + oob = OOB.find_or_create(reply.command) + oob.url = CONFIG[:public_onboarding_url] + oob.desc = "Get your XMPP account" + } + end end diff --git a/lib/sim_kind.rb b/lib/sim_kind.rb new file mode 100644 index 0000000000000000000000000000000000000000..aa77b78123201aa787539fc7b3219d5316239b18 --- /dev/null +++ b/lib/sim_kind.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class SIMKind + attr_reader :klass + + def initialize(variant) + @klass = + case variant + when "sim" + SIMOrder + when "esim" + SIMOrder::ESIM + end + @variant = variant + end + + def self.from_form(form) + new(form.field("sim_kind")&.value) + end + + def cfg(currency) + CONFIG.dig(:sims, @variant.to_sym, currency) + end +end diff --git a/lib/sim_order.rb b/lib/sim_order.rb index ae91fa058f7b31aa51e81e399efa113b1266c67a..03a033e4c073e4c59589cc4f30a854b813f2c7d4 100644 --- a/lib/sim_order.rb +++ b/lib/sim_order.rb @@ -3,9 +3,39 @@ require "bigdecimal/util" require_relative "low_balance" +require_relative "registration" require_relative "transaction" +require_relative "onboarding" + +module SIMAction + # @param can_complete [TrueClass, FalseClass] + def process(can_complete: true) + Command.reply { |reply| + # Users shouldn't be allowed to abort in the middle + # of any flow that ends in them getting onboarded + if can_complete && !@customer.jid.onboarding? + reply.allowed_actions = [:complete] + end + reply.command << form + }.then { |iq| complete(iq) } + end +end + +module SIMFinish + def finish + complete.then { |_iq| + if @customer.jid.onboarding? + Registration.public_onboarding_invite + else + Command.finish + end + } + end +end class SIMOrder + include SIMAction + def self.for(customer, price:, **kwargs) price = price.to_i / 100.to_d return new(customer, price: price, **kwargs) if customer.balance >= price @@ -66,25 +96,36 @@ class SIMOrder @customer, sim, Array(form.field("addr").value).join("\n") - ).complete + ).write end end class Ack + include SIMFinish + def initialize(customer, sim, addr) @customer = customer @sim = sim @addr = addr end - def complete + def write + notify.then { finish } + end + + def notify @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 + + def complete + Command.reply { |iq| + iq.note_type = :info + iq.note_text = + "You will receive a notice from support when your SIM ships." + } end end @@ -137,31 +178,37 @@ protected iq.form.field("nickname")&.value.presence || self.class.label ) }.then do |sim| - ActivationCode.new(sim).complete + ActivationCode.new(@customer, sim).finish end end end class ActivationCode + include SIMFinish + + # @param [Customer] customer the customer who ordered # @param [Sim] sim the sim which the customer # just ordered - def initialize(sim) + def initialize(customer, sim) + @customer = customer @sim = sim end def complete - Command.finish do |reply| + Command.reply { |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 class WithTopUp + include SIMAction + def initialize(customer, continue, price:, plan:, top_up:) @customer = customer @price = price @@ -193,6 +240,8 @@ protected end class PleaseTopUp + include SIMAction + def initialize(price:, plan:) @price = price @plan = plan diff --git a/lib/tel_selections.rb b/lib/tel_selections.rb index 51f525692b8276328c357ddd1486390d19c633f9..fcbf7a3b22108dc850f7867453b1ec8ba0589c2f 100644 --- a/lib/tel_selections.rb +++ b/lib/tel_selections.rb @@ -8,6 +8,17 @@ require "countries" require_relative "area_code_repo" require_relative "form_template" +require_relative "sim_kind" + +module NullReserve + def reserve(*) + EMPromise.resolve(nil) + end +end + +SIMKind.class_eval do + include NullReserve +end class TelSelections THIRTY_DAYS = 60 * 60 * 24 * 30 @@ -46,7 +57,7 @@ class TelSelections @tel = ChooseTel::Tn.for_pending_value(tel) end - def choose_tel + def choose_tel_or_data EMPromise.resolve(@tel) end end @@ -59,18 +70,32 @@ class TelSelections @memcache = memcache end - def choose_tel(error: nil) + def choose_tel_or_data(error: nil) Command.reply { |reply| reply.allowed_actions = [:next] reply.command << FormTemplate.render("tn_search", error: error) - }.then do |iq| - available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache) - next available if available.is_a?(Tn::Bandwidth) + }.then { |iq| + response = iq.form.field( + "http://jabber.org/protocol/commands#actions" + )&.value.to_s.strip + response == "data_only" ? choose_sim_kind : choose_tel(iq) + } + end - choose_from_list(available.tns) - rescue Fail - choose_tel(error: $!.to_s) - end + def choose_sim_kind + Command.reply { |reply| + reply.command << FormTemplate.render("registration/choose_sim_kind") + reply.allowed_actions = [:cancel, :next] + }.then { |iq| SIMKind.from_form(iq.form) } + end + + def choose_tel(iq) + available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache) + return available if available.is_a?(Tn::Bandwidth) + + choose_from_list(available.tns) + rescue Fail + choose_tel_or_data(error: $!.to_s) end def choose_from_list(tns) @@ -86,7 +111,7 @@ class TelSelections def choose_from_list_result(tns, iq) tel = iq.form.field("tel")&.value - return choose_tel if iq.prev? || !tel + return choose_tel_or_data if iq.prev? || !tel tns.find { |tn| tn.tel == tel } || Tn::Bandwidth.new(Tn.new(tel)) end @@ -296,6 +321,8 @@ class TelSelections end class LocalInventory < SimpleDelegator + include NullReserve + attr_reader :price def initialize(tn, bandwidth_account_id, price: 0) @@ -352,10 +379,6 @@ class TelSelections "LocalInventory/#{tel}/#{@bandwidth_account_id}/#{price}" end - def reserve(*) - EMPromise.resolve(nil) - end - def order(db, _customer) # Move always moves to wrong account, oops # Also probably can't move from/to same account diff --git a/sgx_jmp.rb b/sgx_jmp.rb index fc2cc27f97c7e57f0d33cdfe0fcd3c8d8d2d4ec6..751c028c9a66b19d2e6bd1ec7e5a29dcce23aba2 100644 --- a/sgx_jmp.rb +++ b/sgx_jmp.rb @@ -784,12 +784,7 @@ Command.new( else Command.finish end - }.then { |action| - Command.reply { |reply| - reply.allowed_actions = [:complete] - reply.command << action.form - }.then(&action.method(:complete)) - } + }.then(&:process) end }.register(self).then(&CommandList.method(:register)) diff --git a/test/test_helper.rb b/test/test_helper.rb index 7729ff95c5fcb1373721cc7b1ced347bea51a889..18790a9eafd519e6c59890a46af239fd07998e9c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -139,6 +139,16 @@ CONFIG = { ], credit_card_url: ->(*) { "http://creditcard.example.com?" }, electrum_notify_url: ->(*) { "http://notify.example.com" }, + sims: { + sim: { + USD: { price: 500, plan: "1GB" }, + CAD: { price: 600, plan: "1GB" } + }, + esim: { + USD: { price: 300, plan: "500MB" }, + CAD: { price: 400, plan: "500MB" } + } + }, keep_area_codes: ["556"], keep_area_codes_in: { account: "moveto", @@ -167,7 +177,8 @@ CONFIG = { bulk_order_tokens: { sometoken: { customer_id: "bulkcustomer", peer_id: "bulkpeer" }, lowtoken: { customer_id: "customerid_low", peer_id: "lowpeer" } - } + }, + public_onboarding_url: "xmpp:example.com?register" }.freeze def panic(e) diff --git a/test/test_registration.rb b/test/test_registration.rb index 61dbe71247959273ac9d02731d0a469fddad4263..f7c9d63cdbfa877df73d50d25dfb44e40527ab4e 100644 --- a/test/test_registration.rb +++ b/test/test_registration.rb @@ -2197,4 +2197,215 @@ class RegistrationTest < Minitest::Test end em :test_write_needs_dns end + + class RegistrationTypeTest < Minitest::Test + def test_for_with_sim_kind + cust = customer(plan_name: "test_usd").with_balance(1000) + sim_kind = SIMKind.new("sim") + result = Registration::RegistrationType.for(cust, nil, sim_kind) + assert_kind_of Registration::DataOnly, result + end + + def test_for_with_esim_kind + cust = customer(plan_name: "test_usd").with_balance(1000) + sim_kind = SIMKind.new("esim") + result = Registration::RegistrationType.for(cust, nil, sim_kind) + assert_kind_of Registration::DataOnly, result + end + + def test_for_with_telephone_number + cust = customer(plan_name: "test_usd", expires_at: Time.now + 999) + tel = TelSelections::ChooseTel::Tn.for_pending_value("+15555550000") + result = Registration::RegistrationType.for(cust, nil, tel) + assert_kind_of Registration::Finish, result + end + end + + class DataOnlyTest < Minitest::Test + def setup + @customer = customer(plan_name: "test_usd").with_balance(1000) + @sim_kind = SIMKind.new("sim") + end + + def test_for_returns_pay_for_sim_when_balance_insufficient + low_balance_customer = @customer.with_balance(0.50) + result = Registration::DataOnly.for(low_balance_customer, @sim_kind) + assert_kind_of Registration::PayForSim, result + end + + def test_for_returns_pay_for_sim_when_config_missing + no_currency_customer = customer(currency: nil) + result = Registration::DataOnly.for(no_currency_customer, @sim_kind) + assert_kind_of Registration::PayForSim, result + end + + def test_for_returns_data_only_when_balance_sufficient + result = + Registration::DataOnly.for(@customer, @sim_kind) + assert_kind_of Registration::DataOnly, result + end + + def test_write_calls_sim_kind_for_and_process + result = execute_command do + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.reject(:test_result), + [Matching.new do |iq| + assert_equal iq.form.title, "Order SIM" + assert( + iq.form.instructions.start_with?("Our SIMs provide a plan") + ) + end] + ) + Registration::DataOnly.new( + @customer, + @sim_kind + ).write.catch { |e| e }.sync + end + assert_equal result, :test_result + assert_mock Command::COMMAND_MANAGER + end + em :test_write_calls_sim_kind_for_and_process + end + + class PayForSimTest < Minitest::Test + Command::COMMAND_MANAGER = Minitest::Mock.new + Registration::PayForSim::Payment = Minitest::Mock.new + + def setup + @customer = customer(plan_name: "test_usd") + @sim_kind = SIMKind.new("sim") + @pay_for_sim = Registration::PayForSim.new(@customer, @sim_kind) + end + + def test_write_displays_payment_form + iq = Blather::Stanza::Iq::Command.new + iq.form.fields = [ + { var: "activation_method", value: "credit_card" } + ] + + result = execute_command do + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.resolve(iq), + [Matching.new do |reply| + assert_equal :form, reply.form.type + assert_equal "Pay for SIM", reply.form.title + end] + ) + + ejector_mock = Minitest::Mock.new + ejector_mock.expect( + :write, + EMPromise.reject(:test_result), + [] + ) + Registration::PayForSim::Payment.expect( + :for, + ejector_mock + ) do |*, price:, maybe_bill:| + assert_equal 500, price + assert_equal( + Registration::Payment::JustCharge, + maybe_bill + ) + end + @pay_for_sim.write.catch { |e| e } + end + + assert_equal :test_result, result + assert_mock Command::COMMAND_MANAGER + end + em :test_write_displays_payment_form + + def test_write_with_no_currency_shows_plan_selection + no_currency_customer = customer(currency: nil) + pay_for_sim = Registration::PayForSim.new(no_currency_customer, @sim_kind) + + iq = Blather::Stanza::Iq::Command.new + iq.form.fields = [ + { var: "plan_name", value: "test_usd" }, + { var: "activation_method", value: "credit_card" } + ] + + result = execute_command do + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.resolve(iq), + [Matching.new do |reply| + assert_equal :form, reply.form.type + assert_equal "Pay for SIM", reply.form.title + end] + ) + + CustomerPlan::DB.expect( + :exec_defer, + EMPromise.resolve(nil), + [String, ["test", "test_usd", nil]] + ) + ejector_mock = Minitest::Mock.new + ejector_mock.expect( + :write, + EMPromise.reject(:test_result), + [] + ) + Registration::PayForSim::Payment.expect( + :for, + ejector_mock + ) do |*, price:, maybe_bill:| + assert_equal 500, price + assert_equal( + Registration::Payment::JustCharge, + maybe_bill + ) + end + pay_for_sim.write.catch { |e| e } + end + + assert_equal :test_result, result + assert_mock Command::COMMAND_MANAGER + assert_mock CustomerPlan::DB + end + em :test_write_with_no_currency_shows_plan_selection + + def test_process_payment_uses_just_charge_strategy + iq = Blather::Stanza::Iq::Command.new + iq.form.fields = [ + { var: "activation_method", value: "credit_card" } + ] + + reloaded_customer = @customer.with_balance(100).with_plan("test_usd") + + result = execute_command do |exe| + payment = Minitest::Mock.new + payment.expect( + :write, + EMPromise.resolve(nil), + [] + ) + Registration::PayForSim::Payment.expect( + :for, + payment + ) do |_, customer, sim_kind, price:, maybe_bill:| + assert_equal @customer, customer + assert_equal @sim_kind, sim_kind + assert_equal Registration::Payment::JustCharge, maybe_bill + assert_equal 500, price + end + exe.customer_repo.expect( + :find, + EMPromise.resolve(reloaded_customer), + [@customer.customer_id] + ) + + result_ = @pay_for_sim.process_payment(iq).sync + assert_mock payment + assert_mock Command.execution.customer_repo + assert_mock Registration::PayForSim::Payment + result_ + end + assert_kind_of Registration::PayForSim, result + end + em :test_process_payment_uses_just_charge_strategy + end end diff --git a/test/test_sim_kind.rb b/test/test_sim_kind.rb new file mode 100644 index 0000000000000000000000000000000000000000..8aaba041c516a5006a6e2bd4e59d6bbe6c0a38fa --- /dev/null +++ b/test/test_sim_kind.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "test_helper" + +require "ostruct" +require "customer" + +require_relative "../lib/sim_order" +require_relative "../lib/sim_kind" + +class SIMKindTest < Minitest::Test + def setup + @sim_form = Blather::Stanza::Iq::Command.new.tap { |iq| + iq.form.fields = [{ var: "sim_kind", value: "sim" }] + } + + @esim_form = Blather::Stanza::Iq::Command.new.tap { |iq| + iq.form.fields = [{ var: "sim_kind", value: "esim" }] + } + + @invalid_form = Blather::Stanza::Iq::Command.new.tap { |iq| + iq.form.fields = [{ var: "sim_kind", value: "invalid" }] + } + + @missing_form = Blather::Stanza::Iq::Command.new.tap { |iq| + iq.form.fields = [] + } + end + + def test_initialize_with_sim + kind = SIMKind.new("sim") + assert_equal SIMOrder, kind.klass + end + + def test_initialize_with_esim + kind = SIMKind.new("esim") + assert_equal SIMOrder::ESIM, kind.klass + end + + def test_initialize_with_invalid_variant + kind = SIMKind.new("invalid") + assert_nil kind.klass + end + + def test_initialize_with_nil + kind = SIMKind.new(nil) + assert_nil kind.klass + end + + def test_from_form_with_sim + kind = SIMKind.from_form(@sim_form.form) + assert_equal SIMOrder, kind.klass + end + + def test_from_form_with_esim + kind = SIMKind.from_form(@esim_form.form) + assert_equal SIMOrder::ESIM, kind.klass + end + + def test_cfg_returns_nil_when_customer_currency_nil + kind = SIMKind.new("sim") + cust = customer(currency: nil) + assert_nil kind.cfg(cust.currency) + end + + def test_cfg_returns_nil_when_config_missing_variant + kind = SIMKind.new("invalid") + cust = customer(currency: :USD) + assert_nil kind.cfg(cust.currency) + end + + def test_cfg_returns_nil_when_config_missing_currency + kind = SIMKind.new("sim") + cust = customer(currency: :EUR) + assert_nil kind.cfg(cust.currency) + end +end diff --git a/test/test_sim_order.rb b/test/test_sim_order.rb index 8e659660f7838bfb4ccae4be40f5ef1cb786602e..886b95d1e6f14d6687f1cb3a953ce986ab7f241e 100644 --- a/test/test_sim_order.rb +++ b/test/test_sim_order.rb @@ -62,157 +62,164 @@ class SIMOrderTest < Minitest::Test end def test_complete_nil_nick - customer = Minitest::Mock.new(customer("123", balance: 100)) - customer.expect( - :stanza_from, - EMPromise.resolve(nil), - [Blather::Stanza::Message] - ) - - SIMOrder::DB.expect( - :transaction, - EMPromise.resolve(@sim) - ) do |&blk| - blk.call - end - - Transaction::DB.expect( - :exec, - nil, - [ - String, - Matching.new { |params| - assert_equal "123", params[0] - assert_equal "tx123", params[1] - assert_kind_of Time, params[2] - assert_kind_of Time, params[3] - assert_in_delta(-(@price / 100).to_d, params[4], 0.05) - assert_equal "SIM Activation 123", params[5] - } - ] - ) - - @sim_repo.expect(:available, EMPromise.resolve(@sim), []) - @sim_repo.expect(:put_owner, nil, [@sim, customer, "SIM"]) - @sim_repo.expect( - :refill, - EMPromise.resolve(OpenStruct.new(ack: "success", transaction_id: "tx123")) - ) do |refill_sim, amount_mb:| - @sim == refill_sim && amount_mb == 1024 - end + execute_command { + customer = Minitest::Mock.new(customer("123", balance: 100)) + customer.expect( + :stanza_from, + EMPromise.resolve(nil), + [Blather::Stanza::Message] + ) - order_form = Blather::Stanza::Iq::Command.new.tap { |iq| - iq.form.fields = [ - { var: "addr", value: "123 Main St" }, - { var: "nickname", value: nil } - ] - } + SIMOrder::DB.expect( + :transaction, + EMPromise.resolve(@sim) + ) do |&blk| + blk.call + end + + Transaction::DB.expect( + :exec, + nil, + [ + String, + Matching.new { |params| + assert_equal "123", params[0] + assert_equal "tx123", params[1] + assert_kind_of Time, params[2] + assert_kind_of Time, params[3] + assert_in_delta(-(@price / 100).to_d, params[4], 0.05) + assert_equal "SIM Activation 123", params[5] + } + ] + ) - blather = Minitest::Mock.new - blather.expect( - :<<, - EMPromise.resolve(:test_result), - [Matching.new do |reply| - assert_equal :completed, reply.status - assert_equal :info, reply.note_type - assert_equal( - "You will receive an notice from support when your SIM ships.", - reply.note.content + @sim_repo.expect(:available, EMPromise.resolve(@sim), []) + @sim_repo.expect(:put_owner, nil, [@sim, customer, "SIM"]) + @sim_repo.expect( + :refill, + EMPromise.resolve( + OpenStruct.new(ack: "success", transaction_id: "tx123") ) - end] - ) - sim_order = SIMOrder.for(customer, price: @price, plan: @plan_name) - sim_order.instance_variable_set(:@sim_repo, @sim_repo) + ) do |refill_sim, amount_mb:| + @sim == refill_sim && amount_mb == 1024 + end + + order_form = Blather::Stanza::Iq::Command.new.tap { |iq| + iq.form.fields = [ + { var: "addr", value: "123 Main St" }, + { var: "nickname", value: nil } + ] + } + + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.reject(:test_result), + [Matching.new do |reply| + assert_equal :executing, reply.status + assert_equal :info, reply.note_type + assert_equal( + "You will receive a notice from support when your SIM ships.", + reply.note.content + ) + end] + ) - assert_equal( - :test_result, - execute_command(blather: blather) { sim_order.complete(order_form) } - ) + sim_order = SIMOrder.for(customer, price: @price, plan: @plan_name) + sim_order.instance_variable_set(:@sim_repo, @sim_repo) + + assert_equal( + :test_result, + sim_order.complete(order_form).catch { |e| e }.sync + ) - assert_mock Transaction::DB - assert_mock SIMOrder::DB - assert_mock customer - assert_mock @sim_repo - assert_mock blather + assert_mock Transaction::DB + assert_mock SIMOrder::DB + assert_mock customer + assert_mock @sim_repo + } end em :test_complete_nil_nick def test_complete_nick_present - customer = Minitest::Mock.new(customer("123", balance: 100)) - customer.expect( - :stanza_from, - EMPromise.resolve(nil), - [Blather::Stanza::Message] - ) - Transaction::DB.expect( - :exec, - nil, - [ - String, - Matching.new { |params| - assert_equal "123", params[0] - assert_equal "tx123", params[1] - assert_kind_of Time, params[2] - assert_kind_of Time, params[3] - assert_in_delta(-(@price / 100).to_d, params[4], 0.05) - assert_equal "SIM Activation 123", params[5] - } - ] - ) + execute_command { + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.reject(:test_result), + [Matching.new do |reply| + assert_equal :info, reply.note_type + assert_equal :executing, reply.status + assert_equal( + "You will receive a notice from support when your SIM ships.", + reply.note.content + ) + end] + ) - SIMOrder::DB.expect( - :transaction, - @sim - ) do |&blk| - blk.call - end + customer = Minitest::Mock.new(customer("123", balance: 100)) + customer.expect( + :stanza_from, + EMPromise.resolve(nil), + [Blather::Stanza::Message] + ) + Transaction::DB.expect( + :exec, + nil, + [ + String, + Matching.new { |params| + assert_equal "123", params[0] + assert_equal "tx123", params[1] + assert_kind_of Time, params[2] + assert_kind_of Time, params[3] + assert_in_delta(-(@price / 100).to_d, params[4], 0.05) + assert_equal "SIM Activation 123", params[5] + } + ] + ) - @sim_repo.expect(:available, EMPromise.resolve(@sim), []) - @sim_repo.expect( - :put_owner, - nil, - [@sim, customer, "test_nick"] - ) - @sim_repo.expect( - :refill, - EMPromise.resolve(OpenStruct.new(ack: "success", transaction_id: "tx123")) - ) do |refill_sim, amount_mb:| - @sim == refill_sim && amount_mb == 1024 - end + SIMOrder::DB.expect( + :transaction, + @sim + ) do |&blk| + blk.call + end + + @sim_repo.expect(:available, EMPromise.resolve(@sim), []) + @sim_repo.expect( + :put_owner, + nil, + [@sim, customer, "test_nick"] + ) + @sim_repo.expect( + :refill, + EMPromise.resolve( + OpenStruct.new(ack: "success", transaction_id: "tx123") + ) + ) do |refill_sim, amount_mb:| + @sim == refill_sim && amount_mb == 1024 + end + + order_form = Blather::Stanza::Iq::Command.new.tap { |iq| + iq.form.fields = [ + { var: "addr", value: "123 Main St" }, + { var: "nickname", value: "test_nick" } + ] + } + + sim_order = + SIMOrder.for(customer, price: @price, plan: @plan_name) + sim_order.instance_variable_set(:@sim_repo, @sim_repo) - order_form = Blather::Stanza::Iq::Command.new.tap { |iq| - iq.form.fields = [ - { var: "addr", value: "123 Main St" }, - { var: "nickname", value: "test_nick" } - ] - } + assert_equal( + :test_result, + sim_order.complete(order_form).catch { |e| e }.sync + ) - blather = Minitest::Mock.new - blather.expect( - :<<, - EMPromise.resolve(:test_result), - [Matching.new do |reply| - assert_equal :completed, reply.status - assert_equal :info, reply.note_type - assert_equal( - "You will receive an notice from support when your SIM ships.", - reply.note.content - ) - end] - ) - sim_order = SIMOrder.for(customer, price: @price, plan: @plan_name) - sim_order.instance_variable_set(:@sim_repo, @sim_repo) - - assert_equal( - :test_result, - execute_command(blather: blather) { sim_order.complete(order_form) } - ) - - assert_mock Transaction::DB - assert_mock SIMOrder::DB - assert_mock customer - assert_mock @sim_repo - assert_mock blather + assert_mock Transaction::DB + assert_mock SIMOrder::DB + assert_mock customer + assert_mock @sim_repo + } end em :test_complete_nick_present end diff --git a/test/test_tel_selections.rb b/test/test_tel_selections.rb index c6583c13ea74f9103bea93437e706b2e19e76d11..1b6b1a622df18a97c81a49fa2587378c22e1017f 100644 --- a/test/test_tel_selections.rb +++ b/test/test_tel_selections.rb @@ -28,7 +28,10 @@ class TelSelectionsTest < Minitest::Test jid, TelSelections::ChooseTel::Tn.for_pending_value("+15555550000") ).sync - assert_equal "+15555550000", @manager[jid].then(&:choose_tel).sync.tel + assert_equal( + "+15555550000", + @manager[jid].then(&:choose_tel_or_data).sync.tel + ) end em :test_choose_tel_have_tel