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