rate-card 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +10 -202
- data/exe/rate-card +12 -5
- data/lib/rate_card/constants/addresses.rb +6 -1
- data/lib/rate_card/constants/carriers.rb +2 -2
- data/lib/rate_card/grid.rb +24 -72
- data/lib/rate_card/provider_prompt.rb +19 -0
- data/lib/rate_card/providers/ehub/client.rb +128 -0
- data/lib/rate_card/providers/ehub/provider.rb +109 -0
- data/lib/rate_card/providers/ehub/service_catalog.rb +59 -0
- data/lib/rate_card/providers/ehub/shipment.rb +82 -0
- data/lib/rate_card/providers/ehub/token.rb +52 -0
- data/lib/rate_card/providers/ehub/token_prompt.rb +71 -0
- data/lib/rate_card/providers.rb +22 -0
- data/lib/rate_card/service.rb +14 -0
- data/lib/rate_card/tui/app.rb +8 -6
- data/lib/rate_card/version.rb +1 -1
- data/lib/rate_card.rb +8 -5
- metadata +9 -6
- data/lib/rate_card/client.rb +0 -124
- data/lib/rate_card/service_catalog.rb +0 -62
- data/lib/rate_card/shipment.rb +0 -78
- data/lib/rate_card/token.rb +0 -48
- data/lib/rate_card/token_prompt.rb +0 -58
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RateCard
|
|
4
|
-
# Turns the /services response into the list of services the wizard can offer.
|
|
5
|
-
# Read live rather than from a hardcoded list so a service id that has drifted
|
|
6
|
-
# cannot silently produce a card of blank cells.
|
|
7
|
-
module ServiceCatalog
|
|
8
|
-
module_function
|
|
9
|
-
|
|
10
|
-
# Returns Array<Service>, de-duplicated and sorted for display.
|
|
11
|
-
def from_response(body)
|
|
12
|
-
entries = body.is_a?(Hash) ? (body['services'] || []) : []
|
|
13
|
-
|
|
14
|
-
services = entries.filter_map { |entry| build_service(entry) }.uniq(&:id)
|
|
15
|
-
sort_for_display(services)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# Returns { carrier => Array<Service> } with carriers in display order.
|
|
19
|
-
def group_by_carrier(services)
|
|
20
|
-
services.group_by(&:carrier)
|
|
21
|
-
.sort_by { |carrier, _| carrier_rank(carrier) }
|
|
22
|
-
.to_h
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def build_service(entry)
|
|
26
|
-
id = entry['service_id']
|
|
27
|
-
return nil if id.nil?
|
|
28
|
-
|
|
29
|
-
code = entry['service_code'].to_s
|
|
30
|
-
name = entry['service'].to_s
|
|
31
|
-
name = code if name.strip.empty?
|
|
32
|
-
|
|
33
|
-
Service.new(
|
|
34
|
-
id: id.to_i,
|
|
35
|
-
code: code,
|
|
36
|
-
name: name,
|
|
37
|
-
carrier: Constants::Carriers.for_carrier_code(entry['carrier_code']),
|
|
38
|
-
package_types: package_types(entry)
|
|
39
|
-
)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# services[].package_types is an array of {type, name}; only the type is
|
|
43
|
-
# sent back in a rate request.
|
|
44
|
-
def package_types(entry)
|
|
45
|
-
Array(entry['package_types']).filter_map do |package_type|
|
|
46
|
-
next package_type.to_s unless package_type.is_a?(Hash)
|
|
47
|
-
|
|
48
|
-
type = package_type['type'].to_s
|
|
49
|
-
type.empty? ? nil : type
|
|
50
|
-
end.uniq
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def sort_for_display(services)
|
|
54
|
-
services.sort_by { |service| [carrier_rank(service.carrier), service.name.to_s] }
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def carrier_rank(carrier)
|
|
58
|
-
index = Constants::Carriers.display_order.index(carrier)
|
|
59
|
-
index || Constants::Carriers.display_order.length
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
data/lib/rate_card/shipment.rb
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RateCard
|
|
4
|
-
# Builds the request body for one (weight, zone) rate call.
|
|
5
|
-
#
|
|
6
|
-
# The shape follows the documented rate request
|
|
7
|
-
# (https://docs.ehub.com/rates/rate-request). The customs blocks are sent even
|
|
8
|
-
# for domestic cards because international services error without them; the
|
|
9
|
-
# docs mark the parcel-level one as required for international shipments.
|
|
10
|
-
class Shipment
|
|
11
|
-
DIMENSION = 2
|
|
12
|
-
ITEM_VALUE = 18.99
|
|
13
|
-
HS_TARIFF_CODE = '1704.90.3000'
|
|
14
|
-
|
|
15
|
-
def initialize(spec:, weight:, address:)
|
|
16
|
-
@spec = spec
|
|
17
|
-
@weight = weight
|
|
18
|
-
@address = address
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def payload
|
|
22
|
-
{
|
|
23
|
-
shipment: {
|
|
24
|
-
from_location: Constants::Addresses::ORIGIN,
|
|
25
|
-
to_location: to_location,
|
|
26
|
-
parcels: [parcel]
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
attr_reader :spec, :weight, :address
|
|
34
|
-
|
|
35
|
-
def to_location
|
|
36
|
-
{
|
|
37
|
-
company: 'Rate Card Builder',
|
|
38
|
-
phone: '000-000-0000'
|
|
39
|
-
}.merge(address)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def parcel
|
|
43
|
-
box = dims
|
|
44
|
-
{
|
|
45
|
-
package_type: spec.package_type,
|
|
46
|
-
length: box[:length],
|
|
47
|
-
width: box[:width],
|
|
48
|
-
height: box[:height],
|
|
49
|
-
weight: spec.weight_in_oz_for(weight),
|
|
50
|
-
parcel_items: [parcel_item],
|
|
51
|
-
customs_data: customs_data
|
|
52
|
-
}
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def dims
|
|
56
|
-
spec.dims_for(weight) || { length: DIMENSION, width: DIMENSION, height: DIMENSION }
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# The parcel as a whole. One item at quantity 1, so this mirrors the item's
|
|
60
|
-
# declaration; the docs require it for international shipments.
|
|
61
|
-
def customs_data
|
|
62
|
-
{
|
|
63
|
-
content_type: 'merchandise',
|
|
64
|
-
value: ITEM_VALUE,
|
|
65
|
-
hs_tariff_code: HS_TARIFF_CODE
|
|
66
|
-
}
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def parcel_item
|
|
70
|
-
{
|
|
71
|
-
name: 'Candy',
|
|
72
|
-
quantity: 1,
|
|
73
|
-
price: ITEM_VALUE,
|
|
74
|
-
customs_data: customs_data
|
|
75
|
-
}
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
data/lib/rate_card/token.rb
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'base64'
|
|
4
|
-
require 'json'
|
|
5
|
-
|
|
6
|
-
module RateCard
|
|
7
|
-
# Reads claims out of an eHub JWT so we can show the user which customer a
|
|
8
|
-
# pasted token belongs to. We decode so the user can confirm they are pointed at
|
|
9
|
-
# the intended production account before a run starts. Deliberately does NOT
|
|
10
|
-
# verify the signature: these claims are displayed, never trusted for
|
|
11
|
-
# authorization. The API's 401 is the only real check.
|
|
12
|
-
module Token
|
|
13
|
-
class DecodeError < Error; end
|
|
14
|
-
|
|
15
|
-
module_function
|
|
16
|
-
|
|
17
|
-
def decode(raw)
|
|
18
|
-
payload = payload_from(raw)
|
|
19
|
-
user = payload.dig('data', 'user') || {}
|
|
20
|
-
|
|
21
|
-
customer_id = user['customer_id'] if user['customer_id'].is_a?(Integer)
|
|
22
|
-
email = presence(user['email'])
|
|
23
|
-
# FIXME - I don't even think we need a name field here tbh
|
|
24
|
-
name = email || (customer_id ? "customer #{customer_id}" : 'unknown customer')
|
|
25
|
-
|
|
26
|
-
{ name: name, customer_id: customer_id, email: email }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def presence(value)
|
|
30
|
-
string = value.to_s.strip
|
|
31
|
-
string.empty? ? nil : string
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def payload_from(raw)
|
|
35
|
-
segments = raw.to_s.split('.')
|
|
36
|
-
raise DecodeError, 'token is not a JWT (expected three segments)' unless segments.length == 3
|
|
37
|
-
|
|
38
|
-
# urlsafe_decode64 restores missing '=' padding itself, which matters
|
|
39
|
-
# because real JWT segments are always unpadded.
|
|
40
|
-
parsed = JSON.parse(Base64.urlsafe_decode64(segments[1]))
|
|
41
|
-
raise DecodeError, 'token payload is not a JSON object' unless parsed.is_a?(Hash)
|
|
42
|
-
|
|
43
|
-
parsed
|
|
44
|
-
rescue ArgumentError, JSON::ParserError
|
|
45
|
-
raise DecodeError, 'this does not look like an eHub API token — please try again'
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'io/console'
|
|
4
|
-
|
|
5
|
-
module RateCard
|
|
6
|
-
# Reads the API token before the TUI takes the terminal.
|
|
7
|
-
#
|
|
8
|
-
# This is deliberately NOT a TUI field. bubbletea-ruby's input reader returns
|
|
9
|
-
# one key per poll and discards the rest of the buffered bytes, so a pasted
|
|
10
|
-
# 200-character JWT arrives as a single character and the other 199 are lost
|
|
11
|
-
# — verified against 0.1.4, and bracketed_paste does not help. Typing is
|
|
12
|
-
# unaffected at any speed, so every other answer is safe inside the loop; the
|
|
13
|
-
# token is the one field that is always pasted.
|
|
14
|
-
#
|
|
15
|
-
# Reading it here also means the terminal's own line editing handles the
|
|
16
|
-
# paste, which is what makes it work.
|
|
17
|
-
module TokenPrompt
|
|
18
|
-
module_function
|
|
19
|
-
|
|
20
|
-
# Returns the raw token, or nil if the user gave up (EOF / ctrl-d).
|
|
21
|
-
def read(ui:, io: $stdin)
|
|
22
|
-
loop do
|
|
23
|
-
ui.prompt('eHub API token: ')
|
|
24
|
-
raw = read_masked(io)
|
|
25
|
-
return nil if raw.nil?
|
|
26
|
-
|
|
27
|
-
raw = raw.strip
|
|
28
|
-
# Never a silent `next`: that reprinted the prompt with no explanation,
|
|
29
|
-
# and a pasted token whose clipboard content carries a leading newline
|
|
30
|
-
# then reads as a doubled prompt or a hang. Not phrased as a bad token —
|
|
31
|
-
# the user has not pasted one yet.
|
|
32
|
-
if raw.empty?
|
|
33
|
-
ui.error('no token on that line — paste the token')
|
|
34
|
-
next
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
begin
|
|
38
|
-
Token.decode(raw)
|
|
39
|
-
return raw
|
|
40
|
-
rescue Token::DecodeError => e
|
|
41
|
-
ui.error("#{e.message} — paste the token again")
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# noecho keeps the JWT off the screen; #gets keeps the terminal's line
|
|
47
|
-
# discipline, which is what makes a paste arrive intact.
|
|
48
|
-
def read_masked(io)
|
|
49
|
-
if io.respond_to?(:noecho) && io.tty?
|
|
50
|
-
value = io.noecho(&:gets)
|
|
51
|
-
puts
|
|
52
|
-
value
|
|
53
|
-
else
|
|
54
|
-
io.gets
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|