From 9a88e00a1f758eb9cc2633a625cb4967ffb0cf88 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Wed, 16 Apr 2025 15:02:47 -0500 Subject: [PATCH] New bulk order API --- .rubocop.yml | 1 + config-schema.dhall | 2 + config.dhall.sample | 3 +- lib/bandwidth_tn_order.rb | 23 ++- lib/transaction.rb | 2 + test/test_helper.rb | 6 +- test/test_web.rb | 290 +++++++++++++++++++++++++++++++++++++- web.rb | 69 +++++++++ 8 files changed, 388 insertions(+), 8 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 34eb5657f4c2660aff1f6ec90ee68e9d37796da9..a736a631ae148fb69be0ecccf5e215fef82a6841 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,6 +16,7 @@ Metrics/BlockLength: ExcludedMethods: - route - "on" + - json Exclude: - test/* diff --git a/config-schema.dhall b/config-schema.dhall index fff429e7654e1b5e26ac4cd6e1639a94d5e6ca9a..7b3c24875ba72a9b73a98a35ad848a00892ecb31 100644 --- a/config-schema.dhall +++ b/config-schema.dhall @@ -12,6 +12,8 @@ , private_key : Text , public_key : Text } +, bulk_order_tokens : + List { mapKey : Text, mapValue : { customer_id : Text, peer_id : Text } } , churnbuster : { account_id : Text, api_key : Text } , component : { jid : Text, secret : Text } , credit_card_url : forall (jid : Text) -> forall (customer_id : Text) -> Text diff --git a/config.dhall.sample b/config.dhall.sample index ba026d00b6478a2ac40db46acc6a734d18ace475..a2c8842c09bb3fb8587b557855daea2277d0eb9c 100644 --- a/config.dhall.sample +++ b/config.dhall.sample @@ -118,5 +118,6 @@ in churnbuster = { api_key = "", account_id = "" - } + }, + bulk_order_tokens = toMap { sometoken = { customer_id = "somecustomer", peer_id = "" } } } diff --git a/lib/bandwidth_tn_order.rb b/lib/bandwidth_tn_order.rb index b07a44e8a806a4e38a9c667aa2d9dfa7f12da8f6..2ae770b344b0c701a974e64e5582288308d8e960 100644 --- a/lib/bandwidth_tn_order.rb +++ b/lib/bandwidth_tn_order.rb @@ -26,12 +26,18 @@ class BandwidthTNOrder reservation_id: nil, **kwargs ) + create_custom( + name: name, **kwargs, + existing_telephone_number_order_type: { + telephone_number_list: { telephone_number: [tel.sub(/^\+?1?/, "")] } + }.merge(ReservationIdList.new(reservation_id).to_h) + ) + end + + def self.create_custom(name:, **kwargs) EMPromise.resolve(nil).then do Received.new(BandwidthIris::Order.create( - name: name, **ORDER_DEFAULTS, **kwargs, - existing_telephone_number_order_type: { - telephone_number_list: { telephone_number: [tel.sub(/^\+?1?/, "")] } - }.merge(ReservationIdList.new(reservation_id).to_h) + name: name, **ORDER_DEFAULTS, **kwargs )) end end @@ -79,7 +85,14 @@ class BandwidthTNOrder end def tel - "+1#{@order.completed_numbers[:telephone_number][:full_number]}" + tels[0] + end + + def tels + tns = @order.completed_numbers[:telephone_number] + (tns.is_a?(Array) ? tns : [tns]).map do |item| + "+1#{item[:full_number]}" + end end def poll diff --git a/lib/transaction.rb b/lib/transaction.rb index c880df6206a75fca5b910d269893dec7c72bfdc9..3ec445d30dfdf212aa61e4fb6431b424d2a589d1 100644 --- a/lib/transaction.rb +++ b/lib/transaction.rb @@ -15,6 +15,7 @@ class Transaction amount BigDecimal, coerce: ->(x) { BigDecimal(x, 4) } note String bonus_eligible? Bool(), default: true + ignore_duplicate Bool(), default: false end def insert @@ -72,6 +73,7 @@ class Transaction (customer_id, transaction_id, created_at, settled_after, amount, note) VALUES ($1, $2, $3, $4, $5, $6) + #{ignore_duplicate ? 'ON CONFLICT (transaction_id) DO NOTHING' : ''} SQL end diff --git a/test/test_helper.rb b/test/test_helper.rb index 6235ecfed2e7dae17d3dafeb2bb5f9a861fb797f..539f3e5ae84b5135bdc65c7f1cf3d9f9677a77ec 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -163,7 +163,11 @@ CONFIG = { onboarding_domain: "onboarding.example.com", adr: "A Mailing Address", interac: "interac@example.com", - support_link: ->(*) { "https://support.com" } + support_link: ->(*) { "https://support.com" }, + bulk_order_tokens: { + sometoken: { customer_id: "bulkcustomer", peer_id: "bulkpeer" }, + lowtoken: { customer_id: "customerid_low", peer_id: "lowpeer" } + } }.freeze def panic(e) diff --git a/test/test_web.rb b/test/test_web.rb index 85dc91e25a8035c6d6c0e1d62cf35971cac55c2a..8bc2cc36a2a44489fbb0b56437dbb303566b87e3 100644 --- a/test/test_web.rb +++ b/test/test_web.rb @@ -11,6 +11,7 @@ Customer::BLATHER = Minitest::Mock.new CustomerFwd::BANDWIDTH_VOICE = Minitest::Mock.new Web::BANDWIDTH_VOICE = Minitest::Mock.new LowBalance::AutoTopUp::CreditCardSale = Minitest::Mock.new +Transaction::DB = Minitest::Mock.new ReachableRedis = Minitest::Mock.new @@ -38,7 +39,8 @@ class WebTest < Minitest::Test "catapult_jid-+15551234563" => "customer_customerid_reach@component", "jmp_customer_jid-customerid2" => "customer2@example.com", "catapult_jid-+15551230000" => "customer_customerid2@component", - "jmp_customer_jid-customerid_tombed" => "customer_tombed@example.com" + "jmp_customer_jid-customerid_tombed" => "customer_tombed@example.com", + "jmp_customer_jid-bulkcustomer" => "bulk@example.com" ), db: FakeDB.new( ["customerid"] => [{ @@ -75,6 +77,11 @@ class WebTest < Minitest::Test "balance" => BigDecimal(10), "plan_name" => "test_usd", "expires_at" => Time.now + 100 + }], + ["bulkcustomer"] => [{ + "balance" => BigDecimal(1000), + "plan_name" => "test_usd", + "expires_at" => Time.now + 100 }] ), sgx_repo: Bwmsgsv2Repo.new( @@ -865,4 +872,285 @@ class WebTest < Minitest::Test assert_mock ReachableRedis end em :test_inbound_from_reachability_during_reachability + + def test_bulk_order + create_order = stub_request( + :post, + "https://dashboard.bandwidth.com/v1.0/accounts//orders" + ).to_return(status: 201, body: <<~RESPONSE) + + + test_order + + + RESPONSE + + post( + "/orders", + { quantity: 100 }, + "HTTP_ACCEPT" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer sometoken" + ) + + assert last_response.ok? + assert_equal( + { id: "test_order" }.to_json, + last_response.body + ) + assert_requested create_order + end + em :test_bulk_order + + def test_bulk_order_low_balance + post( + "/orders", + { quantity: 100 }, + "HTTP_ACCEPT" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer lowtoken" + ) + + assert_equal 402, last_response.status + end + em :test_bulk_order_low_balance + + def test_bulk_order_bad_token + post( + "/orders", + { quantity: 100 }, + "HTTP_ACCEPT" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer badtoken" + ) + + assert_equal 401, last_response.status + end + em :test_bulk_order_bad_token + + def test_order_get + stub_request( + :get, + "https://dashboard.bandwidth.com/v1.0/accounts//orders/testorder" + ).to_return(status: 200, body: <<~RESPONSE) + + testorder + complete + + + 5551234567 + + + + RESPONSE + + Transaction::DB.expect(:transaction, nil) do |&blk| + blk.call + true + end + Transaction::DB.expect( + :exec, + nil, + [String, Matching.new { |params| + assert_equal "bulkcustomer", params[0] + assert_equal "testorder", params[1] + assert_equal(-1.75, params[4]) + assert_equal "Bulk order", params[5] + }] + ) + + get( + "/orders/testorder", + "", + "HTTP_ACCEPT" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer sometoken" + ) + + assert last_response.ok? + assert_equal( + { id: "testorder", status: "complete", tels: ["+15551234567"] }.to_json, + last_response.body + ) + assert_mock Transaction::DB + end + em :test_order_get + + def test_order_get_multi + stub_request( + :get, + "https://dashboard.bandwidth.com/v1.0/accounts//orders/testorder" + ).to_return(status: 200, body: <<~RESPONSE) + + testorder + complete + + + 5551234567 + + + 5551234568 + + + + RESPONSE + + Transaction::DB.expect(:transaction, nil) do |&blk| + blk.call + true + end + Transaction::DB.expect( + :exec, + nil, + [String, Matching.new { |params| + assert_equal "bulkcustomer", params[0] + assert_equal "testorder", params[1] + assert_equal (-1.75 * 2), params[4] + assert_equal "Bulk order", params[5] + }] + ) + + get( + "/orders/testorder", + "", + "HTTP_ACCEPT" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer sometoken" + ) + + assert last_response.ok? + assert_equal( + { + id: "testorder", + status: "complete", + tels: ["+15551234567", "+15551234568"] + }.to_json, + last_response.body + ) + assert_mock Transaction::DB + end + em :test_order_get_multi + + def test_order_get_received + stub_request( + :get, + "https://dashboard.bandwidth.com/v1.0/accounts//orders/testorder" + ).to_return(status: 200, body: <<~RESPONSE) + + testorder + received + + RESPONSE + + get( + "/orders/testorder", + "", + "HTTP_ACCEPT" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer sometoken" + ) + + assert last_response.ok? + assert_equal( + { id: "testorder", status: "received" }.to_json, + last_response.body + ) + end + em :test_order_get_received + + def test_order_get_failed + stub_request( + :get, + "https://dashboard.bandwidth.com/v1.0/accounts//orders/testorder" + ).to_return(status: 200, body: <<~RESPONSE) + + testorder + failed + + RESPONSE + + get( + "/orders/testorder", + "", + "HTTP_ACCEPT" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer sometoken" + ) + + assert last_response.ok? + assert_equal( + { id: "testorder", status: "failed" }.to_json, + last_response.body + ) + end + em :test_order_get_failed + + def test_order_get_bad_token + get( + "/orders/testorder", + "", + "HTTP_ACCEPT" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer badtoken" + ) + + assert_equal 401, last_response.status + end + em :test_order_get_bad_token + + def test_delete_tel + stub_request( + :get, + "https://dashboard.bandwidth.com/v1.0/tns/+15551234567/tndetails" + ).to_return(status: 200, body: <<~RESPONSE) + + + bulkpeer + + + RESPONSE + + req = stub_request( + :post, + "https://dashboard.bandwidth.com/v1.0/accounts//disconnects" + ).to_return(status: 200, body: "") + + delete( + "/orders/tels/+15551234567", + "", + "HTTP_ACCEPT" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer sometoken" + ) + + assert last_response.ok? + assert_requested req + end + em :test_delete_tel + + def test_delete_tel_not_yours + stub_request( + :get, + "https://dashboard.bandwidth.com/v1.0/tns/+15551234567/tndetails" + ).to_return(status: 200, body: <<~RESPONSE) + + + mainpeer + + + RESPONSE + + delete( + "/orders/tels/+15551234567", + "", + "HTTP_ACCEPT" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer sometoken" + ) + + assert_equal 401, last_response.status + end + em :test_delete_tel_not_yours + + def test_delete_tel_bad_token + delete( + "/orders/tels/+15551234567", + "", + "HTTP_ACCEPT" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer badtoken" + ) + + assert_equal 401, last_response.status + end + em :test_delete_tel_bad_token end diff --git a/web.rb b/web.rb index 3e9a835f540c34d5caae9f52721e514235dcafa3..c535f8cdec8533cc66b9da76fa2a64fb9673e9bd 100644 --- a/web.rb +++ b/web.rb @@ -9,6 +9,7 @@ require "roda" require "sentry-ruby" require "thin" +require_relative "lib/bandwidth_tn_order" require_relative "lib/call_attempt_repo" require_relative "lib/trust_level_repo" require_relative "lib/cdr" @@ -503,6 +504,74 @@ class Web < Roda end end + r.on "orders" do + token = r.env["HTTP_AUTHORIZATION"].to_s.sub(/\ABearer\s+/, "") + + r.json do + if (orderer = CONFIG[:bulk_order_tokens][token.to_sym]) + r.get :order_id do |order_id| + BandwidthTNOrder.get(order_id).then do |order| + if order.status == :complete + Transaction.new( + customer_id: orderer[:customer_id], + transaction_id: order.id, + amount: order.tels.length * -1.75, + note: "Bulk order", + ignore_duplicate: true + ).insert.then do + { + id: order.id, + status: order.status, + tels: order.tels + }.to_json + end + else + { id: order.id, status: order.status }.to_json + end + end + end + + r.on "tels" do + r.on :tel, method: :delete do |tel| + tn_repo = BandwidthTnRepo.new + tn = tn_repo.find(tel) + if tn&.dig(:sip_peer, :peer_id).to_s == orderer[:peer_id] + tn_repo.disconnect(tel, orderer[:customer_id]) + { status: "disconnected" }.to_json + else + response.status = 401 + { error: "Number not found" }.to_json + end + end + end + + r.post do + customer_repo.find(orderer[:customer_id]).then do |customer| + if customer.balance >= 1.75 * params["quantity"].to_i + BandwidthTNOrder.create_custom( + name: "Bulk order", + customer_order_id: orderer[:customer_id], + peer_id: orderer[:peer_id], + state_search_and_order_type: { + quantity: params["quantity"].to_i, + state: ["CA", "TX", "IL", "NY", "FL"].sample + } + ).then do |order| + { id: order.id }.to_json + end + else + response.status = 402 + { error: "Balance too low" }.to_json + end + end + end + else + response.status = 401 + { error: "Bad token" }.to_json + end + end + end + r.public end end