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.
- checksums.yaml +7 -0
- data/README.md +225 -0
- data/Rakefile +21 -0
- data/app/assets/config/spree_boxnow_manifest.js +3 -0
- data/app/assets/images/integration_icons/boxnow-logo.png +0 -0
- data/app/controllers/spree/admin/boxnow_controller.rb +126 -0
- data/app/controllers/spree/boxnow_controller.rb +24 -0
- data/app/javascript/spree_boxnow/application.js +16 -0
- data/app/javascript/spree_boxnow/controllers/spree_boxnow_controller.js +34 -0
- data/app/lib/spree_boxnow/api_client.rb +108 -0
- data/app/models/spree/calculator/shipping/boxnow_rate.rb +123 -0
- data/app/models/spree/integrations/boxnow.rb +30 -0
- data/app/models/spree/order_decorator.rb +17 -0
- data/app/models/spree/shipment_decorator.rb +17 -0
- data/app/services/spree_boxnow/cancel_voucher.rb +24 -0
- data/app/services/spree_boxnow/create_voucher.rb +79 -0
- data/app/services/spree_boxnow/print_vouchers.rb +17 -0
- data/app/views/spree/admin/integrations/forms/_boxnow.html.erb +14 -0
- data/app/views/spree/admin/orders/_shipment.html.erb +198 -0
- data/app/views/spree/admin/shipping_methods/_boxnow_form.html.erb +13 -0
- data/app/views/spree/checkout/_delivery_shipping_rate.html.erb +31 -0
- data/app/views/spree_boxnow/_head.html.erb +1 -0
- data/app/views/spree_boxnow/_locker_picker.html.erb +101 -0
- data/app/views/spree_boxnow/_order_dropdown_options.html.erb +38 -0
- data/config/importmap.rb +6 -0
- data/config/initializers/spree.rb +12 -0
- data/config/locales/el.yml +74 -0
- data/config/locales/en.yml +74 -0
- data/config/routes.rb +10 -0
- data/db/migrate/20260210120618_add_boxnow_to_spree_shipping_methods.rb +5 -0
- data/lib/generators/spree_boxnow/install/install_generator.rb +20 -0
- data/lib/spree_boxnow/configuration.rb +13 -0
- data/lib/spree_boxnow/engine.rb +39 -0
- data/lib/spree_boxnow/factories.rb +10 -0
- data/lib/spree_boxnow/version.rb +7 -0
- data/lib/spree_boxnow.rb +11 -0
- 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
|
+
[](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
|
|
Binary file
|
|
@@ -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
|