spree_easypost 6.0.0.beta1

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: ad5ff952bf4c6793d867e260b9061c3e19a9efbd5e4383a4bcf0d2522f387ee0
4
+ data.tar.gz: c5c0c74919b84867fa54e8c9b4e56bda8e7e9fe6c2fb7f90e5d5792ac4c61f4e
5
+ SHA512:
6
+ metadata.gz: d65fa05b70d3a3b933cf6a9f46275ba1e02022c58e0e18cb3a0b2db587d1299f644fe0e22b79122bc78554a93122de7fcfcbab4257a85b17b1fc598ce3871fef
7
+ data.tar.gz: 720dfa79747c360ef893c9b860d0283e6869c91d65f20a102a0e383d085177ffac0df68de3c110243d02c79c18961f5257cc7c4c77f3c2ebc76b250b0b78e6bd
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present, Vendo Sp. z o.o., Vendo Connect Inc.
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # spree_easypost
2
+
3
+ Live multi-carrier delivery rates for Spree via [EasyPost](https://www.easypost.com) — the reference implementation of the `Spree::DeliveryRateProvider` interface, built on the official [`easypost`](https://github.com/EasyPost/easypost-ruby) Ruby SDK.
4
+
5
+ ## What it does
6
+
7
+ - **`SpreeEasyPost::Integration`** holds the store's EasyPost API key (a masked `:password` preference), managed from the dashboard's Settings → Integrations page. The key decides the mode — EasyPost issues separate test and production keys.
8
+ - **`SpreeEasyPost::DeliveryRateProvider`** quotes live at checkout. One delivery method is the carrier connection: every service EasyPost returns for the address becomes its own named rate ("UPS Ground", "USPS Priority Mail", …) carrying the carrier, service level and estimated delivery date. The method's service rows optionally narrow which services are offered, rename them, or add a markup per service; with no rows, everything EasyPost returns is offered and the method-level markup applies. One EasyPost API call per package, however many services come back.
9
+ - **`SpreeEasyPost::FulfillmentProvider`** buys the label when a fulfillment ships — the rate quoted at checkout when still valid, otherwise a fresh quote bought only for the exact carrier service the customer selected. Tracking lands on the fulfillment, the label is served via `documents`, and cancelling files a refund request. A purchase failure never blocks shipping — it is reported and the admin buys the label manually.
10
+
11
+ ## Setup
12
+
13
+ 1. Add the gem: `bundle add spree_easypost`
14
+ 2. In the admin dashboard, open **Settings → Integrations**, connect **EasyPost**, and activate it (activation verifies the key against the EasyPost API).
15
+ 3. Create ONE delivery method (**Settings → Delivery methods**) with **EasyPost** as its rate and fulfillment provider. The service picker lists what your EasyPost account can actually quote — read from a throwaway quote rather than the carrier-accounts endpoint, which is production-only and reports no service levels. That's enough — customers see every service EasyPost quotes. Optionally narrow the offered services, rename them ("UPS 1 day"), or add a handling-fee markup, per service or method-wide.
16
+
17
+ Methods without the EasyPost provider keep pricing through their calculator — the two coexist freely per store.
18
+
19
+ ## Notes
20
+
21
+ - Weights are converted to ounces from the store's unit system (imperial → pounds, metric → grams).
22
+ - The gallery card's logo is the official hosted EasyPost asset (`logo_url` — no asset pipeline involved; the dashboard falls back to a letter avatar if unreachable) and its description lives in `config/locales/`, resolved per the store's locale.
23
+
24
+ ## Testing
25
+
26
+ ```bash
27
+ cd spree/providers/easypost
28
+ bundle install
29
+ bundle exec rake test_app
30
+ bundle exec rspec
31
+ ```
32
+
33
+ The suite runs offline: unit specs stub the client, and the API-contract specs replay recorded HTTP through VCR (`spec/vcr/`). To re-record the cassettes against the live EasyPost test API, delete them and run the suite with `EASYPOST_TEST_API_KEY` set — the key is filtered out of recordings.
34
+
35
+ For a quick live sanity check outside the suite:
36
+
37
+ ```bash
38
+ EASYPOST_TEST_API_KEY=EZTK... bin/smoke
39
+ ```
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'rake/testtask'
4
+ require 'rspec/core/rake_task'
5
+ require 'spree/testing_support/common_rake'
6
+
7
+ RSpec::Core::RakeTask.new
8
+
9
+ task default: :spec
10
+
11
+ desc "Generates a dummy app for testing"
12
+ task :test_app do
13
+ ENV['LIB_NAME'] = 'spree_easypost'
14
+ Rake::Task['common:test_app'].invoke
15
+ end
@@ -0,0 +1,135 @@
1
+ module SpreeEasyPost
2
+ # Quotes a delivery method from EasyPost. One method is the carrier
3
+ # connection: every service EasyPost returns for the address becomes its
4
+ # own rate ("UPS Ground", "USPS Priority", ...), and the method's service
5
+ # rows narrow, rename or mark up individual services. Methods sharing this
6
+ # provider within a request reuse one shipment-create API call.
7
+ class DeliveryRateProvider < Spree::DeliveryRateProvider::Base
8
+ def self.integration_class
9
+ 'SpreeEasyPost::Integration'
10
+ end
11
+
12
+ def self.provider_name
13
+ SpreeEasyPost::PROVIDER_NAME
14
+ end
15
+
16
+ # EasyPost quotes real parcel shipments, so it only prices methods that
17
+ # ship to an address.
18
+ def self.requires_address?
19
+ true
20
+ end
21
+
22
+ # Domestic destination + nominal parcel for the service-listing probe
23
+ # quote; never charged, nothing to clean up.
24
+ PROBE_DESTINATION = {
25
+ street1: '179 N Harbor Dr',
26
+ city: 'Redondo Beach',
27
+ state: 'CA',
28
+ zip: '90277',
29
+ country: 'US',
30
+ name: 'Service catalog probe'
31
+ }.freeze
32
+ PROBE_OUNCES = 16
33
+
34
+ # The carrier services this account can actually sell.
35
+ #
36
+ # Read from a throwaway quote rather than the carrier-accounts endpoint:
37
+ # the latter is production-only (a test key is rejected outright), while
38
+ # a quote works on both key tiers and reports carrier names exactly as
39
+ # they arrive on real rates — which is what service rows must match.
40
+ # Costs one API call, made only when an admin opens the picker.
41
+ def self.service_catalog(integration)
42
+ return Spree::DeliveryRateProvider::ServiceCatalog.none if integration.nil?
43
+
44
+ shipment = integration.client.shipment.create(
45
+ from_address: SpreeEasyPost::Integration::VERIFICATION_ADDRESS,
46
+ to_address: PROBE_DESTINATION,
47
+ parcel: { weight: PROBE_OUNCES }
48
+ )
49
+ Spree::DeliveryRateProvider::ServiceCatalog.listing(services_from(shipment.rates))
50
+ rescue StandardError => e
51
+ Spree::DeliveryRateProvider::ServiceCatalog.unavailable(e.message)
52
+ end
53
+
54
+ # One entry per (carrier, service) the probe quoted, deduplicated and
55
+ # ordered for a stable picker.
56
+ def self.services_from(rates)
57
+ rates.map { |rate| [rate.carrier, rate.service] }.uniq.sort.map do |carrier, service|
58
+ { carrier: carrier, service: service, label: "#{carrier} #{service.to_s.titleize}" }
59
+ end
60
+ end
61
+ private_class_method :services_from
62
+
63
+ # A rating failure must never break checkout: any API error suppresses
64
+ # this method (the customer still sees calculator-priced options) and is
65
+ # reported for observability rather than raised into the rate refresh.
66
+ #
67
+ # @param package [Spree::Stock::Package]
68
+ # @return [Array<Spree::DeliveryRateProvider::Estimate>]
69
+ def estimates(package)
70
+ return [] if integration.nil?
71
+ return [] if package.owner&.ship_address.nil?
72
+
73
+ shipment = begin
74
+ easypost_shipment(package)
75
+ rescue StandardError => e
76
+ Rails.error.report(e, context: { delivery_method_id: delivery_method.id }, source: 'spree_easypost.rating')
77
+ nil
78
+ end
79
+ return [] if shipment.nil?
80
+
81
+ rates = Array(shipment.rates)
82
+ log_rating_failures(shipment) if rates.empty?
83
+
84
+ rates.map do |rate|
85
+ Spree::DeliveryRateProvider::Estimate.new(
86
+ cost: rate.rate,
87
+ currency: rate.currency,
88
+ carrier: rate.carrier,
89
+ service_level: rate.service,
90
+ estimated_delivery_date: rate.delivery_date,
91
+ metadata: { 'easypost_rate_id' => rate.id, 'easypost_shipment_id' => rate.shipment_id }
92
+ )
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ # EasyPost answers 201 with an empty rate list and per-carrier rate_error
99
+ # messages when it cannot quote (incomplete origin address, unsupported
100
+ # lane, oversize parcel). Without surfacing them, the method silently
101
+ # vanishing from checkout is undiagnosable from the outside.
102
+ def log_rating_failures(shipment)
103
+ # The carrier account is part of the identity: the same carrier can fail
104
+ # differently per linked account, and deduplicating on carrier alone
105
+ # would hide one of them.
106
+ reasons = Array(shipment.messages).map do |message|
107
+ carrier = [message.carrier, message.try(:carrier_account_id)].compact.join('/')
108
+ "#{carrier}: #{message.message}"
109
+ end.uniq
110
+ reasons = ['no rates returned and no carrier messages given'] if reasons.empty?
111
+
112
+ Rails.logger.warn(
113
+ "[SpreeEasyPost] no rates for delivery method '#{delivery_method.name}' " \
114
+ "(shipment #{shipment.id}): #{reasons.join(' | ')}"
115
+ )
116
+ end
117
+
118
+ # One shipment-create returns rates for every carrier and service, so all
119
+ # delivery methods sharing this provider within a request reuse it.
120
+ def easypost_shipment(package)
121
+ cache_key = [:easypost_shipment, store.id, package.stock_location.id, package.owner.id]
122
+ Spree::Current.provider_cache[cache_key] ||= integration.client.shipment.create(
123
+ **shipment_params(package)
124
+ )
125
+ end
126
+
127
+ # Built by the shared helper so a label bought against this quote carries
128
+ # exactly the customs form and duty terms the quote was priced with.
129
+ def shipment_params(package)
130
+ SpreeEasyPost.shipment_params(
131
+ package, package.stock_location, package.owner.ship_address, integration, store
132
+ )
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,305 @@
1
+ module SpreeEasyPost
2
+ # Buys and refunds EasyPost labels for outbound parcels and returns
3
+ # (docs/plans/6.0-shipping-labels-and-deliveries.md). Core records what
4
+ # was bought as a Spree::ShippingLabel and never asks twice for a parcel
5
+ # that already holds one, so nothing here remembers a purchase — the label
6
+ # row does.
7
+ #
8
+ # Nothing here may raise: a purchase failure is reported and answered with
9
+ # nil, which the explicit buy-label step surfaces as a 422 and the one-click
10
+ # fulfill degrades to "no label yet".
11
+ class FulfillmentProvider < Spree::FulfillmentProvider::Base
12
+ # EasyPost carrier names → Spree.tracking_carriers keys, so a bought label
13
+ # gets the same badge and tracking page a hand-entered number would.
14
+ CARRIER_KEYS = {
15
+ 'USPS' => 'usps',
16
+ 'UPS' => 'ups',
17
+ 'FedEx' => 'fedex',
18
+ 'FedExDefault' => 'fedex',
19
+ 'DHLExpress' => 'dhl',
20
+ 'DhlEcs' => 'dhl',
21
+ 'DPD' => 'dpd',
22
+ 'GLS' => 'gls',
23
+ 'RoyalMail' => 'royal_mail',
24
+ 'CanadaPost' => 'canada_post',
25
+ 'AustraliaPost' => 'australia_post'
26
+ }.freeze
27
+
28
+ LABEL_FORMATS = {
29
+ 'application/pdf' => 'pdf',
30
+ 'image/png' => 'png',
31
+ 'application/zpl' => 'zpl',
32
+ 'text/plain' => 'zpl'
33
+ }.freeze
34
+
35
+ def self.integration_class
36
+ 'SpreeEasyPost::Integration'
37
+ end
38
+
39
+ def self.provider_name
40
+ SpreeEasyPost::PROVIDER_NAME
41
+ end
42
+
43
+ def self.generates_labels?
44
+ true
45
+ end
46
+
47
+ # An outbound parcel buys the rate quoted at checkout when it is still
48
+ # valid; EasyPost quotes expire, so a stale or missing quote is re-quoted
49
+ # from the actual fulfillment and bought only when a rate for the exact
50
+ # carrier/service the customer selected comes back — silently shipping a
51
+ # different service than the customer paid for is worse than no label.
52
+ #
53
+ # A return buys an inbound shipment from the address the order shipped
54
+ # to back to the return's stock location, at the cheapest rate the
55
+ # account offers: return postage is the merchant's own money and no
56
+ # service was ever chosen for it.
57
+ #
58
+ # @param owner [Spree::Fulfillment, Spree::Return]
59
+ # @return [Spree::LabelPurchase, nil]
60
+ def purchase_label(owner)
61
+ integration = integration_for(owner)
62
+ return if integration.nil?
63
+
64
+ shipment =
65
+ if owner.is_a?(Spree::Return)
66
+ buy_return(integration, owner)
67
+ else
68
+ buy_quoted(integration, owner) || requote_and_buy(integration, owner)
69
+ end
70
+ return if shipment.nil?
71
+
72
+ label_purchase(shipment)
73
+ rescue EasyPost::Errors::EasyPostError => e
74
+ # EasyPost says exactly what is wrong — an incomplete origin address, an
75
+ # unserviceable destination — and the merchant is the only one who can
76
+ # fix it. Passed through rather than reported as a connection problem.
77
+ report(e, owner)
78
+ raise Spree::Core::LabelPurchaseRefused, e.message
79
+ rescue Spree::Core::LabelPurchaseRefused
80
+ # Already the merchant's answer, raised by name from further in. It is a
81
+ # StandardError, so without this it would be swallowed as nil below and
82
+ # the buy would fail with no reason given.
83
+ raise
84
+ rescue StandardError => e
85
+ report(e, owner)
86
+ nil
87
+ end
88
+
89
+ # Files the refund with EasyPost. USPS refunds settle later and come
90
+ # back as `submitted`; commercial carriers usually answer at once.
91
+ #
92
+ # @param shipping_label [Spree::ShippingLabel]
93
+ # @return [String, false]
94
+ def refund_label(shipping_label)
95
+ # The account that sold the label, not whichever one the parcel's
96
+ # current method points at — a rerouted parcel still owes its postage
97
+ # back to the carrier it was bought from.
98
+ integration = shipping_label.integration || integration_for(shipping_label.owner)
99
+ return false if integration.nil? || shipping_label.external_id.blank?
100
+
101
+ shipment = integration.client.shipment.refund(shipping_label.external_id)
102
+ refund_outcome(shipment.try(:refund_status))
103
+ rescue EasyPost::Errors::EasyPostError => e
104
+ # A parcel already in the carrier's hands, a label too old to void:
105
+ # EasyPost says which, and only the merchant can act on it.
106
+ report(e, shipping_label.owner)
107
+ raise Spree::Core::LabelRefundRefused, e.message
108
+ rescue StandardError => e
109
+ report(e, shipping_label.owner)
110
+ false
111
+ end
112
+
113
+ # Dispatch is the label; there is nothing else to tell EasyPost.
114
+ def create_fulfillment(_fulfillment)
115
+ {}
116
+ end
117
+
118
+ def cancel_fulfillment(_fulfillment)
119
+ true
120
+ end
121
+
122
+ # The tracker page EasyPost hosts for the delivery's label.
123
+ #
124
+ # @param delivery [Spree::Delivery]
125
+ # @return [String, nil]
126
+ def tracking_url(delivery)
127
+ delivery&.shipping_label&.metadata&.dig('easypost_tracker_url')
128
+ end
129
+
130
+ # Customs forms and commercial invoices the purchase produced. The label
131
+ # itself is the Spree::ShippingLabel, never listed here.
132
+ #
133
+ # @param owner [Spree::Fulfillment, Spree::Return]
134
+ # @return [Array<Spree::ShippingDocument>]
135
+ def documents(owner)
136
+ # Read from the loaded association rather than re-scoping it: this is
137
+ # called once per parcel while serializing an order, and a fresh query
138
+ # each time defeats the controller's preload.
139
+ label = owner.shipping_labels.detect { |candidate| !candidate.refunded? }
140
+ forms = label&.metadata&.dig('easypost_forms')
141
+ Array(forms).filter_map do |form|
142
+ next if form['url'].blank?
143
+
144
+ Spree::ShippingDocument.new(kind: form['form_type'].presence || 'form', url: form['url'])
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def buy_quoted(integration, fulfillment)
151
+ quoted = fulfillment.selected_delivery_rate&.metadata || {}
152
+ shipment_id = quoted['easypost_shipment_id']
153
+ rate_id = quoted['easypost_rate_id']
154
+ return if shipment_id.blank? || rate_id.blank?
155
+
156
+ buy(integration, fulfillment.stock_location, fulfillment.store, shipment_id, rate_id)
157
+ rescue EasyPost::Errors::EasyPostError
158
+ nil
159
+ end
160
+
161
+ def requote_and_buy(integration, fulfillment)
162
+ # The selected rate carries the carrier service the customer chose —
163
+ # the delivery method no longer pins one (it offers many).
164
+ selected = fulfillment.selected_delivery_rate
165
+ address = fulfillment.address || fulfillment.order&.ship_address
166
+
167
+ # Each of these is the merchant's to fix, so each says so rather than
168
+ # failing as "check the carrier connection".
169
+ raise Spree::Core::LabelPurchaseRefused, Spree.t('easypost.errors.no_destination') if address.nil?
170
+
171
+ if selected&.carrier.blank? || selected.service_level.blank?
172
+ raise Spree::Core::LabelPurchaseRefused, Spree.t('easypost.errors.rate_not_quoted')
173
+ end
174
+
175
+ shipment = integration.client.shipment.create(
176
+ **SpreeEasyPost.shipment_params(
177
+ fulfillment.to_package, fulfillment.stock_location, address, integration, fulfillment.store
178
+ )
179
+ )
180
+ rate = shipment.rates.find do |candidate|
181
+ candidate.carrier == selected.carrier && candidate.service == selected.service_level
182
+ end
183
+
184
+ # Silently buying a different service than the customer paid for is
185
+ # worse than no label, so this refuses and names the service.
186
+ if rate.nil?
187
+ raise Spree::Core::LabelPurchaseRefused,
188
+ Spree.t('easypost.errors.service_unavailable', service: "#{selected.carrier} #{selected.service_level}")
189
+ end
190
+
191
+ buy(integration, fulfillment.stock_location, fulfillment.store, shipment.id, rate.id)
192
+ end
193
+
194
+ def buy_return(integration, return_record)
195
+ address = return_record.ship_from_address
196
+ return if address.nil? || return_record.stock_location.nil?
197
+
198
+ params = SpreeEasyPost.shipment_params(
199
+ return_record.to_package, address, return_record.stock_location, integration, return_record.store
200
+ )
201
+ shipment = integration.client.shipment.create(**params, is_return: true)
202
+ rate = EasyPost::Util.get_lowest_object_rate(shipment)
203
+ return if rate.nil?
204
+
205
+ buy(integration, return_record.stock_location, return_record.store, shipment.id, rate.id)
206
+ end
207
+
208
+ # Labels bought on EasyPost's own carrier accounts (USPS) require an
209
+ # EndShipper — the legally responsible shipping party. Created fresh per
210
+ # purchase so it always matches the current warehouse address; label buys
211
+ # are rare enough that caching one would only buy staleness.
212
+ def buy(integration, stock_location, store, shipment_id, rate_id)
213
+ buy_params = { rate: { id: rate_id } }
214
+ end_shipper_id = end_shipper_id(integration, stock_location, store)
215
+ buy_params[:end_shipper_id] = end_shipper_id if end_shipper_id.present?
216
+
217
+ integration.client.shipment.buy(shipment_id, **buy_params)
218
+ end
219
+
220
+ # Both ways this can fail are the merchant's to fix, so both say what to
221
+ # fix and where. Buying without an end shipper was the alternative, and a
222
+ # carrier that requires one answers that with a bare "malformed syntax"
223
+ # naming no field at all.
224
+ # Whatever the warehouse can supply is sent and EasyPost judges it: what
225
+ # it requires here is its rule to change, and a local copy of that list
226
+ # would quietly disagree with the real one the first time it moved. Its
227
+ # answer names the field ("Phone number is empty"), which is what the
228
+ # merchant needs, so it is passed through with the location that owns it.
229
+ def end_shipper_id(integration, stock_location, store)
230
+ params = SpreeEasyPost.end_shipper_params(stock_location, store)
231
+ return if params.nil?
232
+
233
+ integration.client.end_shipper.create(**params).id
234
+ rescue EasyPost::Errors::EasyPostError => e
235
+ report(e, stock_location)
236
+ raise Spree::Core::LabelPurchaseRefused,
237
+ Spree.t('easypost.errors.origin_address_refused',
238
+ location: location_name(stock_location), reason: carrier_reason(e))
239
+ end
240
+
241
+ # EasyPost's summary line is often generic ("Missing required parameter")
242
+ # while the field it means sits in the errors array, so the detail is
243
+ # preferred and the summary is the fallback.
244
+ def carrier_reason(error)
245
+ details = Array(error.try(:errors)).filter_map do |detail|
246
+ next detail if detail.is_a?(String)
247
+
248
+ field = detail['field'].to_s.split('.').last
249
+ [field.presence&.humanize, detail['message']].compact_blank.join(' ')
250
+ end
251
+
252
+ details.compact_blank.presence&.to_sentence || error.message
253
+ end
254
+
255
+ def location_name(stock_location)
256
+ stock_location.try(:name).presence || stock_location.try(:address1)
257
+ end
258
+
259
+ def label_purchase(shipment)
260
+ rate = shipment.try(:selected_rate)
261
+ postage_label = shipment.try(:postage_label)
262
+ tracker = shipment.try(:tracker)
263
+
264
+ Spree::LabelPurchase.new(
265
+ external_id: shipment.id,
266
+ carrier: carrier_key(rate&.carrier),
267
+ service: rate&.service,
268
+ tracking_number: shipment.tracking_code,
269
+ tracking_url: tracker&.public_url,
270
+ cost: rate&.rate.to_d,
271
+ currency: rate&.currency,
272
+ format: LABEL_FORMATS[postage_label&.label_file_type.to_s],
273
+ file_url: postage_label&.label_url,
274
+ metadata: {
275
+ 'easypost_tracker_id' => tracker&.id,
276
+ 'easypost_tracker_url' => tracker&.public_url,
277
+ 'easypost_rate_id' => rate&.id,
278
+ 'easypost_forms' => Array(shipment.try(:forms)).map { |form| { 'form_type' => form.try(:form_type), 'url' => form.try(:form_url) } }
279
+ }.compact
280
+ )
281
+ end
282
+
283
+ def carrier_key(carrier)
284
+ return if carrier.blank?
285
+
286
+ CARRIER_KEYS[carrier.to_s] || carrier.to_s
287
+ end
288
+
289
+ def refund_outcome(refund_status)
290
+ case refund_status.to_s
291
+ when 'refunded' then 'refunded'
292
+ when 'rejected', 'not_applicable' then false
293
+ else 'refund_requested'
294
+ end
295
+ end
296
+
297
+ def report(error, subject)
298
+ Rails.error.report(
299
+ error,
300
+ context: { subject_type: subject.class.name, subject_id: subject.try(:id) },
301
+ source: 'spree_easypost.fulfillment'
302
+ )
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,98 @@
1
+ module SpreeEasyPost
2
+ # Per-store EasyPost credentials. The API key decides the mode — EasyPost
3
+ # issues separate test and production keys, so there is no mode toggle.
4
+ class Integration < Spree::Integration
5
+ # EasyPost rejects any other value outright, and a rejected shipment
6
+ # create is a checkout with no delivery options at all.
7
+ INCOTERMS = %w[CFR CIF CIP CPT DAT DAP DDP EXW FAS FCA FOB].freeze
8
+ # EasyPost's contents enum minus `other`, which additionally demands a
9
+ # free-text explanation nothing here supplies — offering it would only
10
+ # produce a rejected declaration.
11
+ CUSTOMS_CONTENTS_TYPES = %w[documents gift merchandise returned_goods sample dangerous_goods humanitarian_donation].freeze
12
+
13
+ # Declared credentials first: they are the two an operator must supply,
14
+ # and the form renders in this order.
15
+ preference :api_key, :password
16
+ # The shared secret configured on the EasyPost webhook; signs every
17
+ # delivery. Without it webhooks are refused — an unauthenticated report
18
+ # could otherwise mark parcels delivered, which starts return windows.
19
+ preference :webhook_secret, :password
20
+
21
+ # Customs declaration defaults, used only on international labels. The
22
+ # signer takes legal responsibility for the declared contents, so it names
23
+ # a person at the merchant rather than the store.
24
+ preference :customs_signer, :string
25
+ preference :customs_contents_type, :string, default: 'merchandise', in: CUSTOMS_CONTENTS_TYPES
26
+ # Who pays duties and taxes on arrival. Defaults to DAP — the recipient is
27
+ # billed by the carrier. DDP bills the merchant instead, so only choose it
28
+ # once duties are actually collected from the customer at checkout;
29
+ # otherwise the merchant absorbs them silently.
30
+ preference :incoterm, :string, default: 'DAP', in: INCOTERMS
31
+
32
+ validates :preferred_incoterm, inclusion: { in: INCOTERMS }, allow_blank: true
33
+ validates :preferred_customs_contents_type, inclusion: { in: CUSTOMS_CONTENTS_TYPES }, allow_blank: true
34
+
35
+ # EasyPost's own documentation example address — used only to prove the
36
+ # key authenticates.
37
+ VERIFICATION_ADDRESS = {
38
+ street1: '417 Montgomery Street',
39
+ city: 'San Francisco',
40
+ state: 'CA',
41
+ zip: '94104',
42
+ country: 'US'
43
+ }.freeze
44
+
45
+ def self.integration_group
46
+ 'shipping'
47
+ end
48
+
49
+ # Verifies the EasyPost HMAC signature and translates `tracker.updated`
50
+ # payloads into UpdateTracking arguments. Non-tracker events (batches,
51
+ # refund confirmations) return nil and are acknowledged without action.
52
+ def parse_webhook_event(raw_post, headers)
53
+ raise Spree::Integration::WebhookSignatureError, 'webhook secret not configured' if preferred_webhook_secret.blank?
54
+
55
+ payload = EasyPost::Util.validate_webhook(raw_post, headers, preferred_webhook_secret)
56
+
57
+ SpreeEasyPost::TrackerEvent.from_webhook(payload)&.to_update_tracking_arguments
58
+ rescue EasyPost::Errors::SignatureVerificationError => e
59
+ raise Spree::Integration::WebhookSignatureError, e.message
60
+ end
61
+
62
+ def self.integration_name
63
+ SpreeEasyPost::PROVIDER_NAME
64
+ end
65
+
66
+ def self.logo_url
67
+ 'https://www.easypost.com/wp-content/uploads/2026/03/EasyPost-Logo.svg'
68
+ end
69
+
70
+ # Fallback for hosts without the gem's translations; the localized
71
+ # description in config/locales wins.
72
+ def self.description
73
+ 'Live multi-carrier delivery rates at checkout through your EasyPost account.'
74
+ end
75
+
76
+ # Verifies the key by creating an address — the one authenticated call
77
+ # that works in both modes. Account-management endpoints
78
+ # (`carrier_account.all`, `api_key.all`) are production-only and reject a
79
+ # valid test key with "This resource requires a production API Key",
80
+ # which would block merchants from connecting a test key at all.
81
+ # Addresses are inert: no shipment, no charge, nothing to clean up.
82
+ #
83
+ # Rescues broadly, not just SDK errors — a DNS failure or timeout must
84
+ # surface as a clean activation error, never a 500.
85
+ def can_connect?
86
+ client.address.create(VERIFICATION_ADDRESS)
87
+ true
88
+ rescue StandardError => e
89
+ self.connection_error_message = e.message
90
+ false
91
+ end
92
+
93
+ # @return [EasyPost::Client]
94
+ def client
95
+ @client ||= EasyPost::Client.new(api_key: preferred_api_key)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,84 @@
1
+ module SpreeEasyPost
2
+ # One `tracker.updated` webhook, translated into the vocabulary
3
+ # Spree::Deliveries::UpdateTracking speaks.
4
+ #
5
+ # Translating carrier status names is the provider's job precisely so core
6
+ # never learns any one carrier's spelling — EasyPost's own vocabulary happens
7
+ # to be close, but `unknown` and `error` are not the same thing to us, and a
8
+ # future provider's will not line up at all.
9
+ class TrackerEvent
10
+ include ActiveModel::Model
11
+ include ActiveModel::Attributes
12
+
13
+ # EasyPost status → Spree tracking status. Anything unlisted is recorded as
14
+ # `unknown` rather than dropped: a merchant looking at a parcel wants to
15
+ # see that something was reported, even if we cannot name it.
16
+ STATUS_MAP = {
17
+ 'pre_transit' => 'pre_transit',
18
+ 'in_transit' => 'in_transit',
19
+ 'out_for_delivery' => 'out_for_delivery',
20
+ 'available_for_pickup' => 'available_for_pickup',
21
+ 'delivered' => 'delivered',
22
+ 'return_to_sender' => 'return_to_sender',
23
+ 'failure' => 'failure',
24
+ 'cancelled' => 'failure',
25
+ 'error' => 'failure',
26
+ 'unknown' => 'unknown'
27
+ }.freeze
28
+
29
+ attribute :tracking_code, :string
30
+ attribute :status, :string
31
+ attribute :estimated_delivery_at, :datetime
32
+ attribute :delivered_at, :datetime
33
+ attr_accessor :details
34
+
35
+ # @param payload [Hash] the webhook body EasyPost posted
36
+ # @return [SpreeEasyPost::TrackerEvent, nil] nil when the payload is not a
37
+ # tracker update or carries no tracking code to match on
38
+ def self.from_webhook(payload)
39
+ # Always a plain Hash: the integration hands over what the SDK's
40
+ # signature validation parsed from the raw body.
41
+ payload = payload.to_h.deep_stringify_keys
42
+ return unless payload['description'].to_s.start_with?('tracker.')
43
+
44
+ tracker = payload['result'] || {}
45
+ tracking_code = tracker['tracking_code']
46
+ return if tracking_code.blank?
47
+
48
+ new(
49
+ tracking_code: tracking_code,
50
+ status: STATUS_MAP.fetch(tracker['status'].to_s, 'unknown'),
51
+ estimated_delivery_at: tracker['est_delivery_date'],
52
+ delivered_at: delivery_time(tracker),
53
+ details: tracker.slice('status', 'status_detail', 'carrier', 'est_delivery_date', 'public_url')
54
+ )
55
+ end
56
+
57
+ # The shape {Spree::Deliveries::UpdateTracking} takes, plus the
58
+ # tracking code the endpoint matches the fulfillment on.
59
+ #
60
+ # @return [Hash]
61
+ def to_update_tracking_arguments
62
+ {
63
+ tracking_code: tracking_code,
64
+ tracking_status: status,
65
+ estimated_delivery_at: estimated_delivery_at,
66
+ delivered_at: delivered_at,
67
+ details: details
68
+ }
69
+ end
70
+
71
+ # The scan that actually recorded delivery, which is earlier than the
72
+ # webhook and is what a return window has to run from.
73
+ def self.delivery_time(tracker)
74
+ return unless tracker['status'].to_s == 'delivered'
75
+
76
+ delivering_scan = Array(tracker['tracking_details']).reverse.find do |detail|
77
+ detail.is_a?(Hash) && detail['status'].to_s == 'delivered'
78
+ end
79
+
80
+ delivering_scan&.dig('datetime')
81
+ end
82
+ private_class_method :delivery_time
83
+ end
84
+ end
@@ -0,0 +1,12 @@
1
+ ---
2
+ en:
3
+ spree:
4
+ easypost:
5
+ errors:
6
+ no_destination: This parcel has no delivery address, so no carrier can quote it.
7
+ origin_address_refused: "%{location} cannot be used as the shipping origin: %{reason}"
8
+ rate_not_quoted: This delivery rate was not quoted by EasyPost. Re-quote the fulfillment and pick a carrier service before buying a label.
9
+ service_unavailable: "%{service} is no longer offered for this parcel. Re-quote the fulfillment and pick another service."
10
+ integrations:
11
+ easy_post:
12
+ description: Live multi-carrier delivery rates at checkout through your EasyPost account, including your own negotiated carrier contracts.
@@ -0,0 +1,47 @@
1
+ require 'rails/engine'
2
+
3
+ module SpreeEasyPost
4
+ class Engine < Rails::Engine
5
+ engine_name 'spree_easypost'
6
+
7
+ # Gem name and module disagree on word boundaries (spree_easypost →
8
+ # SpreeEasyPost, matching the official EasyPost SDK's casing), so
9
+ # Zeitwerk needs telling once.
10
+ initializer 'spree_easypost.inflections', before: :set_autoload_paths do
11
+ Rails.autoloaders.each do |autoloader|
12
+ autoloader.inflector.inflect('spree_easypost' => 'SpreeEasyPost')
13
+ end
14
+ end
15
+
16
+ # One line per EasyPost API call in the Rails log, so a checkout that
17
+ # quotes no rates can be read instead of guessed at. Development only by
18
+ # default; set SPREE_EASYPOST_HTTP_LOG=1 to turn it on elsewhere. The
19
+ # request body is deliberately not logged — it carries customer addresses.
20
+ initializer 'spree_easypost.http_logging' do
21
+ next unless Rails.env.development? || ENV['SPREE_EASYPOST_HTTP_LOG'].present?
22
+
23
+ logger_hook = lambda do |context|
24
+ duration_ms = ((context.response_timestamp - context.request_timestamp) * 1000).round
25
+ line = "[EasyPost] #{context.method.to_s.upcase} #{context.path} -> #{context.http_status} (#{duration_ms}ms)"
26
+
27
+ if (400..599).cover?(context.http_status.to_i)
28
+ Rails.logger.warn("#{line} #{context.response_body.to_s.truncate(500)}")
29
+ else
30
+ Rails.logger.info(line)
31
+ end
32
+ rescue StandardError => e
33
+ # The SDK invokes hooks inline in the request path with no rescue of
34
+ # its own — a logging hiccup must never take checkout down with it.
35
+ Rails.logger.warn("[EasyPost] logging hook failed: #{e.class}: #{e.message}")
36
+ end
37
+
38
+ EasyPost::Hooks.subscribe(:response, :spree_rails_logger, logger_hook)
39
+ end
40
+
41
+ config.after_initialize do
42
+ Spree.integrations << 'SpreeEasyPost::Integration'
43
+ Spree.delivery_rate_providers << SpreeEasyPost::DeliveryRateProvider
44
+ Spree.fulfillment_providers << SpreeEasyPost::FulfillmentProvider
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,237 @@
1
+ require 'easypost'
2
+ require 'spree_core'
3
+ require 'spree_easypost/engine'
4
+
5
+ module SpreeEasyPost
6
+ # Vendor branding, shared by both providers and the integration.
7
+ PROVIDER_NAME = 'EasyPost'.freeze
8
+
9
+ # EasyPost rejects a parcel weighing zero ("must be greater than 0"), and
10
+ # products without a weight are ordinary in Spree — so a weightless package
11
+ # is quoted at this nominal ounce rather than failing the whole request.
12
+ # Rates come back slightly low for such carts, which is the better failure:
13
+ # the alternative is no delivery options at all.
14
+ MINIMUM_OUNCES = 0.1
15
+
16
+ # EasyPost expects parcel dimensions in inches. A package reports its
17
+ # dimensions in the unit the store's system implies, so the conversion is
18
+ # from there — through the one conversion table Spree has, rather than a
19
+ # second copy of the same constants.
20
+ #
21
+ # @param value [Numeric, nil]
22
+ # @param store [Spree::Store, nil]
23
+ # @return [Float]
24
+ def self.inches(value, store)
25
+ from = Spree::Variant.store_dimensions_unit(store)
26
+
27
+ Spree::Measurement.convert_length(value, from: from, to: 'in').to_f.round(2)
28
+ end
29
+
30
+ # EasyPost expects parcel weight in ounces. A package reports its weight in
31
+ # the store's weight unit, which is a separate setting from its unit
32
+ # system — a metric store may still weigh in pounds.
33
+ #
34
+ # @param weight [Numeric, nil]
35
+ # @param store [Spree::Store, nil]
36
+ # @return [Float]
37
+ def self.ounces(weight, store)
38
+ from = store&.preferred_weight_unit.presence || Spree::Measurement::DEFAULT_WEIGHT_UNIT
39
+ converted = Spree::Measurement.convert_weight(weight, from: from, to: 'oz').to_f.round(2)
40
+
41
+ [converted, MINIMUM_OUNCES].max
42
+ end
43
+
44
+ # EasyPost parcel payload for a package: weight always, dimensions when
45
+ # the store configured a default package — carriers bill dimensional
46
+ # weight above size thresholds, so a weight-only parcel under-quotes
47
+ # bulky-but-light shipments.
48
+ #
49
+ # @param package [Spree::Stock::Package]
50
+ # @param store [Spree::Store, nil]
51
+ # @return [Hash]
52
+ def self.parcel_params(package, store)
53
+ params = { weight: ounces(package.weight, store) }
54
+
55
+ dimensions = package.dimensions
56
+ return params if dimensions.nil?
57
+
58
+ params.merge(
59
+ length: inches(dimensions[:length], store),
60
+ width: inches(dimensions[:width], store),
61
+ height: inches(dimensions[:height], store)
62
+ )
63
+ end
64
+
65
+ # EasyPost address payload from a Spree address or stock location.
66
+ #
67
+ # `name`/`company` look optional — rating succeeds without them — but
68
+ # buying a label fails with "address.name or address.company required",
69
+ # so both are always sent when the record has them.
70
+ #
71
+ # @param source [Spree::Address, Spree::StockLocation]
72
+ # @return [Hash]
73
+ def self.address_params(source)
74
+ {
75
+ name: address_name(source),
76
+ company: source.try(:company),
77
+ street1: source.address1,
78
+ street2: source.address2,
79
+ city: source.city,
80
+ state: source.respond_to?(:state_abbr) ? source.state_abbr : source.state&.abbr,
81
+ zip: source.zipcode,
82
+ country: source.country&.iso,
83
+ phone: source.phone
84
+ }.compact_blank
85
+ end
86
+
87
+ # Addresses carry a person's name; stock locations carry the location's.
88
+ def self.address_name(source)
89
+ return source.full_name if source.respond_to?(:full_name)
90
+
91
+ source.try(:name)
92
+ end
93
+
94
+ # US export filings are waived below this declared value (NOEEI 30.37(a));
95
+ # above it a shipment needs its own filing number, which only the merchant
96
+ # can supply, so no exemption is claimed on its behalf.
97
+ EEI_EXEMPTION_LIMIT_USD = 2_500
98
+
99
+ # Customs payload for an international shipment. Nil for domestic ones —
100
+ # EasyPost rejects a customs form on a domestic label, and every carrier
101
+ # treats "no customs_info" as the domestic case.
102
+ #
103
+ # Classification (HS code, country of origin) is sent per item when the
104
+ # merchant recorded it. It is deliberately not required: carriers differ on
105
+ # what they demand, and a rejected label carries the carrier's own message,
106
+ # which is more actionable than a guess made here.
107
+ #
108
+ # The declaration is only certified when the integration names a signer:
109
+ # EasyPost requires one whenever `customs_certify` is set, so certifying
110
+ # without it would fail every international quote for an integration that
111
+ # never configured a signer — the common case.
112
+ #
113
+ # @param package [Spree::Stock::Package]
114
+ # @param origin [Spree::StockLocation, nil] where the parcel ships from
115
+ # @param destination [Spree::Address, nil] where it is going
116
+ # @param integration [SpreeEasyPost::Integration, nil] customs signer/contents preferences
117
+ # @return [Hash, nil] nil when the shipment is domestic or undeterminable
118
+ def self.customs_info_params(package, origin, destination, integration = nil)
119
+ return unless international?(origin, destination)
120
+
121
+ items = customs_items_params(package)
122
+ return if items.empty?
123
+
124
+ signer = integration&.preferred_customs_signer.presence
125
+ params = {
126
+ contents_type: integration&.preferred_customs_contents_type.presence || 'merchandise',
127
+ restriction_type: 'none',
128
+ eel_pfc: eel_pfc_for(items, origin),
129
+ customs_items: items
130
+ }
131
+ params[:customs_certify] = true if signer
132
+ params[:customs_signer] = signer
133
+ params.compact_blank
134
+ end
135
+
136
+ # The full shipment-create payload, shared by quoting and label purchase so
137
+ # the two can never diverge. Whatever is set here at quote time is what a
138
+ # label bought against that quote carries — including the customs form and
139
+ # the duty terms, which are shipment properties EasyPost fixes at creation
140
+ # and does not accept on the buy call.
141
+ #
142
+ # @param package [Spree::Stock::Package]
143
+ # @param origin [Spree::StockLocation, nil]
144
+ # @param destination [Spree::Address, nil]
145
+ # @param integration [SpreeEasyPost::Integration, nil]
146
+ # @param store [Spree::Store, nil]
147
+ # @return [Hash]
148
+ def self.shipment_params(package, origin, destination, integration, store)
149
+ params = {
150
+ from_address: address_params(origin),
151
+ to_address: address_params(destination),
152
+ parcel: parcel_params(package, store)
153
+ }
154
+
155
+ customs_info = customs_info_params(package, origin, destination, integration)
156
+ return params if customs_info.nil?
157
+
158
+ params[:customs_info] = customs_info
159
+ incoterm = integration&.preferred_incoterm.presence
160
+ params[:options] = { incoterm: incoterm } if incoterm
161
+ params
162
+ end
163
+
164
+ # The US export exemption, claimed only where it actually applies: a
165
+ # US-origin shipment declared in US dollars under the filing threshold.
166
+ # Anything else — a foreign origin, a non-dollar declaration, a value over
167
+ # the limit — is left for the carrier to require rather than mis-declared
168
+ # here. The threshold is a dollar figure, so a declaration in another
169
+ # currency cannot be compared against it without a conversion this code
170
+ # deliberately does not attempt.
171
+ #
172
+ # @param items [Array<Hash>] customs items with :value and :currency
173
+ # @param origin [Spree::StockLocation, nil]
174
+ # @return [String, nil]
175
+ def self.eel_pfc_for(items, origin)
176
+ return unless origin&.country_code == 'US'
177
+ return unless items.all? { |item| item[:currency].to_s.upcase == 'USD' }
178
+
179
+ declared_value = items.sum { |item| item[:value].to_f }
180
+ 'NOEEI 30.37(a)' if declared_value < EEI_EXEMPTION_LIMIT_USD
181
+ end
182
+
183
+ # One customs item per package line. Quantity, value and weight always;
184
+ # classification only when present.
185
+ #
186
+ # @param package [Spree::Stock::Package]
187
+ # @return [Array<Hash>]
188
+ def self.customs_items_params(package)
189
+ store = package.owner&.store
190
+
191
+ package.contents.filter_map do |item|
192
+ variant = item.variant
193
+ next if variant.nil?
194
+
195
+ {
196
+ description: variant.customs_description_for_declaration,
197
+ quantity: item.quantity,
198
+ value: (item.price.to_f * item.quantity).round(2),
199
+ weight: ounces(item.weight, store),
200
+ origin_country: variant.country_of_origin.presence,
201
+ hs_tariff_number: variant.hs_code.presence,
202
+ currency: package.owner&.currency
203
+ }.compact_blank
204
+ end
205
+ end
206
+
207
+ # A shipment crosses a customs border when its origin and destination
208
+ # countries differ. Unknown countries mean domestic — never attach a
209
+ # customs form on a guess.
210
+ def self.international?(origin, destination)
211
+ origin_code = origin&.country_code
212
+ destination_code = destination&.country_code
213
+ return false if origin_code.blank? || destination_code.blank?
214
+
215
+ origin_code != destination_code
216
+ end
217
+
218
+ # EndShipper payload — the party legally responsible for the shipment,
219
+ # required when buying labels on EasyPost's own carrier accounts (USPS
220
+ # refuses the purchase without one). Whatever the warehouse and store can
221
+ # supply is sent as-is: which fields EasyPost demands is its rule, and it
222
+ # answers a payload it cannot accept by naming the field, which is more
223
+ # use to a merchant than a list maintained here that would drift out of
224
+ # step the first time that rule changed.
225
+ #
226
+ # @param stock_location [Spree::StockLocation, nil]
227
+ # @param store [Spree::Store, nil]
228
+ # @return [Hash, nil] nil only when there is no location to describe
229
+ def self.end_shipper_params(stock_location, store)
230
+ return if stock_location.nil?
231
+
232
+ address_params(stock_location).merge(
233
+ phone: stock_location.phone.presence || store&.contact_phone.presence,
234
+ email: store&.mail_from_address.presence || store&.customer_support_email.presence
235
+ ).compact_blank
236
+ end
237
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spree_easypost
3
+ version: !ruby/object:Gem::Version
4
+ version: 6.0.0.beta1
5
+ platform: ruby
6
+ authors:
7
+ - Vendo Connect Inc.
8
+ - Vendo Sp. z o.o.
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2026-09-15 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: easypost
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '7.0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '7.0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: spree_core
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - '='
33
+ - !ruby/object:Gem::Version
34
+ version: 6.0.0.beta1
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - '='
40
+ - !ruby/object:Gem::Version
41
+ version: 6.0.0.beta1
42
+ - !ruby/object:Gem::Dependency
43
+ name: vcr
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ description: Live multi-carrier delivery rates for Spree via EasyPost. Generate and
57
+ buy shipping labels both for fulfillments and returns. Supports 100+ carierrs worldwide.
58
+ email: hello@spreecommerce.org
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - LICENSE
64
+ - README.md
65
+ - Rakefile
66
+ - app/models/spree_easypost/delivery_rate_provider.rb
67
+ - app/models/spree_easypost/fulfillment_provider.rb
68
+ - app/models/spree_easypost/integration.rb
69
+ - app/models/spree_easypost/tracker_event.rb
70
+ - config/locales/en.yml
71
+ - lib/spree_easypost.rb
72
+ - lib/spree_easypost/engine.rb
73
+ homepage: https://spreecommerce.org
74
+ licenses:
75
+ - MIT
76
+ metadata:
77
+ bug_tracker_uri: https://github.com/spree/spree/issues
78
+ changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.0.beta1
79
+ documentation_uri: https://docs.spreecommerce.org/
80
+ source_code_uri: https://github.com/spree/spree/tree/v6.0.0.beta1
81
+ post_install_message:
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '3.2'
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ requirements: []
96
+ rubygems_version: 3.5.22
97
+ signing_key:
98
+ specification_version: 4
99
+ summary: EasyPost delivery rates and shipping labels for Spree eCommerce platform
100
+ test_files: []