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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 45fc9c209efdd18d8586a8cbbb2e02ff0c83c410c2d27c75762dd93999b9276a
|
|
4
|
+
data.tar.gz: a0aaf2c79154dc92586953af3c9a89abfe7997cbab3825ba9188e53bd250c730
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 520df789fc49541ed4254432db087d217ecf66b5367f5d3a25564334fc9ef80712a246e874a309f87ee843cbb73cf5d60656d7e2c31f72c3f30a82e62020c988
|
|
7
|
+
data.tar.gz: 6cfb5ebd7544fb624fac46a6bc8567dd0175814b2f1116cd7ef7d8a111f1b9c099922d0b2cea8b7264b5e12446701214ffe44a66302b368c070b3417566bbf8c
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 eHub
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# rate-card
|
|
2
|
+
|
|
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`.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
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
|
+
```bash
|
|
32
|
+
bundle install
|
|
33
|
+
bundle exec exe/rate-card
|
|
34
|
+
NO_COLOR=1 bundle exec rspec
|
|
35
|
+
```
|
|
36
|
+
|
|
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:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bin/release
|
|
55
|
+
```
|
|
56
|
+
|
|
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
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
bundle exec exe/rate-card
|
|
66
|
+
```
|
|
67
|
+
|
|
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:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
ln -s "$PWD/exe/rate-card" ~/bin/rate-card
|
|
126
|
+
```
|
|
127
|
+
|
|
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
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
NO_COLOR=1 bundle exec rspec
|
|
190
|
+
```
|
|
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
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
|
|
7
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
8
|
+
require 'rate_card'
|
|
9
|
+
|
|
10
|
+
DEFAULT_OUTPUT_BASE = Pathname.new(Dir.home).join('Downloads', 'rate_cards')
|
|
11
|
+
|
|
12
|
+
options = { output_base: DEFAULT_OUTPUT_BASE, show_table: true }
|
|
13
|
+
|
|
14
|
+
parser = OptionParser.new do |opts|
|
|
15
|
+
opts.banner = 'Usage: rate-card [options]'
|
|
16
|
+
opts.separator ''
|
|
17
|
+
opts.separator 'Generates a shipping rate card from the eHub production API.'
|
|
18
|
+
opts.separator 'All rate-card inputs are collected interactively.'
|
|
19
|
+
opts.separator ''
|
|
20
|
+
|
|
21
|
+
opts.on('--output-dir DIR', "Where to save CSVs (default: #{DEFAULT_OUTPUT_BASE})") do |dir|
|
|
22
|
+
options[:output_base] = Pathname.new(File.expand_path(dir))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
opts.on('--no-table', 'Do not print the rate tables to stdout') do
|
|
26
|
+
options[:show_table] = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
opts.on('-v', '--version', 'Print the version') do
|
|
30
|
+
puts RateCard::VERSION
|
|
31
|
+
exit 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
opts.on('-h', '--help', 'Print this message') do
|
|
35
|
+
puts opts
|
|
36
|
+
exit 0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
parser.parse!(ARGV)
|
|
42
|
+
rescue OptionParser::ParseError => e
|
|
43
|
+
warn e.message
|
|
44
|
+
warn parser.help
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
ui = RateCard::UI.new(io: $stdout)
|
|
49
|
+
ui.banner
|
|
50
|
+
|
|
51
|
+
# 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?
|
|
56
|
+
ui.info('Cancelled — nothing was built.')
|
|
57
|
+
exit 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The TUI owns the terminal from here until it quits: the catalogue lookup, the
|
|
61
|
+
# remaining answers and the fetch all happen inside its event loop. Reporting
|
|
62
|
+
# happens after, on a terminal that has been handed back.
|
|
63
|
+
app = RateCard::TUI::App.new(token: token, output_base: options[:output_base])
|
|
64
|
+
runner = Bubbletea::Runner.new(app)
|
|
65
|
+
app.notifier = runner
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
runner.run
|
|
69
|
+
rescue Interrupt
|
|
70
|
+
ui.blank
|
|
71
|
+
ui.info('Cancelled.')
|
|
72
|
+
exit 130
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if app.cancelled?
|
|
76
|
+
ui.info('Cancelled — nothing was built.')
|
|
77
|
+
exit 0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Errors are carried out of the loop rather than raised through it, so the
|
|
81
|
+
# terminal is restored before anything is printed.
|
|
82
|
+
case app.error
|
|
83
|
+
when nil then nil
|
|
84
|
+
when RateCard::Unauthorized
|
|
85
|
+
ui.error('Production rejected that token. Check it is a current eHub API token.')
|
|
86
|
+
exit 1
|
|
87
|
+
when RateCard::NoServices
|
|
88
|
+
ui.error('This token has no shipping services enabled — nothing to build a card from.')
|
|
89
|
+
exit 1
|
|
90
|
+
when RateCard::OutputNotWritable
|
|
91
|
+
ui.error(app.error.message)
|
|
92
|
+
exit 1
|
|
93
|
+
else
|
|
94
|
+
ui.error(app.error.message)
|
|
95
|
+
exit 1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
spec = app.spec
|
|
99
|
+
spec.show_table = options[:show_table]
|
|
100
|
+
|
|
101
|
+
exit RateCard::Runner.new(spec: spec, grid: app.grid, ui: ui).run
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RateCard
|
|
7
|
+
# The single network seam. Talks to eHub production and classifies every
|
|
8
|
+
# outcome into: a parsed Hash, Unauthorized, or RequestFailed. Callers never
|
|
9
|
+
# see a Faraday exception.
|
|
10
|
+
#
|
|
11
|
+
# The fetch engine depends on this split: it records a nil cell for each
|
|
12
|
+
# RequestFailed and keeps going, but lets Unauthorized abort the whole run.
|
|
13
|
+
# Narrowing Unauthorized to 403 only would turn a rejected token into a card
|
|
14
|
+
# full of blank cells instead of one clear error — do not do that.
|
|
15
|
+
class Client
|
|
16
|
+
BASE_URL = 'https://api.ehub.com'
|
|
17
|
+
# No trailing slash: that is the documented path, and a trailing slash would
|
|
18
|
+
# rely on a redirect — which can drop the POST body.
|
|
19
|
+
RATES_PATH = '/api/v2/rates'
|
|
20
|
+
SERVICES_PATH = '/api/v2/services'
|
|
21
|
+
# 'ecommerce' services are storefront integrations, not shippable rates.
|
|
22
|
+
SERVICES_CATEGORY = 'shipping'
|
|
23
|
+
|
|
24
|
+
# The rate endpoint answers 201; 200 is accepted too so a change on the
|
|
25
|
+
# server side cannot turn a good response into a hard failure.
|
|
26
|
+
SUCCESS = [200, 201].freeze
|
|
27
|
+
# A rejected token will not improve on retry, so these are never retried.
|
|
28
|
+
# Production answers 403, but 401 stays in the list: if it ever comes back
|
|
29
|
+
# instead, we want one clear error rather than a card full of blank cells.
|
|
30
|
+
UNAUTHORIZED = [401, 403].freeze
|
|
31
|
+
# 429 and 5xx are transient; everything else is a decision, not a hiccup.
|
|
32
|
+
# The gateway codes stay in: 502/503/504 are the load balancer, not the
|
|
33
|
+
# rate engine, and they clear on their own within a backoff or two.
|
|
34
|
+
RETRYABLE = [429, 500, 502, 503, 504].freeze
|
|
35
|
+
BACKOFF = [0.5, 1.0].freeze
|
|
36
|
+
|
|
37
|
+
# stubs: a Faraday::Adapter::Test::Stubs, for specs only.
|
|
38
|
+
# sleeper: injected so retry backoff is testable without waiting.
|
|
39
|
+
def initialize(token:, stubs: nil, sleeper: ->(seconds) { sleep(seconds) })
|
|
40
|
+
@token = token
|
|
41
|
+
@stubs = stubs
|
|
42
|
+
@sleeper = sleeper
|
|
43
|
+
@connection = build_connection
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns the parsed response body as a Hash.
|
|
47
|
+
def fetch_rates(payload)
|
|
48
|
+
request('rate call') { post(RATES_PATH, payload) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the parsed body of the service catalogue for this token's customer.
|
|
52
|
+
# Discovery uses this rather than a rate call: it costs nothing, and it lists
|
|
53
|
+
# services that would not have quoted at the single probe weight and zone.
|
|
54
|
+
def fetch_services
|
|
55
|
+
request('service list call') { get(SERVICES_PATH, category: SERVICES_CATEGORY) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Shared status handling: every call classifies into a Hash, Unauthorized,
|
|
61
|
+
# or RequestFailed, and retries the transient statuses the same way.
|
|
62
|
+
def request(label)
|
|
63
|
+
attempt = 0
|
|
64
|
+
loop do
|
|
65
|
+
status, body = yield
|
|
66
|
+
return body if SUCCESS.include?(status)
|
|
67
|
+
|
|
68
|
+
if UNAUTHORIZED.include?(status)
|
|
69
|
+
raise Unauthorized,
|
|
70
|
+
"production rejected this token (HTTP #{status}). It may be expired, revoked, " \
|
|
71
|
+
'or issued for a different environment — generate a new eHub API token and try again.'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
unless RETRYABLE.include?(status) && attempt < BACKOFF.length
|
|
75
|
+
raise RequestFailed, "#{label} failed with HTTP #{status}#{error_detail(body)}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@sleeper.call(BACKOFF[attempt])
|
|
79
|
+
attempt += 1
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def post(path, payload)
|
|
84
|
+
response = @connection.post(path, JSON.generate(payload))
|
|
85
|
+
[response.status, parse(response.body)]
|
|
86
|
+
rescue Faraday::Error => e
|
|
87
|
+
raise RequestFailed, "rate call failed: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def get(path, params)
|
|
91
|
+
response = @connection.get(path, params)
|
|
92
|
+
[response.status, parse(response.body)]
|
|
93
|
+
rescue Faraday::Error => e
|
|
94
|
+
raise RequestFailed, "service list call failed: #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parse(body)
|
|
98
|
+
return body if body.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
JSON.parse(body.to_s)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
{}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def error_detail(body)
|
|
106
|
+
message = body.is_a?(Hash) ? (body['error'] || body['message']) : nil
|
|
107
|
+
message ? " (#{message})" : ''
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def build_connection
|
|
111
|
+
Faraday.new(url: BASE_URL) do |f|
|
|
112
|
+
f.headers['Authorization'] = "Bearer #{@token}"
|
|
113
|
+
f.headers['Content-Type'] = 'application/json'
|
|
114
|
+
f.options.timeout = 30
|
|
115
|
+
f.options.open_timeout = 10
|
|
116
|
+
if @stubs
|
|
117
|
+
f.adapter :test, @stubs
|
|
118
|
+
else
|
|
119
|
+
f.adapter Faraday.default_adapter
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
module Constants
|
|
5
|
+
# Hand-verified destination addresses, one per zone per carrier. Zone
|
|
6
|
+
# membership is a property of a real address and a real carrier's zone chart,
|
|
7
|
+
# so these are curated by hand and never derived at runtime.
|
|
8
|
+
#
|
|
9
|
+
# Ported from ../rate_table_builder/constants/address_constants.rb.
|
|
10
|
+
#
|
|
11
|
+
# One deliberate normalisation: that file spells 130 Braewick Rd / 84103 as
|
|
12
|
+
# 'SLC' in its USPS table and 'Salt Lake City' in its UPS table. We use
|
|
13
|
+
# 'Salt Lake City' in both. Zone membership follows the postal code, and we
|
|
14
|
+
# send validation_level 'basic', which some address validators fail on the
|
|
15
|
+
# abbreviation. Do not "restore" 'SLC'.
|
|
16
|
+
module Addresses
|
|
17
|
+
ORIGIN = {
|
|
18
|
+
company: 'Rate Card Builder',
|
|
19
|
+
address1: '9490 S 300 W',
|
|
20
|
+
city: 'Sandy',
|
|
21
|
+
state: 'UT',
|
|
22
|
+
postal_code: '84070',
|
|
23
|
+
country: 'US',
|
|
24
|
+
phone: '000-000-0000'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
USPS = {
|
|
28
|
+
1 => { address1: '1206 W 9440 S', city: 'South Jordan', state: 'UT', postal_code: '84094', country: 'US' },
|
|
29
|
+
2 => { address1: '130 Braewick Rd', city: 'Salt Lake City', state: 'UT', postal_code: '84103', country: 'US' },
|
|
30
|
+
3 => { address1: '1526 21st St', city: 'Cody', state: 'WY', postal_code: '82414', country: 'US' },
|
|
31
|
+
4 => { address1: '1954 E Fountain St', city: 'Mesa', state: 'AZ', postal_code: '85203', country: 'US' },
|
|
32
|
+
5 => { address1: '2909 Woodland Ave', city: 'West Des Moines', state: 'IA', postal_code: '50266', country: 'US' },
|
|
33
|
+
6 => { address1: '19813 Crescent Ave', city: 'Lynwood', state: 'IL', postal_code: '60411', country: 'US' },
|
|
34
|
+
7 => { address1: '922 Maple Ave', city: 'Pittsburgh', state: 'PA', postal_code: '15234', country: 'US' },
|
|
35
|
+
8 => { address1: '3149 Brickell Ave', city: 'Miami', state: 'FL', postal_code: '33129', country: 'US' }
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
UPS = {
|
|
39
|
+
1 => { address1: '9121 S Village Shop D', city: 'Sandy', state: 'UT', postal_code: '84094', country: 'US' },
|
|
40
|
+
2 => { address1: '130 Braewick Rd', city: 'Salt Lake City', state: 'UT', postal_code: '84103', country: 'US' },
|
|
41
|
+
3 => { address1: '1526 21st St', city: 'Cody', state: 'WY', postal_code: '82414', country: 'US' },
|
|
42
|
+
4 => { address1: '1954 E Fountain St', city: 'Mesa', state: 'AZ', postal_code: '85203', country: 'US' },
|
|
43
|
+
5 => { address1: '2909 Woodland Ave', city: 'West Des Moines', state: 'IA', postal_code: '50266', country: 'US' },
|
|
44
|
+
6 => { address1: '19813 Crescent Ave', city: 'Lynwood', state: 'IL', postal_code: '60411', country: 'US' },
|
|
45
|
+
7 => { address1: '922 Maple Ave', city: 'Pittsburgh', state: 'PA', postal_code: '15234', country: 'US' },
|
|
46
|
+
8 => { address1: '3149 Brickell Ave', city: 'Miami', state: 'FL', postal_code: '33129', country: 'US' }
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
FEDEX = {
|
|
50
|
+
1 => { address1: '50 S Main Street', city: 'Salt Lake City', state: 'UT', postal_code: '84101', country: 'US' },
|
|
51
|
+
2 => { address1: '1700 Lincoln Street', city: 'Denver', state: 'CO', postal_code: '80203', country: 'US' },
|
|
52
|
+
3 => { address1: '411 E Wisconsin Avenue', city: 'Phoenix', state: 'AZ', postal_code: '85004', country: 'US' },
|
|
53
|
+
4 => { address1: '1201 Elm Street', city: 'Dallas', state: 'TX', postal_code: '75270', country: 'US' },
|
|
54
|
+
5 => { address1: '233 S Wacker Drive', city: 'Chicago', state: 'IL', postal_code: '60606', country: 'US' },
|
|
55
|
+
6 => { address1: '191 Peachtree Street NE', city: 'Atlanta', state: 'GA', postal_code: '30303', country: 'US' },
|
|
56
|
+
7 => { address1: '350 Fifth Avenue', city: 'New York', state: 'NY', postal_code: '10118', country: 'US' },
|
|
57
|
+
8 => { address1: '1 Beacon Street', city: 'Boston', state: 'MA', postal_code: '02108', country: 'US' }
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
# DHL eCommerce's zone calculator (ehub's
|
|
61
|
+
# app/services/carriers/dhl_ecommerce/zone_calculator.rb) is an
|
|
62
|
+
# unmodified subclass of USPS's zone calculator, so DHL eCommerce
|
|
63
|
+
# zones are USPS zones. This is the same chart, not a coincidence to
|
|
64
|
+
# keep in sync by hand - if DHL eCommerce ever gets its own zone
|
|
65
|
+
# calculator, split this back into its own hand-verified hash.
|
|
66
|
+
DHL_ECOMMERCE = USPS
|
|
67
|
+
|
|
68
|
+
BY_CARRIER = { 'USPS' => USPS, 'UPS' => UPS, 'FedEx' => FEDEX, 'DHL' => DHL_ECOMMERCE }.freeze
|
|
69
|
+
|
|
70
|
+
module_function
|
|
71
|
+
|
|
72
|
+
# Raises rather than falling back. The old USPS default meant asking for
|
|
73
|
+
# a carrier we have no chart for produced a full, plausible-looking card
|
|
74
|
+
# priced to USPS zone addresses and labeled Z1-Z8 — wrong in a way
|
|
75
|
+
# nothing downstream could detect. A missing chart is a missing chart.
|
|
76
|
+
def for_carrier(carrier)
|
|
77
|
+
BY_CARRIER.fetch(carrier.to_s) do
|
|
78
|
+
raise UnsupportedCarrier,
|
|
79
|
+
"no curated zone chart for #{carrier} — rate cards are only " \
|
|
80
|
+
"available for #{supported_carriers.join(', ')}. Adding one means " \
|
|
81
|
+
"hand-verifying a destination address per zone against that " \
|
|
82
|
+
'carrier\'s own zone chart and adding it to Addresses::BY_CARRIER.'
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def supported?(carrier)
|
|
87
|
+
BY_CARRIER.key?(carrier.to_s)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def supported_carriers
|
|
91
|
+
BY_CARRIER.keys
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# [] for a carrier with no chart, so the wizard can ask about zones
|
|
95
|
+
# without having to rescue.
|
|
96
|
+
def available_zones(carrier)
|
|
97
|
+
supported?(carrier) ? for_carrier(carrier).keys.sort : []
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
module Constants
|
|
5
|
+
# Turns the carrier_code that /services reports into the display name the
|
|
6
|
+
# wizard groups by.
|
|
7
|
+
module Carriers
|
|
8
|
+
OTHER = 'Other'
|
|
9
|
+
|
|
10
|
+
DISPLAY_ORDER = ['USPS', 'UPS', 'FedEx', 'DHL', 'Amazon', OTHER].freeze
|
|
11
|
+
|
|
12
|
+
# Every /services entry carries a lowercase carrier_code. Anything not
|
|
13
|
+
# listed here keeps its own code, upcased: an unrecognised carrier is
|
|
14
|
+
# still better named by itself than lumped into 'Other'.
|
|
15
|
+
#
|
|
16
|
+
# DHL eCommerce reaches /services as 'dhl_ecommerce' and maps to 'DHL'
|
|
17
|
+
# here, aliased to Addresses::USPS because DHL eCommerce's zone
|
|
18
|
+
# calculator in the Rails monolith is a verified unmodified subclass of
|
|
19
|
+
# USPS's zone calculator. The bare 'dhl' code is DHL Express, a distinct
|
|
20
|
+
# carrier with unverified zones, so it gets its own display name
|
|
21
|
+
# ('DHL Express') rather than sharing 'DHL' — with no chart of its own
|
|
22
|
+
# in Addresses, it correctly raises UnsupportedCarrier instead of
|
|
23
|
+
# silently inheriting USPS's zones.
|
|
24
|
+
# Do not add another DHL code to the 'DHL' key unless its zones are
|
|
25
|
+
# likewise verified identical to USPS's — otherwise it needs its own
|
|
26
|
+
# distinct display name here AND its own chart in Addresses, or it will
|
|
27
|
+
# silently inherit the wrong zone chart.
|
|
28
|
+
CARRIER_CODES = {
|
|
29
|
+
'usps' => 'USPS', 'ups' => 'UPS', 'fedex' => 'FedEx',
|
|
30
|
+
'dhl' => 'DHL Express', 'dhl_ecommerce' => 'DHL', 'amazon' => 'Amazon'
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# OTHER is only for a malformed entry with no code at all, so a service is
|
|
36
|
+
# still offered rather than dropped or left with a nil carrier.
|
|
37
|
+
def for_carrier_code(code)
|
|
38
|
+
key = code.to_s.strip.downcase
|
|
39
|
+
return OTHER if key.empty?
|
|
40
|
+
|
|
41
|
+
CARRIER_CODES.fetch(key) { key.upcase }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def display_order
|
|
45
|
+
DISPLAY_ORDER
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|