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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +226 -0
- data/exe/rate-card +101 -0
- data/lib/rate_card/client.rb +124 -0
- data/lib/rate_card/constants/addresses.rb +101 -0
- data/lib/rate_card/constants/carriers.rb +49 -0
- data/lib/rate_card/constants/cubic_tiers.rb +67 -0
- data/lib/rate_card/csv_writer.rb +58 -0
- data/lib/rate_card/failure.rb +8 -0
- data/lib/rate_card/grid.rb +184 -0
- data/lib/rate_card/input.rb +32 -0
- data/lib/rate_card/run_spec.rb +164 -0
- data/lib/rate_card/runner.rb +43 -0
- data/lib/rate_card/service.rb +38 -0
- data/lib/rate_card/service_catalog.rb +62 -0
- data/lib/rate_card/shipment.rb +78 -0
- data/lib/rate_card/table_renderer.rb +78 -0
- data/lib/rate_card/token.rb +48 -0
- data/lib/rate_card/token_prompt.rb +58 -0
- data/lib/rate_card/tui/app.rb +521 -0
- data/lib/rate_card/tui/fields/multi_select.rb +120 -0
- data/lib/rate_card/tui/fields/select.rb +80 -0
- data/lib/rate_card/tui/fields/text.rb +73 -0
- data/lib/rate_card/tui/messages.rb +59 -0
- data/lib/rate_card/tui/theme.rb +51 -0
- data/lib/rate_card/ui.rb +126 -0
- data/lib/rate_card/version.rb +5 -0
- data/lib/rate_card/warning.rb +31 -0
- data/lib/rate_card.rb +51 -0
- metadata +189 -0
|
@@ -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
|