rate-card 0.1.0

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.
@@ -0,0 +1,78 @@
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
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lipgloss'
4
+ require_relative 'tui/theme'
5
+
6
+ module RateCard
7
+ # Renders the grid for the terminal: one table per service per rate key, so
8
+ # stdout maps 1:1 to the CSV files. Returns strings rather than printing, so
9
+ # UI owns all output and this stays testable.
10
+ #
11
+ # Lipgloss draws these rather than TTY::Table, so the card wears the same
12
+ # rounded border as the TUI banner instead of looking like a different
13
+ # program's output. Lipgloss also strips colour by itself when stdout is not a
14
+ # terminal, so the bold header survives being piped to a file as plain text.
15
+ class TableRenderer
16
+ MISSING = '—'
17
+
18
+ def initialize(grid:, spec:)
19
+ @grid = grid
20
+ @spec = spec
21
+ end
22
+
23
+ # Returns Array<[title String, rendered String]>.
24
+ def tables
25
+ spec.services.flat_map do |service|
26
+ spec.rate_keys.map { |rate_key| [title_for(service, rate_key), render(service, rate_key)] }
27
+ end
28
+ end
29
+
30
+ def rows(service, rate_key)
31
+ spec.rows.map do |row|
32
+ cells = spec.zones.map do |zone|
33
+ format_rate(grid.value(service_id: service.id, rate_key: rate_key,
34
+ weight: row, zone: zone))
35
+ end
36
+ [spec.row_label(row), *cells]
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :grid, :spec
43
+
44
+ def title_for(service, rate_key)
45
+ "#{spec.customer_name} (#{spec.customer_id}) · #{service.name} · " \
46
+ "#{spec.rate_key_label(rate_key)}"
47
+ end
48
+
49
+ def render(service, rate_key)
50
+ header = [spec.weight_label, *spec.zones.map { |zone| "Z#{zone}" }]
51
+ body = rows(service, rate_key)
52
+
53
+ Lipgloss::Table.new
54
+ .headers(header)
55
+ .rows(body)
56
+ .border(Lipgloss::ROUNDED_BORDER)
57
+ .style_func(rows: body.length, columns: header.length) { |row, _column| cell_style(row) }
58
+ .render
59
+ end
60
+
61
+ # Everything is right-aligned: these are all numbers, and a rate is only
62
+ # comparable to the one above it when the decimal points line up. The
63
+ # header is the one styled row, which is what makes a wide card scannable
64
+ # once several tables are printed in a row.
65
+ def cell_style(row)
66
+ style = Lipgloss::Style.new.align(Lipgloss::RIGHT).padding(0, 1)
67
+ return style.bold(true).foreground(TUI::Theme::ACCENT) if row == Lipgloss::Table::HEADER_ROW
68
+
69
+ style
70
+ end
71
+
72
+ def format_rate(value)
73
+ return MISSING if value.nil?
74
+
75
+ format('%.2f', value)
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,58 @@
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