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.
@@ -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
@@ -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
@@ -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