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 +7 -0
- data/LICENSE +21 -0
- data/README.md +39 -0
- data/Rakefile +15 -0
- data/app/models/spree_easypost/delivery_rate_provider.rb +135 -0
- data/app/models/spree_easypost/fulfillment_provider.rb +305 -0
- data/app/models/spree_easypost/integration.rb +98 -0
- data/app/models/spree_easypost/tracker_event.rb +84 -0
- data/config/locales/en.yml +12 -0
- data/lib/spree_easypost/engine.rb +47 -0
- data/lib/spree_easypost.rb +237 -0
- metadata +100 -0
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: []
|