spree_boxnow 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +225 -0
  3. data/Rakefile +21 -0
  4. data/app/assets/config/spree_boxnow_manifest.js +3 -0
  5. data/app/assets/images/integration_icons/boxnow-logo.png +0 -0
  6. data/app/controllers/spree/admin/boxnow_controller.rb +126 -0
  7. data/app/controllers/spree/boxnow_controller.rb +24 -0
  8. data/app/javascript/spree_boxnow/application.js +16 -0
  9. data/app/javascript/spree_boxnow/controllers/spree_boxnow_controller.js +34 -0
  10. data/app/lib/spree_boxnow/api_client.rb +108 -0
  11. data/app/models/spree/calculator/shipping/boxnow_rate.rb +123 -0
  12. data/app/models/spree/integrations/boxnow.rb +30 -0
  13. data/app/models/spree/order_decorator.rb +17 -0
  14. data/app/models/spree/shipment_decorator.rb +17 -0
  15. data/app/services/spree_boxnow/cancel_voucher.rb +24 -0
  16. data/app/services/spree_boxnow/create_voucher.rb +79 -0
  17. data/app/services/spree_boxnow/print_vouchers.rb +17 -0
  18. data/app/views/spree/admin/integrations/forms/_boxnow.html.erb +14 -0
  19. data/app/views/spree/admin/orders/_shipment.html.erb +198 -0
  20. data/app/views/spree/admin/shipping_methods/_boxnow_form.html.erb +13 -0
  21. data/app/views/spree/checkout/_delivery_shipping_rate.html.erb +31 -0
  22. data/app/views/spree_boxnow/_head.html.erb +1 -0
  23. data/app/views/spree_boxnow/_locker_picker.html.erb +101 -0
  24. data/app/views/spree_boxnow/_order_dropdown_options.html.erb +38 -0
  25. data/config/importmap.rb +6 -0
  26. data/config/initializers/spree.rb +12 -0
  27. data/config/locales/el.yml +74 -0
  28. data/config/locales/en.yml +74 -0
  29. data/config/routes.rb +10 -0
  30. data/db/migrate/20260210120618_add_boxnow_to_spree_shipping_methods.rb +5 -0
  31. data/lib/generators/spree_boxnow/install/install_generator.rb +20 -0
  32. data/lib/spree_boxnow/configuration.rb +13 -0
  33. data/lib/spree_boxnow/engine.rb +39 -0
  34. data/lib/spree_boxnow/factories.rb +10 -0
  35. data/lib/spree_boxnow/version.rb +7 -0
  36. data/lib/spree_boxnow.rb +11 -0
  37. metadata +192 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2900ebc580d24fc4b70391b12db03014aa48254a0d412350080705e29f72d690
4
+ data.tar.gz: 1abfd9b5be518d7f399712aa903ad429c5506243a5c2276e22d1f1f4ec58b725
5
+ SHA512:
6
+ metadata.gz: a4326bb2f22ce5ad3864e72ba7ae3b616a3418ddf73f26252a1821b1adc531d38b7a8a61c1768f1f60ce5b848cee062814dd5465d07451821fe4b43aaef41d81
7
+ data.tar.gz: edb9c3871bf6237ee19111de09a48d18d4fdc2a437ffd7140ee2a02f0f1adf01875dc7f36aacd49e6535e023b0593808982fd015b2855c87f9ae76548623f473
data/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # spree_boxnow
2
+
3
+ A [Spree Commerce](https://spreecommerce.org) extension that integrates **BoxNow** locker delivery (APM — Automatic Parcel Machine) for Greek e-commerce stores. BoxNow is a last-mile carrier operating exclusively in Greece.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/spree_boxnow.svg)](https://badge.fury.io/rb/spree_boxnow)
6
+
7
+ ## Installation
8
+
9
+ 1. Add to your Gemfile:
10
+
11
+ ```bash
12
+ bundle add spree_boxnow
13
+ ```
14
+
15
+ 2. Run the install generator (copies migrations, mounts routes):
16
+
17
+ ```bash
18
+ bundle exec rails g spree_boxnow:install
19
+ ```
20
+
21
+ The generator will ask whether to run `db:migrate` immediately. If you skip it:
22
+
23
+ ```bash
24
+ bin/rails db:migrate
25
+ ```
26
+
27
+ 3. Restart your server.
28
+
29
+ ---
30
+
31
+ ## Setup
32
+
33
+ ### 1. Configure BoxNow credentials
34
+
35
+ Go to **Admin → Integrations → BoxNow** and fill in:
36
+
37
+ | Field | Description |
38
+ |-------|-------------|
39
+ | **Client ID** | Provided by BoxNow |
40
+ | **Client Secret** | Provided by BoxNow |
41
+ | **Partner ID** | Provided by BoxNow |
42
+ | **API URL** | BoxNow API base URL (e.g. `https://api-production.boxnow.gr`) |
43
+ | **Origin Location ID** | Your warehouse/store APM location ID |
44
+ | **Contact Name** | Sender contact name printed on labels |
45
+ | **Contact Phone** | Sender phone number |
46
+ | **Contact Email** | Sender email address |
47
+
48
+ OAuth2 tokens are obtained automatically using Client Credentials and cached in `Rails.cache` for 1 hour.
49
+
50
+ ### 2. Set product dimensions
51
+
52
+ The shipping calculator determines the price tier from the physical dimensions of the items in the order. Every **Variant** must have `height`, `width`, and `depth` set in **centimetres**.
53
+
54
+ - If any variant in the order is missing dimensions, the BoxNow shipping method will **not appear** at checkout.
55
+ - BoxNow supports **one parcel per order** — all items are treated as a single combined package. If the combined package exceeds BoxNow hard limits, the method is hidden.
56
+
57
+ BoxNow hard limits:
58
+
59
+ | Dimension | Limit |
60
+ |-----------|-------|
61
+ | Max weight | 20 kg |
62
+ | Max height | 36 cm |
63
+ | Max width | 45 cm |
64
+ | Max depth | 60 cm |
65
+
66
+ ### 3. Create a BoxNow shipping method
67
+
68
+ Go to **Admin → Shipping Methods → New**:
69
+
70
+ 1. Name it (e.g. "BoxNow Locker Delivery")
71
+ 2. Tick the **BoxNow** checkbox so the extension recognises it
72
+ 3. Select **BoxNow Rate** as the calculator
73
+ 4. Set the calculator preferences:
74
+
75
+ | Preference | Default | Description |
76
+ |------------|---------|-------------|
77
+ | **Small box price** | 0.0 | Price for parcels ≤ 8 cm in height |
78
+ | **Medium box price** | 0.0 | Price for parcels ≤ 17 cm in height |
79
+ | **Large box price** | 0.0 | Price for parcels ≤ 36 cm in height |
80
+ | **Base padding (cm)** | 1.0 | Added to every dimension to account for the physical box being slightly larger than its contents. Set to `0` to disable. |
81
+ | **Multi-item factor** | 1.05 | Multiplier applied to all dimensions when an order has more than one item (accounts for imperfect stacking). Set to `1.0` to disable. |
82
+
83
+ ---
84
+
85
+ ## How parcel sizing works
86
+
87
+ The calculator models the entire order as **one parcel** — there is no multi-box splitting.
88
+
89
+ For each line item it:
90
+ 1. Sorts the variant's three dimensions smallest → largest (`s ≤ m ≤ d`)
91
+ 2. Stacks items along the smallest axis: `parcel_height += s × quantity`
92
+ 3. Takes `width = max(m)` and `depth = max(d)` across all items
93
+
94
+ After stacking, padding and the multi-item factor are applied. The resulting three dimensions are then sorted again to allow virtual rotation, and the smallest is compared against the tier thresholds.
95
+
96
+ | Tier | Height threshold |
97
+ |------|-----------------|
98
+ | Small | ≤ 8 cm |
99
+ | Medium | ≤ 17 cm |
100
+ | Large | ≤ 36 cm |
101
+
102
+ If the package exceeds the large threshold or any hard limit, `nil` is returned and the BoxNow shipping option is hidden from checkout.
103
+
104
+ ---
105
+
106
+ ## Storefront — locker picker
107
+
108
+ When a customer selects a BoxNow shipping rate at checkout, a locker picker is injected inline. The picker uses the **BoxNow JS widget** which opens a popup map of available APM locations.
109
+
110
+ On locker selection the widget callback POSTs to `/boxnow/select_locker`, which stores:
111
+
112
+ - `boxnow.destination_location_id`
113
+ - `boxnow.locker_name`
114
+ - `boxnow.locker_address`
115
+
116
+ into `shipment.private_metadata`. Once a voucher is created the locker cannot be changed — further calls to `select_locker` are silently ignored for tracked shipments.
117
+
118
+ ---
119
+
120
+ ## Admin — voucher lifecycle
121
+
122
+ Prerequisites for an order to be eligible:
123
+
124
+ - Shipping and billing addresses present
125
+ - Payment completed
126
+ - Shipment state: **ready**
127
+ - A BoxNow locker has been selected by the customer at checkout
128
+
129
+ ### Creating a voucher
130
+
131
+ 1. Open the order in **Spree Admin**.
132
+ 2. The **"Create Voucher"** option appears in the top-right actions dropdown when the shipment is ready and not yet tracked.
133
+ 3. Click it — the extension calls the BoxNow API (`POST /api/v1/delivery-requests`) and stores:
134
+ - `shipment.tracking` ← primary parcel ID
135
+ - `shipment.private_metadata['boxnow.vg_child']` ← any child parcel IDs
136
+
137
+ ### Printing a voucher
138
+
139
+ Once a voucher exists ("Print Voucher" appears in the dropdown):
140
+
141
+ 1. Click **"Print Voucher"** — the extension fetches the PDF label for every parcel ID (primary + children).
142
+ 2. Multiple PDFs are merged into one using `combine_pdf`.
143
+ 3. The merged PDF is sent inline in a new browser tab, ready to print.
144
+
145
+ ### Cancelling a voucher
146
+
147
+ The cancel action is available via `POST admin/boxnow/:order_id/cancel` (guard: shipment must be tracked and not yet shipped). When called it:
148
+
149
+ 1. Calls `POST /api/v1/parcels/{id}:cancel` on the BoxNow API
150
+ 2. Clears `shipment.tracking` and removes `boxnow.vg_child` from `private_metadata`
151
+
152
+ > The cancel button in the admin UI is not yet wired up — see [What's not implemented](#whats-not-implemented).
153
+
154
+ ### Retry behaviour
155
+
156
+ If a voucher creation attempt fails and the admin retries, the extension appends an attempt counter to the order number (`{shipment.number}-2`, `-3`, etc.) to avoid duplicate-order errors from the BoxNow API.
157
+
158
+ ---
159
+
160
+ ## Routes
161
+
162
+ | Method | Path | Action |
163
+ |--------|------|--------|
164
+ | `POST` | `/boxnow/select_locker` | Storefront: save locker selection |
165
+ | `POST` | `/{admin_path}/boxnow/:order_id/create` | Admin: create voucher |
166
+ | `GET` | `/{admin_path}/boxnow/:order_id/print` | Admin: print/download voucher PDF |
167
+ | `POST` | `/{admin_path}/boxnow/:order_id/cancel` | Admin: cancel voucher |
168
+ | `POST` | `/{admin_path}/boxnow/:order_id/select_locker` | Admin: update locker selection (pre-voucher only) |
169
+
170
+ ---
171
+
172
+ ## What's not implemented
173
+
174
+ - **Webhook handling** — BoxNow pushes parcel status events to a configurable endpoint. A public route and shipment state updates are not yet implemented. See `docs/BoxNow parcel events webhooks (10-11-23).pdf` for the event payload reference.
175
+ - **Cancel voucher UI** — the `CancelVoucher` service and the admin route exist but there is no button in the order dropdown yet.
176
+
177
+ ---
178
+
179
+ ## Developing
180
+
181
+ ```bash
182
+ # Install dependencies and set up the dummy app (required before first test run)
183
+ bundle install
184
+ bundle exec rake test_app
185
+
186
+ # Run all specs
187
+ bundle exec rspec
188
+
189
+ # Run a single spec
190
+ bundle exec rspec spec/feature/home_page_spec.rb
191
+ ```
192
+
193
+ Integration tests require these env vars (load via dotenv or export manually):
194
+
195
+ ```
196
+ BOXNOW_CLIENT_ID=
197
+ BOXNOW_CLIENT_SECRET=
198
+ BOXNOW_PARTNER_ID=
199
+ BOXNOW_API_URL=
200
+ ```
201
+
202
+ When testing a host app's integration you can use the gem's factories:
203
+
204
+ ```ruby
205
+ require 'spree_boxnow/factories'
206
+ ```
207
+
208
+ ---
209
+
210
+ ## Releasing a new version
211
+
212
+ ```bash
213
+ bundle exec gem bump -p -t
214
+ bundle exec gem release
215
+ ```
216
+
217
+ See [gem-release README](https://github.com/svenfuchs/gem-release) for more options.
218
+
219
+ ---
220
+
221
+ ## Contributing
222
+
223
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for instructions on setting up a development environment and submitting pull requests.
224
+
225
+ Copyright (c) 2026 OlympusOne, released under the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,21 @@
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_boxnow'
20
+ Rake::Task['extension:test_app'].invoke
21
+ end
@@ -0,0 +1,3 @@
1
+ //= link_tree ../images
2
+ //= link spree_boxnow/application.js
3
+ //= link_tree ../../javascript/spree_boxnow/controllers .js
@@ -0,0 +1,126 @@
1
+ require 'combine_pdf'
2
+
3
+ module Spree
4
+ module Admin
5
+ class BoxnowController < Spree::Admin::BaseController
6
+ include Spree::Admin::OrdersFiltersHelper
7
+
8
+ def create
9
+ begin
10
+ load_order
11
+
12
+ @order.shipments.each do |shipment|
13
+ next unless shipment.can_create_boxnow_voucher?
14
+
15
+ result = SpreeBoxnow::CreateVoucher.new(shipment).call
16
+
17
+ shipment.tracking = result[:parcel_id]
18
+ shipment.private_metadata['boxnow.vg_child'] = result[:child_parcel_ids]
19
+ shipment.save!
20
+ end
21
+
22
+ flash[:success] = Spree.t('admin.integrations.boxnow.voucher_successfully_created')
23
+ rescue ActiveRecord::RecordNotFound
24
+ order_not_found
25
+ rescue StandardError => e
26
+ Rails.logger.error "Boxnow Error: #{e.message}"
27
+
28
+ flash[:error] = "#{Spree.t('admin.integrations.boxnow.voucher_creation_failed')}: #{e.message}"
29
+ end
30
+ end
31
+
32
+ def print
33
+ begin
34
+ load_order
35
+
36
+ shipments = @order.shipments.select(&:can_print_boxnow_voucher?)
37
+
38
+ voucher_numbers = shipments.flat_map do |shipment|
39
+ child_ids = shipment.private_metadata['boxnow.vg_child'] || []
40
+ [shipment.tracking] + child_ids
41
+ end
42
+
43
+ if voucher_numbers.empty?
44
+ raise StandardError, Spree.t('admin.integrations.boxnow.voucher_print_failed')
45
+ end
46
+
47
+ pdf_contents = voucher_numbers.map do |parcel_id|
48
+ SpreeBoxnow::PrintVouchers.new(parcel_id).call
49
+ end
50
+
51
+ merged_bytes =
52
+ if pdf_contents.size == 1
53
+ pdf_contents.first
54
+ else
55
+ combined = CombinePDF.new
56
+ pdf_contents.each { |bytes| combined << CombinePDF.parse(bytes) }
57
+ combined.to_pdf
58
+ end
59
+
60
+ send_data merged_bytes,
61
+ filename: "#{@order.number}.pdf",
62
+ type: 'application/pdf',
63
+ disposition: 'inline'
64
+ rescue ActiveRecord::RecordNotFound
65
+ render json: {
66
+ error: flash_message_for(Spree::Order.new, :not_found)
67
+ }, status: 404
68
+ rescue StandardError => e
69
+ Rails.logger.error "Boxnow Error: #{e.message}"
70
+
71
+ render json: {
72
+ error: Spree.t('admin.integrations.boxnow.voucher_print_failed')
73
+ }, status: 400
74
+ end
75
+ end
76
+
77
+ def cancel
78
+ load_order
79
+
80
+ @order.shipments.each do |shipment|
81
+ next unless shipment.can_cancel_boxnow_voucher?
82
+
83
+ SpreeBoxnow::CancelVoucher.new(shipment).call
84
+ end
85
+
86
+ flash[:success] = Spree.t('admin.integrations.boxnow.voucher_successfully_cancelled')
87
+ rescue ActiveRecord::RecordNotFound
88
+ order_not_found
89
+ rescue StandardError => e
90
+ Rails.logger.error "Boxnow Error: #{e.message}"
91
+
92
+ flash[:error] = "#{Spree.t('admin.integrations.boxnow.voucher_cancellation_failed')}: #{e.message}"
93
+ end
94
+
95
+ def select_locker
96
+ load_order
97
+ shipment = @order.shipments.find { |s| s.shipping_method&.boxnow? }
98
+
99
+ if shipment.nil? || params[:locker_id].blank?
100
+ render json: { error: 'Invalid request' }, status: :unprocessable_entity and return
101
+ end
102
+
103
+ shipment.private_metadata['boxnow.destination_location_id'] = params[:locker_id]
104
+ shipment.private_metadata['boxnow.locker_name'] = params[:locker_name]
105
+ shipment.private_metadata['boxnow.locker_address'] = params[:locker_address]
106
+ shipment.save!
107
+
108
+ render json: { success: true }
109
+ rescue ActiveRecord::RecordNotFound
110
+ order_not_found
111
+ end
112
+
113
+ private
114
+
115
+ def load_order
116
+ @order = current_store.orders.find(params[:order_id])
117
+ authorize! action, @order
118
+ @order
119
+ end
120
+
121
+ def order_not_found
122
+ flash[:error] = flash_message_for(Spree::Order.new, :not_found)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,24 @@
1
+ module Spree
2
+ class BoxnowController < Spree::StoreController
3
+ def select_locker
4
+ order = current_order
5
+
6
+ if order.blank? || params[:locker_id].blank?
7
+ render json: { error: 'Invalid request' }, status: :unprocessable_entity
8
+ return
9
+ end
10
+
11
+ order.shipments.each do |shipment|
12
+ next unless shipment.shipping_method&.boxnow?
13
+ next if shipment.tracked?
14
+
15
+ shipment.private_metadata['boxnow.destination_location_id'] = params[:locker_id]
16
+ shipment.private_metadata['boxnow.locker_name'] = params[:locker_name]
17
+ shipment.private_metadata['boxnow.locker_address'] = params[:locker_address]
18
+ shipment.save!
19
+ end
20
+
21
+ render json: { success: true }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ import '@hotwired/turbo-rails'
2
+ import { Application } from '@hotwired/stimulus'
3
+
4
+ let application
5
+
6
+ if (typeof window.Stimulus === "undefined") {
7
+ application = Application.start()
8
+ application.debug = false
9
+ window.Stimulus = application
10
+ } else {
11
+ application = window.Stimulus
12
+ }
13
+
14
+ import SpreeBoxnowController from 'spree_boxnow/controllers/spree_boxnow_controller'
15
+
16
+ application.register('spree-boxnow', SpreeBoxnowController)
@@ -0,0 +1,34 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { post } from "@rails/request.js";
3
+
4
+ export default class extends Controller {
5
+ static values = {
6
+ orderId: Number,
7
+ createVoucherPrompt: String,
8
+ createVoucherError: String,
9
+ cancelVoucherPrompt: String,
10
+ cancelVoucherError: String,
11
+ };
12
+
13
+ async createVoucher(event) {
14
+ event.preventDefault();
15
+
16
+ const value = window.confirm(this.createVoucherPromptValue);
17
+ if (!value) return;
18
+
19
+ await post(`${Spree.adminPath}/boxnow/${this.orderIdValue}/create`);
20
+
21
+ Turbo.visit(window.location.href, { action: "replace" });
22
+ }
23
+
24
+ async cancelVoucher(event) {
25
+ event.preventDefault();
26
+
27
+ const confirmed = window.confirm(this.cancelVoucherPromptValue);
28
+ if (!confirmed) return;
29
+
30
+ await post(`${Spree.adminPath}/boxnow/${this.orderIdValue}/cancel`);
31
+
32
+ Turbo.visit(window.location.href, { action: "replace" });
33
+ }
34
+ }
@@ -0,0 +1,108 @@
1
+ require 'faraday'
2
+ require 'faraday/retry'
3
+
4
+ module SpreeBoxnow
5
+ class ApiClient
6
+ include Spree::IntegrationsConcern
7
+
8
+ TOKEN_MARGIN = 60 # seconds before expiry to refresh
9
+
10
+ def initialize
11
+ integration = store_integration('boxnow')
12
+ raise 'BoxNow integration not configured' unless integration
13
+
14
+ @client_id = integration.preferred_client_id
15
+ @client_secret = integration.preferred_client_secret
16
+ @api_url = integration.preferred_api_url.to_s.chomp('/')
17
+ end
18
+
19
+ def create_delivery_request(params)
20
+ post('/api/v1/delivery-requests', params)
21
+ end
22
+
23
+ def fetch_label(parcel_id, format: 'pdf')
24
+ get("/api/v1/parcels/#{parcel_id}/label.#{format}", raw: true)
25
+ end
26
+
27
+ def cancel_parcel(parcel_id)
28
+ post("/api/v1/parcels/#{parcel_id}:cancel", {})
29
+ end
30
+
31
+ def destinations(params = {})
32
+ get('/api/v1/destinations', params: params)
33
+ end
34
+
35
+ private
36
+
37
+ def token_cache_key
38
+ "spree_boxnow/access_token/#{@client_id}"
39
+ end
40
+
41
+ def access_token
42
+ cached = Rails.cache.read(token_cache_key)
43
+ return cached if cached
44
+
45
+ response = Faraday.post(
46
+ "#{@api_url}/api/v1/auth-sessions",
47
+ { grant_type: 'client_credentials', client_id: @client_id, client_secret: @client_secret }.to_json,
48
+ 'Content-Type' => 'application/json',
49
+ 'Accept' => 'application/json'
50
+ )
51
+
52
+ raise ApiError, "Authentication failed (#{response.status}): #{response.body}" unless response.success?
53
+
54
+ body = JSON.parse(response.body)
55
+ token = body['access_token']
56
+ expires_in = body['expires_in'].to_i - TOKEN_MARGIN
57
+
58
+ Rails.cache.write(token_cache_key, token, expires_in: expires_in)
59
+ token
60
+ end
61
+
62
+ def json_connection
63
+ @json_connection ||= Faraday.new(url: @api_url) do |f|
64
+ f.request :json
65
+ f.response :json
66
+ f.request :retry, max: 2, interval: 0.5
67
+ f.adapter Faraday.default_adapter
68
+ end
69
+ end
70
+
71
+ def raw_connection
72
+ @raw_connection ||= Faraday.new(url: @api_url) do |f|
73
+ f.request :retry, max: 2, interval: 0.5
74
+ f.adapter Faraday.default_adapter
75
+ end
76
+ end
77
+
78
+ def get(path, params: {}, raw: false)
79
+ conn = raw ? raw_connection : json_connection
80
+ response = conn.get(path) do |req|
81
+ req.headers['Authorization'] = "Bearer #{access_token}"
82
+ req.headers['Accept'] = raw ? 'application/pdf' : 'application/json'
83
+ req.params.merge!(params) unless params.empty?
84
+ end
85
+ handle_response(response, raw: raw)
86
+ end
87
+
88
+ def post(path, body)
89
+ response = json_connection.post(path) do |req|
90
+ req.headers['Authorization'] = "Bearer #{access_token}"
91
+ req.body = body
92
+ end
93
+ handle_response(response)
94
+ end
95
+
96
+ def handle_response(response, raw: false)
97
+ unless response.success?
98
+ code = response.body.is_a?(Hash) ? response.body['code'] : nil
99
+ message = code ? I18n.t("spree.boxnow.api_errors.#{code}", default: response.body.to_s) : response.body.to_s
100
+ raise ApiError, "[#{code || response.status}] #{message}"
101
+ end
102
+
103
+ raw ? response.body : response.body
104
+ end
105
+
106
+ class ApiError < StandardError; end
107
+ end
108
+ end
@@ -0,0 +1,123 @@
1
+ module Spree
2
+ module Calculator::Shipping
3
+ class BoxnowRate < Spree::ShippingCalculator
4
+ # Hard limits (BoxNow)
5
+ MAX_WEIGHT_KG = 20.0
6
+ MAX_HEIGHT_CM = 36.0
7
+ MAX_WIDTH_CM = 45.0
8
+ MAX_DEPTH_CM = 60.0
9
+
10
+ # Size thresholds (height decides the size; width/depth are constant limits)
11
+ SIZE_MAX_HEIGHT_CM = {
12
+ small: 8.0,
13
+ medium: 17.0,
14
+ large: 36.0
15
+ }.freeze
16
+
17
+ preference :small_box_price, :decimal, default: 0.0
18
+ preference :medium_box_price, :decimal, default: 0.0
19
+ preference :large_box_price, :decimal, default: 0.0
20
+
21
+ preference :base_padding_cm, :decimal, default: 1.0
22
+ preference :multi_item_factor, :decimal, default: 1.05
23
+
24
+ validates :preferred_small_box_price,
25
+ :preferred_medium_box_price,
26
+ :preferred_large_box_price,
27
+ :preferred_base_padding_cm,
28
+ :preferred_multi_item_factor,
29
+ presence: true
30
+
31
+ def self.description
32
+ Spree.t(:shipping_boxnow_rate)
33
+ end
34
+
35
+ def compute_package(package)
36
+ return nil if package.weight.to_f > MAX_WEIGHT_KG
37
+
38
+ dims = estimated_parcel_dimensions_cm(package)
39
+ return nil if dims.nil?
40
+
41
+ dims = apply_packing_margin(dims, package)
42
+
43
+ # allow rotation by sorting dims (smallest->height threshold)
44
+ s, m, d = [dims[:height], dims[:width], dims[:depth]].map(&:to_f).sort
45
+ return nil if s > MAX_HEIGHT_CM || m > MAX_WIDTH_CM || d > MAX_DEPTH_CM
46
+
47
+ size = box_size_for_height(s)
48
+ return nil unless size
49
+
50
+ price_for(size)
51
+ end
52
+
53
+ private
54
+
55
+ # Conservative 1-parcel estimate:
56
+ # - For each item: sort dims s<=m<=d
57
+ # - Stack along smallest side (s) across quantities => parcel height
58
+ # - Width = max(m), Depth = max(d) across all items
59
+ def estimated_parcel_dimensions_cm(package)
60
+ heights = []
61
+ widths = []
62
+ depths = []
63
+
64
+ package.contents.each do |content|
65
+ variant = content.variant
66
+ qty = content.quantity
67
+
68
+ dims = variant_dimensions_cm(variant)
69
+ return nil if dims.nil?
70
+
71
+ s, m, d = dims.sort
72
+ heights << (s * qty)
73
+ widths << m
74
+ depths << d
75
+ end
76
+
77
+ {
78
+ height: heights.sum,
79
+ width: widths.max || 0.0,
80
+ depth: depths.max || 0.0
81
+ }
82
+ end
83
+
84
+ def apply_packing_margin(dims, package)
85
+ factor = multi_item?(package) ? preferred_multi_item_factor : 1.0
86
+
87
+ {
88
+ height: (dims[:height].to_f * factor) + preferred_base_padding_cm,
89
+ width: (dims[:width].to_f * factor) + preferred_base_padding_cm,
90
+ depth: (dims[:depth].to_f * factor) + preferred_base_padding_cm
91
+ }
92
+ end
93
+
94
+ def multi_item?(package)
95
+ package.contents.sum { |c| c.quantity } > 1
96
+ end
97
+
98
+ def variant_dimensions_cm(variant)
99
+ height = variant.height.to_f
100
+ width = variant.width.to_f
101
+ depth = variant.depth.to_f
102
+
103
+ return nil if [height, width, depth].any? { |v| v <= 0 }
104
+
105
+ [height, width, depth]
106
+ end
107
+
108
+ def box_size_for_height(height_cm)
109
+ return :small if height_cm <= SIZE_MAX_HEIGHT_CM[:small]
110
+ return :medium if height_cm <= SIZE_MAX_HEIGHT_CM[:medium]
111
+ return :large if height_cm <= SIZE_MAX_HEIGHT_CM[:large]
112
+ end
113
+
114
+ def price_for(size)
115
+ case size
116
+ when :small then preferred_small_box_price
117
+ when :medium then preferred_medium_box_price
118
+ when :large then preferred_large_box_price
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end