spree_mydhl 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.md +14 -0
- data/README.md +115 -0
- data/Rakefile +24 -0
- data/app/helpers/spree/admin/base_helper_decorator.rb +52 -0
- data/app/models/spree/calculator/shipping/dhl_express.rb +239 -0
- data/config/initializers/spree.rb +3 -0
- data/config/locales/en.yml +15 -0
- data/config/routes.rb +3 -0
- data/lib/spree_mydhl/configuration.rb +4 -0
- data/lib/spree_mydhl/dhl_express_client.rb +133 -0
- data/lib/spree_mydhl/engine.rb +23 -0
- data/lib/spree_mydhl/factories.rb +2 -0
- data/lib/spree_mydhl/version.rb +7 -0
- data/lib/spree_mydhl.rb +14 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5eb4386b2de341e46d441e151c6f30066a2527c2b07057b1b136e79cacced47e
|
|
4
|
+
data.tar.gz: 78286b36ff2b4b9c88bae7a573a5a9bed6f00e7842cbdf464722647f0507470e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0ce2c255a23119803f03bee73e2de65316c8e11991cb55ec97e19e91521ce077189d377d5635073097c33d50b72ecb66749f66e1a30d6949359f6a38c77f4e9d
|
|
7
|
+
data.tar.gz: 4b4ac034a59c4bd52be12437f3add590dccf960220cd28e7bd2009ed5cc45a81cf31e0bc4fab4bd8a7339904d0418ca488f1970a92d516c887668f3addd698bc
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# LICENSE
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 [name of plugin creator]
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
7
|
+
|
|
8
|
+
This program is distributed in the hope that it will be useful,
|
|
9
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
10
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
11
|
+
GNU Affero General Public License for more details.
|
|
12
|
+
|
|
13
|
+
You should have received a copy of the GNU Affero General Public License
|
|
14
|
+
along with this program. If not, see [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
data/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Spree MyDHL
|
|
2
|
+
|
|
3
|
+
A [Spree Commerce](https://spreecommerce.org) extension that adds MyDHL as a real-time shipping rate calculator. It connects to the [DHL MyDHL API](https://developer.dhl.com/api-reference/dhl-express-mydhl-api) during checkout to fetch live rates for your configured shipping methods.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Real-time rates from the MyDHL API
|
|
8
|
+
- Filters available shipping methods by Spree stock location
|
|
9
|
+
- Optional product code filter (e.g. lock a shipping method to *Express Worldwide* only)
|
|
10
|
+
- Automatic international/domestic detection for customs declarations
|
|
11
|
+
- Optional weight-based availability rules (min/max)
|
|
12
|
+
- Configurable rate caching to avoid redundant API calls
|
|
13
|
+
- Sandbox and production API support
|
|
14
|
+
- Admin dropdown selectors for stock location and DHL product code
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Ruby >= 3.2
|
|
19
|
+
- Spree >= 5.3.3
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
1. Add the gem to your Gemfile:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
bundle add spree_mydhl
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
2. Run the install generator:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bundle exec rails g spree_mydhl:install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
3. Restart your server.
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
### DHL API Credentials
|
|
40
|
+
|
|
41
|
+
Sign in to the [DHL Developer Portal](https://developer.dhl.com) and create an application to obtain your API Key and API Secret.
|
|
42
|
+
|
|
43
|
+
### Setting Up a Shipping Method
|
|
44
|
+
|
|
45
|
+
1. In the Spree admin, go to **Configuration → Shipping Methods** and create a new shipping method.
|
|
46
|
+
2. Select **DHL Express** as the calculator.
|
|
47
|
+
3. Fill in the calculator preferences:
|
|
48
|
+
|
|
49
|
+
| Preference | Description |
|
|
50
|
+
|---|---|
|
|
51
|
+
| **API Key** | Your DHL MyDHL API key |
|
|
52
|
+
| **API Secret** | Your DHL MyDHL API secret |
|
|
53
|
+
| **Account Number** | Your DHL account number |
|
|
54
|
+
| **Stock Location** | The stock location shipments originate from |
|
|
55
|
+
| **Unit of Measurement** | `metric` (kg/cm) or `imperial` (lb/in) — must match your variant dimensions |
|
|
56
|
+
| **Currency** | Currency for quoted rates (defaults to the store's default currency) |
|
|
57
|
+
| **Sandbox** | Enable to use the DHL test environment |
|
|
58
|
+
| **Product Code** | Optional — restrict to a specific DHL service (see below) |
|
|
59
|
+
| **Customs Declarable** | Optional — override automatic international detection |
|
|
60
|
+
| **Minimum Weight** | Optional — hide this method below a package weight threshold |
|
|
61
|
+
| **Maximum Weight** | Optional — hide this method above a package weight threshold |
|
|
62
|
+
| **Markup Percentage** | Optional — percentage added on top of the DHL rate (e.g. `10` adds 10%) |
|
|
63
|
+
| **Handling Fee** | Optional — flat amount added after any percentage markup |
|
|
64
|
+
| **Cache TTL Minutes** | How long to cache rates (default: `10`) |
|
|
65
|
+
|
|
66
|
+
### DHL Product Codes
|
|
67
|
+
|
|
68
|
+
By default the calculator returns the cheapest rate across all DHL products available for the route. Set a product code to lock the shipping method to a specific service level:
|
|
69
|
+
|
|
70
|
+
| Code | Service |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `P` | Express Worldwide |
|
|
73
|
+
| `D` | Express Worldwide Doc |
|
|
74
|
+
| `K` | Express 9:00 |
|
|
75
|
+
| `W` | Express 10:30 |
|
|
76
|
+
| `T` | Express 12:00 |
|
|
77
|
+
| `Y` | Express 12:00 Doc |
|
|
78
|
+
| `H` | Economy Select |
|
|
79
|
+
| `N` | Domestic Express |
|
|
80
|
+
|
|
81
|
+
### Variant Dimensions
|
|
82
|
+
|
|
83
|
+
The API requires package dimensions. These are derived from your Spree variant attributes:
|
|
84
|
+
|
|
85
|
+
- **Length** — largest `depth` across all items in the package
|
|
86
|
+
- **Width** — largest `width` across all items in the package
|
|
87
|
+
- **Height** — sum of `height × quantity` across all items
|
|
88
|
+
|
|
89
|
+
Dimensions fall back to `1.0` if all variants report zero. Make sure your variant dimensions are stored in units consistent with the **Unit of Measurement** preference — no automatic conversion is applied.
|
|
90
|
+
|
|
91
|
+
### Customs Declarations
|
|
92
|
+
|
|
93
|
+
By default, `isCustomsDeclarable` is set to `true` whenever the origin and destination country codes differ. Use the **Customs Declarable** preference to override this (e.g. for shipments between territories that share a country code).
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
bundle update
|
|
102
|
+
bundle exec rake
|
|
103
|
+
bundle exec rubocop
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Releasing
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
bundle exec gem bump -p -t
|
|
110
|
+
bundle exec gem release
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
[AGPL-3.0-or-later](LICENSE.md)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require 'bundler'
|
|
2
|
+
Bundler::GemHelper.install_tasks
|
|
3
|
+
|
|
4
|
+
require 'rspec/core/rake_task'
|
|
5
|
+
require 'spree/testing_support/extension_rake'
|
|
6
|
+
|
|
7
|
+
RSpec::Core::RakeTask.new
|
|
8
|
+
|
|
9
|
+
task :default do
|
|
10
|
+
if Dir['spec/dummy'].empty?
|
|
11
|
+
Rake::Task[:test_app].invoke
|
|
12
|
+
Dir.chdir('../../')
|
|
13
|
+
end
|
|
14
|
+
Rake::Task[:spec].invoke
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc 'Generates a dummy app for testing'
|
|
18
|
+
task :test_app do
|
|
19
|
+
ENV['LIB_NAME'] = 'spree_mydhl'
|
|
20
|
+
Rake::Task['extension:test_app'].execute(
|
|
21
|
+
install_storefront: true,
|
|
22
|
+
install_admin: true
|
|
23
|
+
)
|
|
24
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
module BaseHelperDecorator
|
|
4
|
+
def preference_fields(object, form, i18n_scope: '')
|
|
5
|
+
return super unless object.is_a?(Spree::Calculator::Shipping::DhlExpress)
|
|
6
|
+
|
|
7
|
+
fields = Spree::Calculator::Shipping::DhlExpress::PREFERENCE_ORDER.map do |key|
|
|
8
|
+
key == :hr ? tag.hr(style: 'margin-top: 17px; margin-bottom: 34px;') : preference_field(object, form, key, i18n_scope: i18n_scope)
|
|
9
|
+
end
|
|
10
|
+
safe_join(fields)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def preference_field(object, form, key, i18n_scope: '')
|
|
14
|
+
return super unless object.is_a?(Spree::Calculator::Shipping::DhlExpress)
|
|
15
|
+
|
|
16
|
+
case key
|
|
17
|
+
when :unit_of_measurement
|
|
18
|
+
options = Spree::Calculator::Shipping::DhlExpress::UNIT_OF_MEASUREMENT_OPTIONS.map do |opt|
|
|
19
|
+
[opt.capitalize, opt]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
content_tag(:div, class: 'form-group') do
|
|
23
|
+
form.label("preferred_#{key}", Spree.t(key, scope: i18n_scope, default: key.to_s.humanize)) +
|
|
24
|
+
form.select("preferred_#{key}", options, {}, class: 'form-select form-control')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
when :product_code
|
|
28
|
+
content_tag(:div, class: 'form-group') do
|
|
29
|
+
form.label("preferred_#{key}", Spree.t(:product_code)) +
|
|
30
|
+
form.select("preferred_#{key}", Spree::Calculator::Shipping::DhlExpress::PRODUCT_CODE_OPTIONS,
|
|
31
|
+
{ include_blank: false },
|
|
32
|
+
class: 'form-select form-control')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
when :stock_location_id
|
|
36
|
+
stock_locations = Spree::StockLocation.active.order(:name).pluck(:name, :id)
|
|
37
|
+
|
|
38
|
+
content_tag(:div, class: 'form-group') do
|
|
39
|
+
form.label("preferred_#{key}", Spree.t(:stock_location)) +
|
|
40
|
+
form.select("preferred_#{key}", stock_locations, { include_blank: Spree.t(:select_stock_location) },
|
|
41
|
+
class: 'form-select form-control')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
else
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Spree::Admin::BaseHelper.prepend(Spree::Admin::BaseHelperDecorator)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Calculator::Shipping
|
|
3
|
+
class DhlExpress < ShippingCalculator
|
|
4
|
+
PREFERENCE_ORDER = %i[
|
|
5
|
+
hr
|
|
6
|
+
api_key
|
|
7
|
+
api_secret
|
|
8
|
+
account_number
|
|
9
|
+
hr
|
|
10
|
+
stock_location_id
|
|
11
|
+
product_code
|
|
12
|
+
unit_of_measurement
|
|
13
|
+
currency
|
|
14
|
+
sandbox
|
|
15
|
+
customs_declarable
|
|
16
|
+
hr
|
|
17
|
+
minimum_weight
|
|
18
|
+
maximum_weight
|
|
19
|
+
hr
|
|
20
|
+
markup_percentage
|
|
21
|
+
handling_fee
|
|
22
|
+
hr
|
|
23
|
+
cache_ttl_minutes
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
UNIT_OF_MEASUREMENT_OPTIONS = %w[metric imperial].freeze
|
|
27
|
+
|
|
28
|
+
PRODUCT_CODE_OPTIONS = [
|
|
29
|
+
['Any (cheapest)', nil],
|
|
30
|
+
['P — Express Worldwide', 'P'],
|
|
31
|
+
['D — Express Worldwide Doc', 'D'],
|
|
32
|
+
['K — Express 9:00', 'K'],
|
|
33
|
+
['W — Express 10:30', 'W'],
|
|
34
|
+
['T — Express 12:00', 'T'],
|
|
35
|
+
['Y — Express 12:00 Doc', 'Y'],
|
|
36
|
+
['H — Economy Select', 'H'],
|
|
37
|
+
['N — Domestic Express', 'N']
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
preference :api_key, :string
|
|
41
|
+
preference :api_secret, :password
|
|
42
|
+
preference :account_number, :string
|
|
43
|
+
preference :stock_location_id, :integer
|
|
44
|
+
preference :unit_of_measurement, :string, default: UNIT_OF_MEASUREMENT_OPTIONS.first
|
|
45
|
+
preference :currency, :string, default: -> { Spree::Store.default.default_currency }
|
|
46
|
+
preference :sandbox, :boolean, default: false
|
|
47
|
+
preference :product_code, :string, default: nil, nullable: true
|
|
48
|
+
preference :customs_declarable, :boolean, default: nil, nullable: true
|
|
49
|
+
preference :minimum_weight, :decimal, default: nil, nullable: true
|
|
50
|
+
preference :maximum_weight, :decimal, default: nil, nullable: true
|
|
51
|
+
preference :markup_percentage, :decimal, default: nil, nullable: true
|
|
52
|
+
preference :handling_fee, :decimal, default: nil, nullable: true
|
|
53
|
+
preference :cache_ttl_minutes, :integer, default: 10
|
|
54
|
+
|
|
55
|
+
def self.description
|
|
56
|
+
'MyDHL Live Rates'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def available?(package)
|
|
60
|
+
if required_preferences_blank?
|
|
61
|
+
Rails.logger.debug('[SpreeMydhl] available? = false: one or more required preferences are blank')
|
|
62
|
+
return false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if preferred_minimum_weight.present? && preferred_maximum_weight.present? &&
|
|
66
|
+
preferred_minimum_weight.to_f > preferred_maximum_weight.to_f
|
|
67
|
+
Rails.logger.warn('[SpreeMydhl] minimum_weight exceeds maximum_weight — no package can qualify; check shipping method configuration')
|
|
68
|
+
return false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
stock_location = package.stock_location
|
|
72
|
+
if stock_location.nil? || stock_location.id.to_i != preferred_stock_location_id.to_i
|
|
73
|
+
Rails.logger.debug('[SpreeMydhl] available? = false: package stock location does not match configured stock location')
|
|
74
|
+
return false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
address = package.order.ship_address
|
|
78
|
+
if address.nil?
|
|
79
|
+
Rails.logger.debug('[SpreeMydhl] available? = false: ship_address is nil')
|
|
80
|
+
return false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if address.country&.iso.blank?
|
|
84
|
+
Rails.logger.debug('[SpreeMydhl] available? = false: ship_address country ISO is blank')
|
|
85
|
+
return false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
weight = package_weight(package)
|
|
89
|
+
|
|
90
|
+
if preferred_minimum_weight.present? && weight < preferred_minimum_weight.to_f
|
|
91
|
+
Rails.logger.debug("[SpreeMydhl] available? = false: weight #{weight} below minimum #{preferred_minimum_weight}")
|
|
92
|
+
return false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if preferred_maximum_weight.present? && weight > preferred_maximum_weight.to_f
|
|
96
|
+
Rails.logger.debug("[SpreeMydhl] available? = false: weight #{weight} above maximum #{preferred_maximum_weight}")
|
|
97
|
+
return false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def compute_package(package)
|
|
104
|
+
return nil unless available?(package)
|
|
105
|
+
|
|
106
|
+
stock_location = package.stock_location
|
|
107
|
+
origin_country = stock_location.country_iso
|
|
108
|
+
|
|
109
|
+
if origin_country.blank?
|
|
110
|
+
Rails.logger.debug('[SpreeMydhl] compute_package -> nil: stock location has no country ISO')
|
|
111
|
+
return nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
origin_postal = stock_location.zipcode.to_s
|
|
115
|
+
origin_city = stock_location.city.to_s
|
|
116
|
+
|
|
117
|
+
destination = package.order.ship_address
|
|
118
|
+
dest_country = destination.country.iso
|
|
119
|
+
dest_postal = destination.zipcode.to_s
|
|
120
|
+
dest_city = destination.city.to_s
|
|
121
|
+
|
|
122
|
+
weight = package_weight(package)
|
|
123
|
+
dimensions = package_dimensions(package)
|
|
124
|
+
currency = effective_currency(package)
|
|
125
|
+
cache_key = build_cache_key(origin_country, origin_postal, dest_country, dest_postal, dest_city, weight, dimensions, currency)
|
|
126
|
+
|
|
127
|
+
rate = Rails.cache.fetch(cache_key, expires_in: preferred_cache_ttl_minutes.minutes, skip_nil: true) do
|
|
128
|
+
client = SpreeMydhl::DhlExpressClient.new(
|
|
129
|
+
api_key: preferred_api_key,
|
|
130
|
+
api_secret: preferred_api_secret,
|
|
131
|
+
account_number: preferred_account_number,
|
|
132
|
+
origin_country_code: origin_country,
|
|
133
|
+
origin_postal_code: origin_postal,
|
|
134
|
+
origin_city_name: origin_city,
|
|
135
|
+
destination_country_code: dest_country,
|
|
136
|
+
destination_postal_code: dest_postal,
|
|
137
|
+
destination_city_name: dest_city,
|
|
138
|
+
weight: weight,
|
|
139
|
+
length: dimensions[:length],
|
|
140
|
+
width: dimensions[:width],
|
|
141
|
+
height: dimensions[:height],
|
|
142
|
+
unit_of_measurement: preferred_unit_of_measurement,
|
|
143
|
+
currency: currency,
|
|
144
|
+
sandbox: preferred_sandbox,
|
|
145
|
+
product_code: preferred_product_code,
|
|
146
|
+
customs_declarable: preferred_customs_declarable
|
|
147
|
+
)
|
|
148
|
+
client.cheapest_rate
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
rate = apply_markup(rate)
|
|
152
|
+
Rails.logger.debug("[SpreeMydhl] compute_package -> #{rate.inspect} (#{dest_country} #{dest_postal})")
|
|
153
|
+
rate
|
|
154
|
+
rescue StandardError => e
|
|
155
|
+
Rails.logger.error("[SpreeMydhl] compute_package failed: #{e.class}: #{e.message}")
|
|
156
|
+
Rails.logger.debug { Array(e.backtrace).first(5).join("\n") }
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def required_preferences_blank?
|
|
163
|
+
[
|
|
164
|
+
preferred_api_key,
|
|
165
|
+
preferred_api_secret,
|
|
166
|
+
preferred_account_number,
|
|
167
|
+
preferred_stock_location_id
|
|
168
|
+
].any?(&:blank?)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def package_weight(package)
|
|
172
|
+
@package_weights ||= {}
|
|
173
|
+
@package_weights[package.object_id] ||= begin
|
|
174
|
+
w = package.weight
|
|
175
|
+
w.positive? ? w.to_f : 0.1
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Computes package dimensions from variant attributes.
|
|
180
|
+
# Variant depth/width/height and package weight must be stored in units that
|
|
181
|
+
# match the configured unit_of_measurement preference (metric: cm/kg, imperial: in/lb).
|
|
182
|
+
# No automatic unit conversion is applied.
|
|
183
|
+
def package_dimensions(package)
|
|
184
|
+
max_length = 0.0
|
|
185
|
+
max_width = 0.0
|
|
186
|
+
total_height = 0.0
|
|
187
|
+
|
|
188
|
+
package.contents.each do |content|
|
|
189
|
+
variant = content.variant
|
|
190
|
+
quantity = [content.quantity.to_i, 1].max
|
|
191
|
+
max_length = [max_length, variant.depth.to_f].max
|
|
192
|
+
max_width = [max_width, variant.width.to_f].max
|
|
193
|
+
total_height += variant.height.to_f * quantity
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
{
|
|
197
|
+
length: max_length.positive? ? max_length : 1.0,
|
|
198
|
+
width: max_width.positive? ? max_width : 1.0,
|
|
199
|
+
height: total_height.positive? ? total_height : 1.0
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def effective_currency(package)
|
|
204
|
+
preferred_currency.presence || package.order.currency || package.order.store&.default_currency
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def apply_markup(rate)
|
|
208
|
+
return nil if rate.nil?
|
|
209
|
+
|
|
210
|
+
rate = rate * (1 + preferred_markup_percentage.to_f / 100.0) if preferred_markup_percentage.present?
|
|
211
|
+
rate = rate + preferred_handling_fee.to_f if preferred_handling_fee.present?
|
|
212
|
+
rate.round(2)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def build_cache_key(origin_country, origin_postal, dest_country, dest_postal, dest_city, weight, dimensions, currency)
|
|
216
|
+
[
|
|
217
|
+
'spree_mydhl',
|
|
218
|
+
'rates',
|
|
219
|
+
preferred_account_number,
|
|
220
|
+
preferred_stock_location_id,
|
|
221
|
+
preferred_unit_of_measurement,
|
|
222
|
+
preferred_product_code,
|
|
223
|
+
preferred_customs_declarable,
|
|
224
|
+
origin_country,
|
|
225
|
+
origin_postal,
|
|
226
|
+
dest_country,
|
|
227
|
+
dest_postal,
|
|
228
|
+
dest_city,
|
|
229
|
+
weight.round(3),
|
|
230
|
+
dimensions[:length].round(2),
|
|
231
|
+
dimensions[:width].round(2),
|
|
232
|
+
dimensions[:height].round(2),
|
|
233
|
+
currency,
|
|
234
|
+
Date.current.iso8601
|
|
235
|
+
].join('/')
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
en:
|
|
3
|
+
spree:
|
|
4
|
+
account_number: Account number
|
|
5
|
+
api_key: MyDHL API key
|
|
6
|
+
api_secret: MyDHL API secret
|
|
7
|
+
cache_ttl_minutes: Cache TTL (minutes)
|
|
8
|
+
customs_declarable: Customs declarable
|
|
9
|
+
handling_fee: Handline fee
|
|
10
|
+
markup_percentage: Markup (%)
|
|
11
|
+
maximum_weight: Maximum weight
|
|
12
|
+
minimum_weight: Minimum_weight
|
|
13
|
+
product_code: DHL Product Code
|
|
14
|
+
sandbox: Sandbox
|
|
15
|
+
select_stock_location: "— Select stock location —"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'base64'
|
|
5
|
+
|
|
6
|
+
module SpreeMydhl
|
|
7
|
+
class DhlExpressClient
|
|
8
|
+
class ApiError < StandardError; end
|
|
9
|
+
|
|
10
|
+
PRODUCTION_BASE_URL = 'https://express.api.dhl.com/mydhlapi'.freeze
|
|
11
|
+
SANDBOX_BASE_URL = 'https://express.api.dhl.com/mydhlapi/test'.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(api_key:, api_secret:, account_number:, origin_country_code:,
|
|
14
|
+
origin_postal_code:, origin_city_name:, destination_country_code:,
|
|
15
|
+
destination_postal_code:, destination_city_name:, weight:,
|
|
16
|
+
length:, width:, height:, unit_of_measurement: 'metric',
|
|
17
|
+
currency: 'USD', sandbox: false, product_code: nil,
|
|
18
|
+
customs_declarable: nil)
|
|
19
|
+
@api_key = api_key
|
|
20
|
+
@api_secret = api_secret
|
|
21
|
+
@account_number = account_number
|
|
22
|
+
@origin_country_code = origin_country_code
|
|
23
|
+
@origin_postal_code = origin_postal_code
|
|
24
|
+
@origin_city_name = origin_city_name
|
|
25
|
+
@destination_country_code = destination_country_code
|
|
26
|
+
@destination_postal_code = destination_postal_code
|
|
27
|
+
@destination_city_name = destination_city_name
|
|
28
|
+
@weight = weight
|
|
29
|
+
@length = length
|
|
30
|
+
@width = width
|
|
31
|
+
@height = height
|
|
32
|
+
@unit_of_measurement = unit_of_measurement
|
|
33
|
+
@currency = currency
|
|
34
|
+
@sandbox = sandbox
|
|
35
|
+
@product_code = product_code
|
|
36
|
+
@customs_declarable = customs_declarable
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def cheapest_rate
|
|
40
|
+
data = fetch_rates
|
|
41
|
+
return nil if data.nil?
|
|
42
|
+
|
|
43
|
+
products = data['products']
|
|
44
|
+
return nil if products.nil? || products.empty?
|
|
45
|
+
|
|
46
|
+
if @product_code.present?
|
|
47
|
+
products = products.select { |p| p['productCode'] == @product_code }
|
|
48
|
+
return nil if products.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
prices = products.filter_map do |product|
|
|
52
|
+
total_prices = product['totalPrice']
|
|
53
|
+
next unless total_prices.is_a?(Array)
|
|
54
|
+
|
|
55
|
+
billed = total_prices.find { |p| p['currencyType'] == 'BILLC' }
|
|
56
|
+
billed&.fetch('price', nil)&.to_f
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
prices.empty? ? nil : prices.min
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def fetch_rates
|
|
65
|
+
uri = build_uri
|
|
66
|
+
request = build_request(uri)
|
|
67
|
+
|
|
68
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
|
|
69
|
+
http.request(request)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
73
|
+
raise ApiError, "DHL API returned HTTP #{response.code}: #{response.body.to_s[0, 200]}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
JSON.parse(response.body)
|
|
77
|
+
rescue ApiError => e
|
|
78
|
+
Rails.logger.error("[SpreeMydhl] DHL API error: #{e.message}")
|
|
79
|
+
Rails.logger.debug { Array(e.backtrace).first(5).join("\n") }
|
|
80
|
+
nil
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
Rails.logger.error("[SpreeMydhl] DHL request failed: #{e.class}: #{e.message}")
|
|
83
|
+
Rails.logger.debug { Array(e.backtrace).first(5).join("\n") }
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_uri
|
|
88
|
+
base_url = @sandbox ? SANDBOX_BASE_URL : PRODUCTION_BASE_URL
|
|
89
|
+
uri = URI("#{base_url}/rates")
|
|
90
|
+
uri.query = URI.encode_www_form(query_params.reject { |_, v| v.to_s.strip.empty? })
|
|
91
|
+
uri
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_request(uri)
|
|
95
|
+
request = Net::HTTP::Get.new(uri)
|
|
96
|
+
request['Authorization'] = "Basic #{Base64.strict_encode64("#{@api_key}:#{@api_secret}")}"
|
|
97
|
+
request['Accept'] = 'application/json'
|
|
98
|
+
request['User-Agent'] = "spree_mydhl/#{SpreeMydhl::VERSION}"
|
|
99
|
+
request
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def query_params
|
|
103
|
+
{
|
|
104
|
+
accountNumber: @account_number,
|
|
105
|
+
originCountryCode: @origin_country_code,
|
|
106
|
+
originPostalCode: @origin_postal_code,
|
|
107
|
+
originCityName: @origin_city_name,
|
|
108
|
+
destinationCountryCode: @destination_country_code,
|
|
109
|
+
destinationPostalCode: @destination_postal_code,
|
|
110
|
+
destinationCityName: @destination_city_name,
|
|
111
|
+
weight: @weight.round(3),
|
|
112
|
+
length: @length.round(2),
|
|
113
|
+
width: @width.round(2),
|
|
114
|
+
height: @height.round(2),
|
|
115
|
+
plannedShippingDate: planned_shipping_date,
|
|
116
|
+
unitOfMeasurement: @unit_of_measurement,
|
|
117
|
+
isCustomsDeclarable: customs_declarable?,
|
|
118
|
+
nextBusinessDay: true,
|
|
119
|
+
requestedCurrencyCode: @currency
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def customs_declarable?
|
|
124
|
+
return @customs_declarable unless @customs_declarable.nil?
|
|
125
|
+
|
|
126
|
+
@origin_country_code.upcase != @destination_country_code.upcase
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def planned_shipping_date
|
|
130
|
+
Date.current.iso8601
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module SpreeMydhl
|
|
2
|
+
class Engine < Rails::Engine
|
|
3
|
+
require 'spree/core'
|
|
4
|
+
isolate_namespace Spree
|
|
5
|
+
engine_name 'spree_mydhl'
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :rspec
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer 'spree_mydhl.environment', before: :load_config_initializers do |_app|
|
|
12
|
+
SpreeMydhl::Config = SpreeMydhl::Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.activate
|
|
16
|
+
Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c|
|
|
17
|
+
Rails.configuration.cache_classes ? require(c) : load(c)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
config.to_prepare(&method(:activate).to_proc)
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/spree_mydhl.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require 'spree'
|
|
2
|
+
require 'spree_extension'
|
|
3
|
+
require 'spree_mydhl/engine'
|
|
4
|
+
require 'spree_mydhl/version'
|
|
5
|
+
require 'spree_mydhl/configuration'
|
|
6
|
+
require 'spree_mydhl/dhl_express_client'
|
|
7
|
+
|
|
8
|
+
module SpreeMydhl
|
|
9
|
+
mattr_accessor :queue
|
|
10
|
+
|
|
11
|
+
def self.queue
|
|
12
|
+
@@queue ||= Spree.queues.default
|
|
13
|
+
end
|
|
14
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: spree_mydhl
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Matthew Kennedy
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: spree
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 5.3.3
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 5.3.3
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: spree_admin
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 5.3.3
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 5.3.3
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: spree_storefront
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 5.3.3
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 5.3.3
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: spree_extension
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
email: m.kennedy@me.com
|
|
69
|
+
executables: []
|
|
70
|
+
extensions: []
|
|
71
|
+
extra_rdoc_files: []
|
|
72
|
+
files:
|
|
73
|
+
- LICENSE.md
|
|
74
|
+
- README.md
|
|
75
|
+
- Rakefile
|
|
76
|
+
- app/helpers/spree/admin/base_helper_decorator.rb
|
|
77
|
+
- app/models/spree/calculator/shipping/dhl_express.rb
|
|
78
|
+
- config/initializers/spree.rb
|
|
79
|
+
- config/locales/en.yml
|
|
80
|
+
- config/routes.rb
|
|
81
|
+
- lib/spree_mydhl.rb
|
|
82
|
+
- lib/spree_mydhl/configuration.rb
|
|
83
|
+
- lib/spree_mydhl/dhl_express_client.rb
|
|
84
|
+
- lib/spree_mydhl/engine.rb
|
|
85
|
+
- lib/spree_mydhl/factories.rb
|
|
86
|
+
- lib/spree_mydhl/version.rb
|
|
87
|
+
homepage: https://github.com/MatthewKennedy/spree_mydhl
|
|
88
|
+
licenses:
|
|
89
|
+
- AGPL-3.0-or-later
|
|
90
|
+
metadata: {}
|
|
91
|
+
rdoc_options: []
|
|
92
|
+
require_paths:
|
|
93
|
+
- lib
|
|
94
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '3.2'
|
|
99
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
requirements:
|
|
105
|
+
- none
|
|
106
|
+
rubygems_version: 4.0.3
|
|
107
|
+
specification_version: 4
|
|
108
|
+
summary: Spree Commerce MyDHL Extension providing live shipping rates at checkout
|
|
109
|
+
test_files: []
|