rate-card 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45fc9c209efdd18d8586a8cbbb2e02ff0c83c410c2d27c75762dd93999b9276a
4
- data.tar.gz: a0aaf2c79154dc92586953af3c9a89abfe7997cbab3825ba9188e53bd250c730
3
+ metadata.gz: 320d6905617967d73305befafac57482eed231ff28b39d3282d61a8ae31549ff
4
+ data.tar.gz: a54e83be25c1f657dd5fe6eb4a416a02a8fb1f88e38439523f43d2a1c9fd9878
5
5
  SHA512:
6
- metadata.gz: 520df789fc49541ed4254432db087d217ecf66b5367f5d3a25564334fc9ef80712a246e874a309f87ee843cbb73cf5d60656d7e2c31f72c3f30a82e62020c988
7
- data.tar.gz: 6cfb5ebd7544fb624fac46a6bc8567dd0175814b2f1116cd7ef7d8a111f1b9c099922d0b2cea8b7264b5e12446701214ffe44a66302b368c070b3417566bbf8c
6
+ metadata.gz: d4c28f5cc71b01a00e5e7cd41e785acda43c117d26c456ea313239819fb95b9779efdc0a7b20c20f96a5013049e108c715386bdc28337e7d7bfbc14f0c917c07
7
+ data.tar.gz: c02ecec88df73aa9fa01d33cd6107d8b1e4911e367bc6d54bb996a92834ee4cfce6add454a5b5209b283509322d276e44548f263af07eaad74fe8e40d4c10f20
data/README.md CHANGED
@@ -1,226 +1,34 @@
1
1
  # rate-card
2
2
 
3
3
  Interactive CLI that generates a shipping rate card — a weight × zone grid of rates — from the
4
- **eHub production API**. Prints the card to your terminal and saves CSVs to `~/Downloads`.
4
+ eHub production API. Prints the card to your terminal and saves CSVs to `~/Downloads`.
5
5
 
6
6
  ## Install
7
7
 
8
- Homebrew — nothing else needed, and no Ruby setup of your own:
9
-
10
- ```bash
11
- brew install jxsoren/tools/rate-card
12
- rate-card
13
- ```
14
-
15
- > The tap repo (`jxsoren/homebrew-tools`) does not exist yet, so this command does not
16
- > work until it is created — see **Release** below. The gem install underneath it does.
17
-
18
- Homebrew installs its own Ruby and keeps every gem inside the keg, so this cannot
19
- collide with an rbenv or system Ruby you already have.
20
-
21
- If you already work in Ruby, the gem is equivalent:
22
-
23
- ```bash
24
- gem install rate-card
25
- ```
26
-
27
- To upgrade: `brew upgrade rate-card`, or `gem update rate-card`.
28
-
29
- ## Develop
30
-
31
8
  ```bash
32
- bundle install
33
- bundle exec exe/rate-card
34
- NO_COLOR=1 bundle exec rspec
9
+ cd ~ && brew install jxsoren/tools/rate-card
35
10
  ```
36
11
 
37
- `NO_COLOR=1` is required when running the specs from a terminal — five specs assert on
38
- uncoloured rendered output, and lipgloss colours whenever stdout is a tty. See the comment
39
- in `spec/spec_helper.rb`.
40
-
41
- Ruby 3.3.4 (pinned in `.ruby-version`); the gemspec floor is 3.2, and Homebrew currently
42
- builds against Ruby 4.0.
43
-
44
- The TUI is [bubbletea-ruby](https://github.com/marcoroth/bubbletea-ruby) with `bubbles` and
45
- `lipgloss`. These ship precompiled native binaries, so no Go toolchain is needed to install
46
- them. Note that `bubbles` is a shared gem name — `0.0.x` is an unrelated gem that pulls in
47
- `aws-sdk` — which is why the gemspec pins `~> 0.1`.
48
-
49
- ## Release
50
-
51
- Bump `lib/rate_card/version.rb`, commit, then:
12
+ Then run it:
52
13
 
53
14
  ```bash
54
- bin/release
15
+ rate-card
55
16
  ```
56
17
 
57
- That runs the specs, builds and pushes the gem, and rewrites the formula's `url` and
58
- `sha256` to match what was pushed. The tap commit is left to you — see the script's
59
- closing output. `packaging/homebrew/rate-card.rb` is the source of truth for the formula;
60
- the tap's `Formula/rate-card.rb` is a copy.
61
-
62
- ## Use
18
+ If you already work in Ruby, the gem is equivalent:
63
19
 
64
20
  ```bash
65
- bundle exec exe/rate-card
21
+ gem install rate-card
66
22
  ```
67
23
 
68
- No flags needed — the wizard asks for everything:
69
-
70
- 1. **eHub API token** — pasted, masked. Decoded so you can confirm the account before the run
71
- starts. Never written to disk. A malformed token re-prompts rather than ending the run.
72
- 2. **Rate by** — Weight or Cubic dimensions. Only asked when the token has USPS
73
- services, since USPS cubic pricing (Ground Advantage Cubic, Priority Mail Cubic) is
74
- priced by which of ten official volume tiers the package falls into, not by weight.
75
- Choosing Cubic restricts the carrier to USPS and replaces the weight-unit and
76
- weight-range questions with a multi-select of the ten tiers. Choosing Cubic does not check
77
- that the service you go on to pick is actually cubic-priced — you're responsible for
78
- selecting a cubic-rated USPS service (e.g. "USPS Ground Advantage Cubic").
79
- 3. **Carrier**, then **services** — the list comes from the customer's own service catalogue
80
- (`GET /api/v2/services?category=shipping`), so it is exactly what that customer has enabled,
81
- including services that would not quote at a single probe weight.
82
- 4. **Zones** (`1-8`, or `1,3,5`); then, in weight mode, **weight unit** (`oz`/`lbs`) and
83
- **weight range** — or, in cubic mode, the **cubic tiers** to include; then
84
- **package type**, **rate columns** (shipper rate, meter rate).
85
- 5. A recap of every answer, with the call count, and a **Run** / **Back** choice. It opens on
86
- **Back**, so a stray enter carried over from the previous question goes back rather than
87
- spending 128 production calls; `ctrl-c` abandons the session outright.
88
-
89
- Bad input at the zone or weight prompt re-prompts; it never costs you the run.
90
-
91
- `esc` steps back to the previous question at any point in the wizard, including from the recap.
92
- The question you land on reopens on the answer it already has; the answers that came *after* it
93
- are forgotten, since they were given against a choice you are about to change.
94
-
95
- ### Keys
96
-
97
- | Key | Effect |
98
- | --- | --- |
99
- | `↑` / `↓`, `k` / `j` | Move between choices |
100
- | `space` | Tick or untick (services, rate columns) |
101
- | `a` | Tick or untick everything |
102
- | `enter` | Confirm the answer |
103
- | `esc` | Go back to the previous question |
104
- | `ctrl-c` | Abandon the run at any point |
105
-
106
- Answered questions stay on screen as you go, and the fetch shows a live bar with a running
107
- count of failed cells — so a run that is going wrong can be abandoned early rather than waited
108
- out.
109
-
110
- ### One caveat: paste only the token
111
-
112
- The token prompt is deliberately a plain readline **before** the TUI starts, because
113
- bubbletea-ruby 0.1.4's input reader returns one key per poll and discards the rest of the
114
- buffered bytes: a pasted 200-character JWT arrives as a single character. Typing is unaffected
115
- at any speed, so every other answer is safe inside the TUI — but **type** the zone and weight
116
- ranges rather than pasting them.
117
-
118
- To reproduce the underlying bug: run a bare Bubbletea model that logs every `KeyMessage`, then
119
- write 36 bytes to its pty in one call. One message arrives, carrying one rune; the other 35
120
- bytes are discarded.
121
-
122
- For a bare `rate-card` command:
24
+ ## Update
123
25
 
124
26
  ```bash
125
- ln -s "$PWD/exe/rate-card" ~/bin/rate-card
27
+ cd ~ && brew update && brew upgrade rate-card
126
28
  ```
127
29
 
128
- ### Flags
129
-
130
- | Flag | Effect |
131
- | --- | --- |
132
- | `--output-dir DIR` | Save CSVs somewhere other than `~/Downloads/rate_cards` |
133
- | `--no-table` | Skip printing tables to stdout |
134
- | `--version`, `--help` | As expected |
135
-
136
- Everything else is interactive by design — a second input path would let flags and wizard
137
- answers disagree.
138
-
139
- ## Output
140
-
141
- Both to your terminal and to disk:
142
-
143
- ```
144
- ops@acme.test (1042) · USPS Ground Advantage · shipper rate
145
- ┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
146
- │ wt(oz) │ Z1 │ Z2 │ Z3 │ Z4 │ Z5 │ Z6 │ Z7 │ Z8 │
147
- ├────────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤
148
- │ 1 │ 1.11 │ 2.22 │ 3.33 │ 4.44 │ 5.55 │ 6.66 │ 7.77 │ 8.88 │
149
- │ 2 │ 2.11 │ 3.22 │ 4.33 │ 5.44 │ — │ 7.66 │ 8.77 │ 9.88 │
150
- └────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
151
-
152
- ⚠ 1 cell failed
153
- wt 2 Z5 → 500 upstream carrier error
154
-
155
- ✓ Saved 2 files to ~/Downloads/rate_cards/ops_acme_test_1042_2026-09-01T12-00-00Z
156
- GroundAdvantage_shipper_rate.csv
157
- GroundAdvantage_meter_rate.csv
158
- ```
159
-
160
- The rates above are invented placeholders, not a real quote.
161
-
162
- One CSV per service per rate column. Header is `weight` plus one column per zone. The run
163
- directory is timestamped, so a new card never clobbers an old one.
164
-
165
- **A missing rate is an empty cell, never `0.00`.** A zero in a rate table reads as a genuine
166
- free rate; `../rate_table_builder` writes `0.0` there and this tool deliberately does not.
167
- Failures are listed after the tables with weight, zone and reason, and a run with holes still
168
- writes its files.
169
-
170
- ## Call volume
171
-
172
- One API call per (weight × zone). **Services are free**: one response carries every service the
173
- customer has enabled, so checking six costs the same as one. A 16-weight, 8-zone card is 128
174
- calls, fanned out 8 at a time.
175
-
176
- Two distinct no-output cases, which the tool does not conflate:
177
-
178
- - **Every call failed** → likely network or token.
179
- - **Calls succeeded but nothing was priced** → your service, package type or weight range.
180
- USPS First Class is not quoted above 13 oz, for instance.
181
-
182
- A carrier failure is not a failed call: eHub answers 201 and puts the reason in the response
183
- `warnings` array, or in a service's `errors` field. Both are reported under
184
- *the API reported N warnings*, deduplicated with an occurrence count.
185
-
186
- ## Tests
30
+ ## Uninstall
187
31
 
188
32
  ```bash
189
- NO_COLOR=1 bundle exec rspec
33
+ brew uninstall rate-card
190
34
  ```
191
-
192
- **No test contacts production** — `Client` is the only network seam, and specs stub it via
193
- Faraday's test adapter.
194
-
195
- ## Design
196
-
197
- The wizard's only product is a validated `RunSpec`; everything downstream consumes only that.
198
- So the fetch/assemble/render engine is testable with no prompting and no network.
199
-
200
- | File | Responsibility |
201
- | --- | --- |
202
- | `exe/rate-card` | Entrypoint: flags → wizard → runner. Every error becomes one clean line |
203
- | `client.rb` | The only network seam. Every outcome becomes a Hash, `Unauthorized`, or `RequestFailed` |
204
- | `token.rb` | Decodes the JWT payload to name the account. Does not verify the signature |
205
- | `service_catalog.rb` | Service catalogue response → `Service` objects, grouped by carrier |
206
- | `run_spec.rb` | The validated inputs for one run |
207
- | `shipment.rb` | One (weight, zone) request payload |
208
- | `grid.rb` | Fans out the calls, assembles cells and failures |
209
- | `csv_writer.rb` / `table_renderer.rb` | Peers reading the same `Grid`, so files and terminal cannot disagree |
210
- | `ui.rb` | Every byte the tool prints |
211
- | `wizard.rb` / `runner.rb` | Ask; then orchestrate |
212
-
213
- `constants/addresses.rb` holds hand-verified zone addresses — zone membership is a property of
214
- a real address and a carrier's zone chart, so it is never derived at runtime.
215
-
216
- ## Related tools
217
-
218
- - `../rate_sheet_builder` — batch, multi-customer, YAML roster. Use it for many customers at once.
219
- - `../rate_table_builder` — the abandoned predecessor to this tool.
220
-
221
- ## Not done
222
-
223
- `spec/fixtures/rates_response.json` was never captured, so no test pins the production response
224
- field names (`service_rates`, `service_id`, `rate`, `meter_rate`, `errors`). If eHub renames one, the tool
225
- will produce empty cells rather than failing loudly. Capturing one real response would close
226
- that gap — see Task 16 of `docs/superpowers/plans/2026-08-31-rate-card-cli.md`.
data/exe/rate-card CHANGED
@@ -14,6 +14,8 @@ options = { output_base: DEFAULT_OUTPUT_BASE, show_table: true }
14
14
  parser = OptionParser.new do |opts|
15
15
  opts.banner = 'Usage: rate-card [options]'
16
16
  opts.separator ''
17
+ # eHub is the only registered provider today (see RateCard::Providers) — this
18
+ # line should name the provider generically once a second one exists.
17
19
  opts.separator 'Generates a shipping rate card from the eHub production API.'
18
20
  opts.separator 'All rate-card inputs are collected interactively.'
19
21
  opts.separator ''
@@ -48,11 +50,14 @@ end
48
50
  ui = RateCard::UI.new(io: $stdout)
49
51
  ui.banner
50
52
 
53
+ provider_key = RateCard::ProviderPrompt.read(ui: ui)
54
+ provider = RateCard::Providers.build(provider_key)
55
+
51
56
  # Read before the TUI starts. bubbletea-ruby 0.1.4 drops all but the first
52
- # character of a pasted burst, and the token is the one answer that is always
53
- # pasted; out here the terminal's own line editing handles it. See TokenPrompt.
54
- token = RateCard::TokenPrompt.read(ui: ui)
55
- if token.nil?
57
+ # character of a pasted burst, and the credential is the one answer that is
58
+ # always pasted; out here the terminal's own line editing handles it.
59
+ credential = provider.read_credential(ui: ui)
60
+ if credential.nil?
56
61
  ui.info('Cancelled — nothing was built.')
57
62
  exit 0
58
63
  end
@@ -60,7 +65,7 @@ end
60
65
  # The TUI owns the terminal from here until it quits: the catalogue lookup, the
61
66
  # remaining answers and the fetch all happen inside its event loop. Reporting
62
67
  # happens after, on a terminal that has been handed back.
63
- app = RateCard::TUI::App.new(token: token, output_base: options[:output_base])
68
+ app = RateCard::TUI::App.new(token: credential, output_base: options[:output_base], provider: provider)
64
69
  runner = Bubbletea::Runner.new(app)
65
70
  app.notifier = runner
66
71
 
@@ -82,6 +87,8 @@ end
82
87
  case app.error
83
88
  when nil then nil
84
89
  when RateCard::Unauthorized
90
+ # Wording is eHub-specific — fine while eHub is the only registered provider;
91
+ # generalize once a second one exists.
85
92
  ui.error('Production rejected that token. Check it is a current eHub API token.')
86
93
  exit 1
87
94
  when RateCard::NoServices
@@ -65,7 +65,12 @@ module RateCard
65
65
  # calculator, split this back into its own hand-verified hash.
66
66
  DHL_ECOMMERCE = USPS
67
67
 
68
- BY_CARRIER = { 'USPS' => USPS, 'UPS' => UPS, 'FedEx' => FEDEX, 'DHL' => DHL_ECOMMERCE }.freeze
68
+ # OSM's zone calculator in ehub (app/services/carriers/osm/zone_calculator.rb)
69
+ # is likewise an unmodified subclass of USPS's, so OSM zones are USPS
70
+ # zones - same reasoning as DHL_ECOMMERCE above.
71
+ OSM = USPS
72
+
73
+ BY_CARRIER = { 'USPS' => USPS, 'UPS' => UPS, 'FedEx' => FEDEX, 'DHL' => DHL_ECOMMERCE, 'OSM' => OSM }.freeze
69
74
 
70
75
  module_function
71
76
 
@@ -7,7 +7,7 @@ module RateCard
7
7
  module Carriers
8
8
  OTHER = 'Other'
9
9
 
10
- DISPLAY_ORDER = ['USPS', 'UPS', 'FedEx', 'DHL', 'Amazon', OTHER].freeze
10
+ DISPLAY_ORDER = ['USPS', 'UPS', 'FedEx', 'DHL', 'OSM', 'Amazon', OTHER].freeze
11
11
 
12
12
  # Every /services entry carries a lowercase carrier_code. Anything not
13
13
  # listed here keeps its own code, upcased: an unrecognised carrier is
@@ -27,7 +27,7 @@ module RateCard
27
27
  # silently inherit the wrong zone chart.
28
28
  CARRIER_CODES = {
29
29
  'usps' => 'USPS', 'ups' => 'UPS', 'fedex' => 'FedEx',
30
- 'dhl' => 'DHL Express', 'dhl_ecommerce' => 'DHL', 'amazon' => 'Amazon'
30
+ 'dhl' => 'DHL Express', 'dhl_ecommerce' => 'DHL', 'osm' => 'OSM', 'amazon' => 'Amazon'
31
31
  }.freeze
32
32
 
33
33
  module_function
@@ -6,29 +6,27 @@ module RateCard
6
6
  # The fetch engine and the assembled result.
7
7
  #
8
8
  # One call per (weight, zone); each response is harvested for every selected
9
- # service, since eHub returns rates for all enabled services at once. A cell
10
- # whose call failed is nil and is listed in #failures — never 0.0, which in a
11
- # rate table reads as a real free rate.
9
+ # service, via the provider's #parse_rate_response. A cell whose call failed
10
+ # is nil and is listed in #failures — never 0.0, which in a rate table reads
11
+ # as a real free rate.
12
12
  class Grid
13
13
  THREADS = 8
14
-
15
- # eHub answers these as an HTTP 201 "success" with the per-service errors
16
- # field describing a hiccup that clears on its own, so Client's status-code
17
- # retry never sees them. Left alone, the exact same request prices a
18
- # different random set of cells on every run.
19
- TRANSIENT_ERROR_PATTERN = /too many requests|please try again|slow down/i
20
14
  RETRY_BACKOFF = [0.5, 1.0].freeze
21
15
 
22
16
  # on_progress: called with no arguments after each completed call.
23
17
  # retry_sleeper: injected so retry backoff is testable without waiting.
24
- def self.build(spec:, client:, on_progress: nil, retry_sleeper: ->(seconds) { sleep(seconds) })
25
- new(spec).tap { |grid| grid.send(:fetch_all, client, on_progress, retry_sleeper) }
18
+ def self.build(spec:, client:, provider:, on_progress: nil, retry_sleeper: ->(seconds) { sleep(seconds) })
19
+ new(spec, provider).tap { |grid| grid.send(:fetch_all, client, on_progress, retry_sleeper) }
26
20
  end
27
21
 
28
22
  attr_reader :spec
29
23
 
30
- def initialize(spec)
24
+ # provider defaults to nil only for specs that construct a Grid directly to
25
+ # seed @cells as a fixture and never fetch — #build (the real constructor)
26
+ # always supplies one.
27
+ def initialize(spec, provider = nil)
31
28
  @spec = spec
29
+ @provider = provider
32
30
  @cells = {}
33
31
  @failures = []
34
32
  @warnings = Hash.new(0)
@@ -44,36 +42,23 @@ module RateCard
44
42
  @failures.sort_by { |f| [f.weight, f.zone] }
45
43
  end
46
44
 
47
- # What the API said went wrong on calls that otherwise succeeded: the
48
- # response-level `warnings` array and the per-service `errors` field. Both
49
- # arrive with an HTTP 201, so without this a carrier outage would show up as
50
- # a card of blank cells with nothing to explain them.
51
- #
52
- # This card's own services sort first, however loud the account-wide noise
53
- # is: one call rates every service on the token, so a token with 37 services
54
- # enabled can bury the two warnings that explain this card's blank cells
55
- # under thirty about services nobody selected.
56
45
  def warnings
57
46
  @warnings.map { |(message, scope), count| Warning.new(message: message, count: count, scope: scope) }
58
47
  .sort_by { |warning| [scope_rank(warning), -warning.count, warning.message] }
59
48
  end
60
49
 
61
- # True only when no call got through. Deliberately NOT "no cell has a
62
- # value": a run can have every call succeed and still price nothing, if the
63
- # API does not return the selected service (USPS First Class is not priced
64
- # above 13 oz, for instance). Conflating the two would blame the network for
65
- # what is really a service or weight selection problem.
66
50
  def all_failed?
67
51
  @succeeded.zero? && @failures.any?
68
52
  end
69
53
 
70
- # Did any cell actually get a rate? False means we have nothing to write.
71
54
  def any_rates?
72
55
  @cells.values.any?
73
56
  end
74
57
 
75
58
  private
76
59
 
60
+ attr_reader :provider
61
+
77
62
  def fetch_all(client, on_progress, retry_sleeper)
78
63
  Parallel.each(cell_coordinates, in_threads: THREADS) do |weight, zone|
79
64
  fetch_cell(client, weight, zone, retry_sleeper)
@@ -86,12 +71,11 @@ module RateCard
86
71
  end
87
72
 
88
73
  def fetch_cell(client, weight, zone, retry_sleeper)
89
- payload = Shipment.new(spec: spec, weight: weight, address: spec.address_for(zone)).payload
90
- body = fetch_with_transient_retry(client, payload, retry_sleeper)
91
- record_response(body, weight, zone)
74
+ payload = provider.build_payload(spec: spec, weight: weight, address: spec.address_for(zone))
75
+ result = fetch_with_transient_retry(client, payload, retry_sleeper)
76
+ record_response(result, weight, zone)
92
77
  @mutex.synchronize { @succeeded += 1 }
93
78
  rescue Unauthorized
94
- # No later call can succeed; let it abort the whole run.
95
79
  raise
96
80
  rescue StandardError => e
97
81
  @mutex.synchronize do
@@ -103,42 +87,33 @@ module RateCard
103
87
  attempt = 0
104
88
  loop do
105
89
  body = client.fetch_rates(payload)
106
- return body unless transient_error?(body) && attempt < RETRY_BACKOFF.length
90
+ result = provider.parse_rate_response(body, spec: spec)
91
+ return result unless result[:transient] && attempt < RETRY_BACKOFF.length
107
92
 
108
93
  retry_sleeper.call(RETRY_BACKOFF[attempt])
109
94
  attempt += 1
110
95
  end
111
96
  end
112
97
 
113
- # True when a selected service's errors field reads as a transient hiccup
114
- # rather than a real problem with this request.
115
- def transient_error?(body)
116
- by_id = index_by_service_id(body)
117
- spec.services.any? { |service| error_detail(by_id[service.id]) =~ TRANSIENT_ERROR_PATTERN }
118
- end
119
-
120
- def record_response(body, weight, zone)
121
- by_id = index_by_service_id(body)
122
-
98
+ def record_response(result, weight, zone)
123
99
  @mutex.synchronize do
124
- record_warnings(body, by_id)
100
+ record_warnings(result)
125
101
 
126
102
  spec.services.each do |service|
127
- entry = by_id[service.id]
128
103
  spec.rate_keys.each do |rate_key|
129
- field = RunSpec::RATE_KEY_FIELDS.fetch(rate_key)
130
- @cells[[service.id, rate_key, weight, zone]] = coerce(entry && entry[field])
104
+ raw = result[:service_values].dig(service.id, rate_key)
105
+ @cells[[service.id, rate_key, weight, zone]] = coerce(raw)
131
106
  end
132
107
  end
133
108
  end
134
109
  end
135
110
 
136
111
  # Caller holds @mutex.
137
- def record_warnings(body, by_id)
138
- response_warnings(body).each { |message| @warnings[[message, Warning::ACCOUNT]] += 1 }
112
+ def record_warnings(result)
113
+ result[:warnings].each { |message| @warnings[[message, Warning::ACCOUNT]] += 1 }
139
114
 
140
115
  spec.services.each do |service|
141
- detail = error_detail(by_id[service.id])
116
+ detail = result[:service_errors][service.id]
142
117
  next if detail.nil?
143
118
 
144
119
  @warnings[["#{service.name} (#{service.id}): #{detail}", Warning::SERVICE]] += 1
@@ -149,29 +124,6 @@ module RateCard
149
124
  warning.account_wide? ? 1 : 0
150
125
  end
151
126
 
152
- def response_warnings(body)
153
- return [] unless body.is_a?(Hash)
154
-
155
- Array(body['warnings']).map(&:to_s).reject { |message| message.strip.empty? }
156
- end
157
-
158
- # `errors` is documented as populated when a service could not be rated. It
159
- # comes back as a string, but an array is accepted so a list of carrier
160
- # messages reads as one line instead of raising.
161
- def error_detail(entry)
162
- raw = entry && entry['errors']
163
- detail = Array(raw).map(&:to_s).reject { |message| message.strip.empty? }.join('; ')
164
- detail.empty? ? nil : detail
165
- end
166
-
167
- def index_by_service_id(body)
168
- entries = body.is_a?(Hash) ? (body['service_rates'] || []) : []
169
- entries.each_with_object({}) do |entry, acc|
170
- id = entry['service_id']
171
- acc[id.to_i] = entry unless id.nil?
172
- end
173
- end
174
-
175
127
  # nil stays nil. Anything numeric becomes a Float. Never defaults to zero.
176
128
  def coerce(raw)
177
129
  return nil if raw.nil? || raw.to_s.strip.empty?
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RateCard
4
+ # The one thing printed before the credential prompt, so the run says which
5
+ # provider it targets before anything is pasted into it. Selecting among
6
+ # more than one registered provider is not implemented yet, so more than one
7
+ # key raises rather than silently choosing the first.
8
+ module ProviderPrompt
9
+ module_function
10
+
11
+ def read(ui:, keys: Providers.all)
12
+ raise ArgumentError, 'multi-provider selection is not implemented yet' if keys.length > 1
13
+
14
+ key = keys.first
15
+ ui.info("Provider: #{Providers.build(key).label}")
16
+ key
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module RateCard
7
+ module Providers
8
+ module EHub
9
+ # The single network seam. Talks to eHub production and classifies every
10
+ # outcome into: a parsed Hash, Unauthorized, or RequestFailed. Callers never
11
+ # see a Faraday exception.
12
+ #
13
+ # The fetch engine depends on this split: it records a nil cell for each
14
+ # RequestFailed and keeps going, but lets Unauthorized abort the whole run.
15
+ # Narrowing Unauthorized to 403 only would turn a rejected token into a card
16
+ # full of blank cells instead of one clear error — do not do that.
17
+ class Client
18
+ BASE_URL = 'https://api.ehub.com'
19
+ # No trailing slash: that is the documented path, and a trailing slash would
20
+ # rely on a redirect — which can drop the POST body.
21
+ RATES_PATH = '/api/v2/rates'
22
+ SERVICES_PATH = '/api/v2/services'
23
+ # 'ecommerce' services are storefront integrations, not shippable rates.
24
+ SERVICES_CATEGORY = 'shipping'
25
+
26
+ # The rate endpoint answers 201; 200 is accepted too so a change on the
27
+ # server side cannot turn a good response into a hard failure.
28
+ SUCCESS = [200, 201].freeze
29
+ # A rejected token will not improve on retry, so these are never retried.
30
+ # Production answers 403, but 401 stays in the list: if it ever comes back
31
+ # instead, we want one clear error rather than a card full of blank cells.
32
+ UNAUTHORIZED = [401, 403].freeze
33
+ # 429 and 5xx are transient; everything else is a decision, not a hiccup.
34
+ # The gateway codes stay in: 502/503/504 are the load balancer, not the
35
+ # rate engine, and they clear on their own within a backoff or two.
36
+ RETRYABLE = [429, 500, 502, 503, 504].freeze
37
+ BACKOFF = [0.5, 1.0].freeze
38
+
39
+ # stubs: a Faraday::Adapter::Test::Stubs, for specs only.
40
+ # sleeper: injected so retry backoff is testable without waiting.
41
+ def initialize(token:, stubs: nil, sleeper: ->(seconds) { sleep(seconds) })
42
+ @token = token
43
+ @stubs = stubs
44
+ @sleeper = sleeper
45
+ @connection = build_connection
46
+ end
47
+
48
+ # Returns the parsed response body as a Hash.
49
+ def fetch_rates(payload)
50
+ request('rate call') { post(RATES_PATH, payload) }
51
+ end
52
+
53
+ # Returns the parsed body of the service catalogue for this token's customer.
54
+ # Discovery uses this rather than a rate call: it costs nothing, and it lists
55
+ # services that would not have quoted at the single probe weight and zone.
56
+ def fetch_services
57
+ request('service list call') { get(SERVICES_PATH, category: SERVICES_CATEGORY) }
58
+ end
59
+
60
+ private
61
+
62
+ # Shared status handling: every call classifies into a Hash, Unauthorized,
63
+ # or RequestFailed, and retries the transient statuses the same way.
64
+ def request(label)
65
+ attempt = 0
66
+ loop do
67
+ status, body = yield
68
+ return body if SUCCESS.include?(status)
69
+
70
+ if UNAUTHORIZED.include?(status)
71
+ raise Unauthorized,
72
+ "production rejected this token (HTTP #{status}). It may be expired, revoked, " \
73
+ 'or issued for a different environment — generate a new eHub API token and try again.'
74
+ end
75
+
76
+ unless RETRYABLE.include?(status) && attempt < BACKOFF.length
77
+ raise RequestFailed, "#{label} failed with HTTP #{status}#{error_detail(body)}"
78
+ end
79
+
80
+ @sleeper.call(BACKOFF[attempt])
81
+ attempt += 1
82
+ end
83
+ end
84
+
85
+ def post(path, payload)
86
+ response = @connection.post(path, JSON.generate(payload))
87
+ [response.status, parse(response.body)]
88
+ rescue Faraday::Error => e
89
+ raise RequestFailed, "rate call failed: #{e.message}"
90
+ end
91
+
92
+ def get(path, params)
93
+ response = @connection.get(path, params)
94
+ [response.status, parse(response.body)]
95
+ rescue Faraday::Error => e
96
+ raise RequestFailed, "service list call failed: #{e.message}"
97
+ end
98
+
99
+ def parse(body)
100
+ return body if body.is_a?(Hash)
101
+
102
+ JSON.parse(body.to_s)
103
+ rescue JSON::ParserError
104
+ {}
105
+ end
106
+
107
+ def error_detail(body)
108
+ message = body.is_a?(Hash) ? (body['error'] || body['message']) : nil
109
+ message ? " (#{message})" : ''
110
+ end
111
+
112
+ def build_connection
113
+ Faraday.new(url: BASE_URL) do |f|
114
+ f.headers['Authorization'] = "Bearer #{@token}"
115
+ f.headers['Content-Type'] = 'application/json'
116
+ f.options.timeout = 30
117
+ f.options.open_timeout = 10
118
+ if @stubs
119
+ f.adapter :test, @stubs
120
+ else
121
+ f.adapter Faraday.default_adapter
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end