spree-shipstation 5.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/.github/stale.yml +17 -0
- data/.github/workflows/lint.yml +24 -0
- data/.github/workflows/security.yml +41 -0
- data/.github/workflows/test.yml +32 -0
- data/.gitignore +23 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/Appraisals +20 -0
- data/CHANGELOG.md +43 -0
- data/CLAUDE.md +95 -0
- data/Gemfile +14 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/Rakefile +69 -0
- data/app/assets/config/spree_shipstation_manifest.js +1 -0
- data/app/assets/images/integration_icons/shipstation-logo.webp +0 -0
- data/app/controllers/spree/shipstation_controller.rb +57 -0
- data/app/helpers/spree/shipstation/export_helper.rb +53 -0
- data/app/models/spree/integrations/shipstation.rb +61 -0
- data/app/models/spree/shipment_decorator.rb +28 -0
- data/app/presenters/spree/shipstation/export/item_presenter.rb +52 -0
- data/app/presenters/spree/shipstation/export/order_presenter.rb +91 -0
- data/app/presenters/spree/shipstation/export/weight.rb +45 -0
- data/app/views/spree/admin/integrations/forms/_shipstation.html.erb +6 -0
- data/app/views/spree/shipstation/export.xml.builder +59 -0
- data/bin/rails +8 -0
- data/config/initializers/spree.rb +3 -0
- data/config/locales/en.yml +15 -0
- data/config/routes.rb +6 -0
- data/gemfiles/spree_5_2.gemfile +18 -0
- data/gemfiles/spree_5_3.gemfile +18 -0
- data/gemfiles/spree_5_4.gemfile +18 -0
- data/lib/spree/shipstation/engine.rb +32 -0
- data/lib/spree/shipstation/errors.rb +25 -0
- data/lib/spree/shipstation/factories.rb +5 -0
- data/lib/spree/shipstation/shipment_notice.rb +68 -0
- data/lib/spree/shipstation/testing_support/factories/shipstation_integration.rb +9 -0
- data/lib/spree/shipstation/version.rb +12 -0
- data/lib/spree/shipstation.rb +11 -0
- data/lib/spree-shipstation.rb +8 -0
- data/spec/fixtures/shipstation_xml_schema.xsd +171 -0
- data/spree-shipstation.gemspec +36 -0
- metadata +140 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 30a88f9939731dc679980577dc0b23a91fdbda25c4019063b3f4728dfb2fe5a9
|
|
4
|
+
data.tar.gz: d410ae3a207dd0051ab938c3890c60c515b403723ac43392ede48ab872352ca1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f1d456fbe2b2e4a682b7cc2b62e1c07faad779b449bd8f2da72e5a2ce67b9f133b8cb23a214bb62dfd2ba4d1429a6b1b6fc8839597feb3e445a31fd419de5869
|
|
7
|
+
data.tar.gz: ce40b12b11650ef36d848a709a4a0bd513c654a9ca6da8659728e178dd82cdba573504dbaf7ce981ef67509b986d0a11f47157f21280894dec8c90f2fae56769
|
data/.github/stale.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Number of days of inactivity before an issue becomes stale
|
|
2
|
+
daysUntilStale: 60
|
|
3
|
+
# Number of days of inactivity before a stale issue is closed
|
|
4
|
+
daysUntilClose: 7
|
|
5
|
+
# Issues with these labels will never be considered stale
|
|
6
|
+
exemptLabels:
|
|
7
|
+
- pinned
|
|
8
|
+
- security
|
|
9
|
+
# Label to use when marking an issue as stale
|
|
10
|
+
staleLabel: wontfix
|
|
11
|
+
# Comment to post when marking an issue as stale. Set to `false` to disable
|
|
12
|
+
markComment: >
|
|
13
|
+
This issue has been automatically marked as stale because it has not had
|
|
14
|
+
recent activity. It will be closed if no further activity occurs. Thank you
|
|
15
|
+
for your contributions.
|
|
16
|
+
# Comment to post when closing a stale issue. Set to `false` to disable
|
|
17
|
+
closeComment: false
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Standard Rb
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches:
|
|
6
|
+
- '*'
|
|
7
|
+
push:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
jobs:
|
|
11
|
+
standard:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
ruby: ['3.4']
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v2
|
|
18
|
+
- name: Set up Ruby ${{ matrix.ruby }}
|
|
19
|
+
uses: ruby/setup-ruby@v1
|
|
20
|
+
with:
|
|
21
|
+
ruby-version: ${{ matrix.ruby }}
|
|
22
|
+
bundler-cache: true
|
|
23
|
+
- name: Run Standard Rb
|
|
24
|
+
run: bundle exec standardrb --format progress
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Security
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches:
|
|
6
|
+
- '*'
|
|
7
|
+
push:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
jobs:
|
|
11
|
+
brakeman:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
ruby: ['3.4']
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v6
|
|
18
|
+
- name: Set up Ruby ${{ matrix.ruby }}
|
|
19
|
+
uses: ruby/setup-ruby@v1
|
|
20
|
+
with:
|
|
21
|
+
ruby-version: ${{ matrix.ruby }}
|
|
22
|
+
bundler-cache: true
|
|
23
|
+
- name: Run Brakeman
|
|
24
|
+
# --force scans the engine even though it is not a full Rails app;
|
|
25
|
+
# --exit-on-warn fails the build when any security warning is found.
|
|
26
|
+
run: bundle exec brakeman --force --no-pager --quiet --exit-on-warn
|
|
27
|
+
|
|
28
|
+
bundler-audit:
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
strategy:
|
|
31
|
+
matrix:
|
|
32
|
+
ruby: ['3.4']
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v6
|
|
35
|
+
- name: Set up Ruby ${{ matrix.ruby }}
|
|
36
|
+
uses: ruby/setup-ruby@v1
|
|
37
|
+
with:
|
|
38
|
+
ruby-version: ${{ matrix.ruby }}
|
|
39
|
+
bundler-cache: true
|
|
40
|
+
- name: Run bundler-audit
|
|
41
|
+
run: bundle exec bundle-audit check --update
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: CI
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches:
|
|
6
|
+
- '*'
|
|
7
|
+
push:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
jobs:
|
|
11
|
+
sqlite:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
ruby: ['3.4']
|
|
17
|
+
gemfile: [spree_5_2, spree_5_3, spree_5_4]
|
|
18
|
+
env:
|
|
19
|
+
BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile
|
|
20
|
+
BUNDLE_PATH_RELATIVE_TO_CWD: true
|
|
21
|
+
RAILS_ENV: test
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v6
|
|
24
|
+
- name: Set up Ruby ${{ matrix.ruby }}
|
|
25
|
+
uses: ruby/setup-ruby@v1
|
|
26
|
+
with:
|
|
27
|
+
ruby-version: ${{ matrix.ruby }}
|
|
28
|
+
bundler-cache: false
|
|
29
|
+
- name: Bundle Install
|
|
30
|
+
run: bundle install
|
|
31
|
+
- name: Run Tests
|
|
32
|
+
run: bundle exec rake
|
data/.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
\#*
|
|
2
|
+
*~
|
|
3
|
+
.#*
|
|
4
|
+
.DS_Store
|
|
5
|
+
.idea
|
|
6
|
+
.localeapp/locales
|
|
7
|
+
.project
|
|
8
|
+
.vscode
|
|
9
|
+
coverage
|
|
10
|
+
default
|
|
11
|
+
Gemfile.lock
|
|
12
|
+
tmp
|
|
13
|
+
nbproject
|
|
14
|
+
pkg
|
|
15
|
+
*.sw?
|
|
16
|
+
spec/dummy
|
|
17
|
+
.rvmrc
|
|
18
|
+
.sass-cache
|
|
19
|
+
public/spree
|
|
20
|
+
.ruby-version
|
|
21
|
+
.ruby-gemset
|
|
22
|
+
gemfiles/*.gemfile.lock
|
|
23
|
+
*.gem
|
data/.rspec
ADDED
data/.standard.yml
ADDED
data/Appraisals
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
appraise "spree-5-2" do
|
|
2
|
+
spree = "~> 5.2.0"
|
|
3
|
+
|
|
4
|
+
gem "spree", spree
|
|
5
|
+
gem "spree_admin", spree
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
appraise "spree-5-3" do
|
|
9
|
+
spree = "~> 5.3.0"
|
|
10
|
+
|
|
11
|
+
gem "spree", spree
|
|
12
|
+
gem "spree_admin", spree
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
appraise "spree-5-4" do
|
|
16
|
+
spree = "~> 5.4.0"
|
|
17
|
+
|
|
18
|
+
gem "spree", spree
|
|
19
|
+
gem "spree_admin", spree
|
|
20
|
+
end
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
## 5.0.0
|
|
6
|
+
|
|
7
|
+
First public release on RubyGems, as `spree-shipstation`.
|
|
8
|
+
|
|
9
|
+
This gem was developed under the working name `spree_shipstation` but was never
|
|
10
|
+
published under it — that name is held on RubyGems by an unrelated, abandoned
|
|
11
|
+
2014 project. Releasing as `spree-shipstation` also lets the gem name, the require
|
|
12
|
+
path, and the Ruby namespace agree with each other, per the RubyGems convention
|
|
13
|
+
that a dash denotes a gem living under another gem's namespace.
|
|
14
|
+
|
|
15
|
+
**Versioning:** the major version tracks Spree's major version — `spree-shipstation`
|
|
16
|
+
5.x supports Spree 5.x. This release therefore starts at 5.0.0 rather than 1.0.0,
|
|
17
|
+
and continues the repository's existing tag lineage (which reached `v3.0.0` before
|
|
18
|
+
the rename).
|
|
19
|
+
|
|
20
|
+
If you tracked this repository from git before 5.0.0, note:
|
|
21
|
+
|
|
22
|
+
- **Gem name** is `spree-shipstation`. `gem "spree-shipstation"` is all a host app
|
|
23
|
+
needs — `Bundler.require` resolves through a shim at `lib/spree-shipstation.rb`.
|
|
24
|
+
- **Require path** is now `spree/shipstation` (was `spree_shipstation`).
|
|
25
|
+
- **Ruby namespace** is now `Spree::Shipstation` (was `SpreeShipstation`). This
|
|
26
|
+
affects `Spree::Shipstation::ShipmentNotice`, the error hierarchy
|
|
27
|
+
(`Spree::Shipstation::Error` and subclasses), `Spree::Shipstation::ExportHelper`,
|
|
28
|
+
and `Spree::Shipstation::Export::{OrderPresenter,ItemPresenter,Weight}`.
|
|
29
|
+
- **Test factories** are loaded with `require "spree/shipstation/factories"`.
|
|
30
|
+
- **Unchanged:** the `Spree::Integrations::Shipstation` integration model,
|
|
31
|
+
`Spree::ShipstationController`, both `/shipstation` routes, the admin form
|
|
32
|
+
partial, and all i18n keys. Existing installations need no data or config
|
|
33
|
+
changes.
|
|
34
|
+
|
|
35
|
+
### Features
|
|
36
|
+
|
|
37
|
+
- XML export endpoint (`GET /shipstation`) for ShipStation to poll ready shipments,
|
|
38
|
+
paginated at 50 per page and validated against ShipStation's XML schema.
|
|
39
|
+
- Shipnotify webhook (`POST /shipstation`) that applies tracking numbers and ships
|
|
40
|
+
shipments, capturing pending payments first when `auto_capture_on_dispatch` is on.
|
|
41
|
+
- HTTP Basic Auth against per-store credentials, compared in constant time.
|
|
42
|
+
- Registers with Spree's integration framework; no migrations or generators to run.
|
|
43
|
+
- Tested against Spree 5.2, 5.3, and 5.4.
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`spree-shipstation` is a Spree e-commerce extension (gem) that integrates Spree stores with [ShipStation](https://www.shipstation.com). It exposes an XML endpoint that ShipStation polls for shipments, and a webhook endpoint that ShipStation POSTs to when a label is created (updating the Spree shipment with a tracking number and marking it shipped).
|
|
8
|
+
|
|
9
|
+
This gem targets **Spree 5.x** and uses the `spree_extension` framework for integration registration.
|
|
10
|
+
|
|
11
|
+
### Naming
|
|
12
|
+
|
|
13
|
+
Three related but distinct names — do not "fix" one to match another:
|
|
14
|
+
|
|
15
|
+
| | Value | Why |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| Gem name | `spree-shipstation` | Dash denotes a gem under another gem's namespace |
|
|
18
|
+
| Require path | `spree/shipstation` | `lib/spree-shipstation.rb` is a shim so Bundler's auto-require of the gem *name* still works |
|
|
19
|
+
| Ruby namespace | `Spree::Shipstation` | Matches the require path |
|
|
20
|
+
| `engine_name` | `spree_shipstation` | Generates route helper prefixes, so it **must** be a valid Ruby identifier — a dash is illegal here |
|
|
21
|
+
| Asset manifest | `app/assets/config/spree_shipstation_manifest.js` | Referenced by name in the engine's `assets` initializer |
|
|
22
|
+
|
|
23
|
+
`ENV["LIB_NAME"]` in the `Rakefile` must be the **require path** (`spree/shipstation`), not the gem name: Spree's `common:test_app` does a literal `require ENV['LIB_NAME']` and templates the same string into the generated `spec/dummy/config/application.rb`. Setting it to the gem name makes dummy-app generation fail with a `LoadError` that surfaces as the entire suite erroring.
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
### Run all tests
|
|
28
|
+
```shell
|
|
29
|
+
bundle exec rake
|
|
30
|
+
```
|
|
31
|
+
The default Rake task automatically generates a dummy Rails app under `spec/dummy/` if it doesn't exist, then runs the full spec suite.
|
|
32
|
+
|
|
33
|
+
### Generate the test dummy app (first time or after reset)
|
|
34
|
+
```shell
|
|
35
|
+
bundle exec rake test_app
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Run a single spec file
|
|
39
|
+
```shell
|
|
40
|
+
bundle exec rspec spec/controllers/spree/shipstation_controller_spec.rb
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Lint (StandardRB)
|
|
44
|
+
```shell
|
|
45
|
+
bundle exec standardrb
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Auto-fix lint issues
|
|
49
|
+
```shell
|
|
50
|
+
bundle exec standardrb --fix
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Architecture
|
|
54
|
+
|
|
55
|
+
### Integration Registration
|
|
56
|
+
|
|
57
|
+
The extension registers itself with Spree's integration framework via `config/initializers/spree.rb`, which appends `Spree::Integrations::Shipstation` to `spree.integrations`. The integration model (`app/models/spree/integrations/shipstation.rb`) extends `Spree::Integration` and stores credentials as Spree preferences (`preferred_username`, `preferred_password`) with validations.
|
|
58
|
+
|
|
59
|
+
### Request Flow
|
|
60
|
+
|
|
61
|
+
**Export (GET `/shipstation`)** — ShipStation polls this endpoint to fetch shipments ready to process:
|
|
62
|
+
1. `Spree::ShipstationController#export` authenticates via HTTP Basic Auth using credentials stored on the active store integration.
|
|
63
|
+
2. Queries `current_store.shipments.exportable` — a scope added by `Spree::ShipmentDecorator` that filters for `state: "ready"` on complete orders.
|
|
64
|
+
3. Filters by `start_date`/`end_date` params (matching either shipment or order `updated_at`). If both params are absent or invalid, all exportable shipments are returned with no date filter.
|
|
65
|
+
4. Renders `app/views/spree/shipstation/export.xml.builder` using the `builder` gem. XML structure is validated against `spec/fixtures/shipstation_xml_schema.xsd` in tests.
|
|
66
|
+
5. Results are paginated at 50 per page.
|
|
67
|
+
|
|
68
|
+
**Shipnotify (POST `/shipstation`)** — ShipStation calls this when a label is created:
|
|
69
|
+
1. `Spree::ShipstationController#shipnotify` passes `order_number` and `tracking_number` params to `Spree::Shipstation::ShipmentNotice.from_payload`.
|
|
70
|
+
2. `ShipmentNotice#apply` looks up the `Spree::Shipment` by number on `current_store` and, inside a transaction: captures pending payments if `Spree::Config.auto_capture_on_dispatch` is on, sets the tracking number, saves, then calls `ship!` unless already shipped.
|
|
71
|
+
3. Errors from `Spree::Shipstation::Error` subclasses return HTTP 400; successes return HTTP 200. The webhook is written to be safe for ShipStation to retry.
|
|
72
|
+
|
|
73
|
+
> **Note on naming:** ShipStation's webhook param is called `order_number` but its value is actually the *shipment* number — it mirrors the `<OrderNumber>` field from the export XML, which is set to `shipment.number`.
|
|
74
|
+
|
|
75
|
+
### Key Files
|
|
76
|
+
|
|
77
|
+
| File | Purpose |
|
|
78
|
+
|------|---------|
|
|
79
|
+
| `app/controllers/spree/shipstation_controller.rb` | Both endpoints; auth (constant-time `secure_compare`) + integration guard |
|
|
80
|
+
| `app/models/spree/integrations/shipstation.rb` | Integration model with credential preferences and validations |
|
|
81
|
+
| `app/models/spree/shipment_decorator.rb` | Adds `:exportable` and `:between` scopes to `Spree::Shipment` |
|
|
82
|
+
| `lib/spree/shipstation/shipment_notice.rb` | Plain Ruby object that applies a ship notification |
|
|
83
|
+
| `lib/spree/shipstation/errors.rb` | Custom error hierarchy (`ShipmentNotFoundError`, `PaymentError`, `MissingTrackingNumberError`) |
|
|
84
|
+
| `lib/spree/shipstation/engine.rb` | Engine; note `activate` globs `../../../app` — three levels up from `lib/spree/shipstation/` |
|
|
85
|
+
| `lib/spree-shipstation.rb` | Bundler auto-require shim; only does `require "spree/shipstation"` |
|
|
86
|
+
| `app/helpers/spree/shipstation/export_helper.rb` | Helper methods for building address XML nodes |
|
|
87
|
+
| `app/views/spree/shipstation/export.xml.builder` | XML template for ShipStation export |
|
|
88
|
+
| `app/views/spree/admin/integrations/forms/_shipstation.html.erb` | Admin UI partial for configuring credentials |
|
|
89
|
+
|
|
90
|
+
### Testing Helpers
|
|
91
|
+
|
|
92
|
+
- `spec/support/auth_helper.rb` — provides `stub_basic_auth(username, password)` for controller specs.
|
|
93
|
+
- `spec/support/xsd.rb` — provides the `pass_validation(xsd_path)` RSpec matcher for XML schema validation.
|
|
94
|
+
- `spec/support/shipment_helper.rb` — shipment-related test helpers.
|
|
95
|
+
- `lib/spree/shipstation/testing_support/factories/shipstation_integration.rb` — FactoryBot factory for `Spree::Integrations::Shipstation`, loaded via `require "spree/shipstation/factories"` in `spec/spec_helper.rb`.
|
data/Gemfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
source "https://rubygems.org"
|
|
2
|
+
|
|
3
|
+
gem "appraisal"
|
|
4
|
+
gem "benchmark"
|
|
5
|
+
gem "brakeman", require: false
|
|
6
|
+
gem "bundler-audit", require: false
|
|
7
|
+
gem "propshaft"
|
|
8
|
+
gem "rails-controller-testing"
|
|
9
|
+
gem "rspec-xsd"
|
|
10
|
+
gem "spree_dev_tools"
|
|
11
|
+
gem "standard"
|
|
12
|
+
gem "sqlite3"
|
|
13
|
+
|
|
14
|
+
gemspec
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021-2026 Matthew Kennedy
|
|
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,120 @@
|
|
|
1
|
+
# spree-shipstation
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
The spree-shipstation integration connects your Spree stores with [ShipStation](https://www.shipstation.com), allowing ShipStation to pull shipments from your store, and when a shipment is sent, update the order with a tracking number and mark it as shipped.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
1. Add this extension to your Gemfile with this line:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "spree-shipstation"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
2. Install the gem using Bundler
|
|
18
|
+
|
|
19
|
+
```shell
|
|
20
|
+
bundle install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The extension registers itself with Spree's integration framework automatically — there are no migrations or generators to run.
|
|
24
|
+
|
|
25
|
+
### Step 1: Configuring Spree
|
|
26
|
+
|
|
27
|
+
Visit the **Integrations** section of your Spree store and configure the ShipStation integration by creating a unique username and password.
|
|
28
|
+
|
|
29
|
+
### Step 2: Configuring ShipStation
|
|
30
|
+
|
|
31
|
+
Create a new ShipStation store by visiting: **Settings** -> **Selling Channels** -> **Stores** -> **Add Store**, then selecting the **Custom Store** option.
|
|
32
|
+
|
|
33
|
+
Enter the following details:
|
|
34
|
+
|
|
35
|
+
- **Username**: The username you created in Step 1.
|
|
36
|
+
- **Password**: The password you created in Step 1.
|
|
37
|
+
- **URL to custom page**: `https://your-store-domain.com/shipstation.xml`.
|
|
38
|
+
|
|
39
|
+
There are five shipment states for an order (= shipment) in ShipStation. These states do not
|
|
40
|
+
necessarily align with Spree, but you can configure ShipStation to create a mapping for your
|
|
41
|
+
specific needs. Here's the default mapping:
|
|
42
|
+
|
|
43
|
+
ShipStation description | ShipStation status | Spree status
|
|
44
|
+
------------------------|--------------------|---------------
|
|
45
|
+
Awaiting Payment | `unpaid` | `pending`
|
|
46
|
+
Awaiting Shipment | `paid` | `ready`
|
|
47
|
+
Shipped | `shipped` | `shipped`
|
|
48
|
+
Cancelled | `cancelled` | `cancelled`
|
|
49
|
+
On-Hold | `on-hold` | `pending`
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
### Payment capture on dispatch
|
|
54
|
+
|
|
55
|
+
The integration respects Spree's `auto_capture_on_dispatch` setting. When enabled in your Spree store, pending payments are captured automatically before a shipment is marked as shipped. If a payment capture fails, an error is returned to ShipStation (HTTP 400), preventing the shipment from being marked as shipped until the issue is resolved.
|
|
56
|
+
|
|
57
|
+
Payment capture happens **synchronously** within the shipnotify webhook request (inside a database transaction) so that a shipment is never marked as shipped against an uncaptured payment. ShipStation automatically retries failed webhook deliveries, and the operation is designed to be safe to repeat: a shipment that is already `shipped` is not shipped again, and only still-pending payments are captured. If your payment gateway is slow, be aware that the capture round-trip occurs in the request cycle.
|
|
58
|
+
|
|
59
|
+
### Pagination
|
|
60
|
+
|
|
61
|
+
The export endpoint returns up to **50 shipments per page**. ShipStation handles pagination automatically using the `page` query parameter.
|
|
62
|
+
|
|
63
|
+
## Security considerations
|
|
64
|
+
|
|
65
|
+
- **Serve the endpoints over HTTPS.** Both `/shipstation` endpoints authenticate with HTTP Basic Auth, which transmits the configured username and password (base64-encoded) on every request. Always terminate these requests over TLS in production so the credentials are not exposed in transit. Credentials are compared in constant time to avoid timing attacks.
|
|
66
|
+
- **Consider rate limiting.** The gem does not throttle authentication attempts. If you want brute-force protection, add it at the application or edge layer (for example, [`rack-attack`](https://github.com/rack/rack-attack)). The enforced credential length (10–30 character username, 20–60 character complex password) already makes guessing impractical.
|
|
67
|
+
- **Response codes.** When no active ShipStation integration is configured, the endpoints respond with `404` before authentication is checked; a configured-but-unauthenticated request responds with `401`. This is intentional, but be aware it reveals whether the integration is configured.
|
|
68
|
+
|
|
69
|
+
## Performance
|
|
70
|
+
|
|
71
|
+
The export query eager-loads its association graph to avoid N+1 queries. The `exportable` scope filters shipments by `state` and orders them by `updated_at`, joining and filtering orders by their `updated_at`. The gem ships no migrations of its own; on large stores, ensure the relevant Spree core columns (`spree_shipments.state`, `spree_shipments.updated_at`, `spree_orders.updated_at`) are adequately indexed in your application's database.
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
There's nothing you need to do. Once properly configured, the integration just works!
|
|
76
|
+
|
|
77
|
+
### Compatibility
|
|
78
|
+
|
|
79
|
+
This extension works with the following Spree versions:
|
|
80
|
+
- 5.x
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
### Testing
|
|
84
|
+
|
|
85
|
+
First bundle your dependencies:
|
|
86
|
+
|
|
87
|
+
```shell
|
|
88
|
+
bundle
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
To run the tests use:
|
|
92
|
+
|
|
93
|
+
```shell
|
|
94
|
+
bundle exec rake
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Code Formatting
|
|
98
|
+
|
|
99
|
+
To check your code formatting with [Standard Rb](https://github.com/testdouble/standard) run:
|
|
100
|
+
|
|
101
|
+
```shell
|
|
102
|
+
bundle exec standardrb
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
To fix basic code formatting issues run:
|
|
106
|
+
|
|
107
|
+
```shell
|
|
108
|
+
bundle exec standardrb --fix
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Releasing
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
bundle exec gem bump -p -t
|
|
115
|
+
bundle exec gem release
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
Copyright (c) 2021-2026 Matthew Kennedy, released under the MIT License.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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["dummy:verify_schema"].invoke
|
|
15
|
+
Rake::Task[:spec].invoke
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "Generates a dummy app for testing"
|
|
19
|
+
task :test_app do
|
|
20
|
+
# Must be the require path, not the gem name: Spree's common:test_app does a
|
|
21
|
+
# literal `require ENV['LIB_NAME']` and templates the same string into the
|
|
22
|
+
# generated spec/dummy/config/application.rb.
|
|
23
|
+
ENV["LIB_NAME"] = "spree/shipstation"
|
|
24
|
+
Rake::Task["extension:test_app"].execute(
|
|
25
|
+
install_admin: true
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
namespace :dummy do
|
|
30
|
+
# Spree's test_app task runs `db:migrate` in the generator's own process with
|
|
31
|
+
# output redirected to /dev/null and its exit status ignored. On the heavier
|
|
32
|
+
# Spree 5.3+/5.4 setup path (admin install + Tailwind/asset build) that step
|
|
33
|
+
# can be interrupted mid schema-dump, leaving a TRUNCATED db/schema.rb. Rails
|
|
34
|
+
# then loads that broken dump on the next migrate and fails, so the dummy
|
|
35
|
+
# database ends up empty and the whole suite errors with
|
|
36
|
+
# "Could not find table 'spree_*'".
|
|
37
|
+
#
|
|
38
|
+
# Make setup deterministic: discard any (possibly corrupt) schema dump and
|
|
39
|
+
# rebuild the database from the migration files, in a fresh process and
|
|
40
|
+
# visibly. Fail fast with the real error if migration genuinely fails.
|
|
41
|
+
desc "Ensure the dummy app database schema is fully migrated"
|
|
42
|
+
task :verify_schema do
|
|
43
|
+
dummy_path = File.expand_path("spec/dummy", __dir__)
|
|
44
|
+
next unless File.directory?(dummy_path)
|
|
45
|
+
|
|
46
|
+
# Keep using the gem's bundle after the chdir below.
|
|
47
|
+
ENV["BUNDLE_GEMFILE"] = File.expand_path(ENV["BUNDLE_GEMFILE"], __dir__) if ENV["BUNDLE_GEMFILE"]
|
|
48
|
+
ENV["RAILS_ENV"] = "test"
|
|
49
|
+
|
|
50
|
+
Dir.chdir(dummy_path) do
|
|
51
|
+
puts "Rebuilding dummy app database schema..."
|
|
52
|
+
|
|
53
|
+
# Remove a potentially-truncated schema dump so the migration files (the
|
|
54
|
+
# source of truth) are used to build the schema from scratch.
|
|
55
|
+
schema = File.join(dummy_path, "db", "schema.rb")
|
|
56
|
+
File.delete(schema) if File.exist?(schema)
|
|
57
|
+
|
|
58
|
+
# Separate invocations on purpose: chaining db:create and db:migrate in a
|
|
59
|
+
# single `rails` call can leave migrate reading a stale schema state.
|
|
60
|
+
system("bundle exec rails db:drop db:create")
|
|
61
|
+
migrated = system("bundle exec rails db:migrate")
|
|
62
|
+
|
|
63
|
+
unless migrated
|
|
64
|
+
abort "Dummy app database setup failed; aborting before running specs. " \
|
|
65
|
+
"See the migration output above for the underlying error."
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//= link_tree ../images
|
|
Binary file
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
class ShipstationController < Spree::BaseController
|
|
5
|
+
include Spree::IntegrationsHelper
|
|
6
|
+
include Pagy::Method
|
|
7
|
+
|
|
8
|
+
protect_from_forgery with: :null_session, only: :shipnotify
|
|
9
|
+
|
|
10
|
+
before_action :ensure_active_integration
|
|
11
|
+
before_action :authenticate_shipstation
|
|
12
|
+
|
|
13
|
+
def export
|
|
14
|
+
@pagy, @shipments = pagy(
|
|
15
|
+
current_store.shipments
|
|
16
|
+
.exportable
|
|
17
|
+
.between(date_param(:start_date), date_param(:end_date)),
|
|
18
|
+
page: params[:page],
|
|
19
|
+
items: 50
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
respond_to do |format|
|
|
23
|
+
format.xml { render layout: false }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def shipnotify
|
|
28
|
+
Shipstation::ShipmentNotice.from_payload(params.permit(:order_number, :tracking_number).to_h, store: current_store).apply
|
|
29
|
+
head :ok
|
|
30
|
+
rescue Shipstation::Error => e
|
|
31
|
+
Rails.logger.error("ShipStation Notification Error: #{e.message}")
|
|
32
|
+
render plain: e.message, status: :bad_request
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def ensure_active_integration
|
|
38
|
+
head :not_found unless store_integration("shipstation")&.active?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def date_param(name)
|
|
42
|
+
return if params[name].blank?
|
|
43
|
+
|
|
44
|
+
Time.strptime("#{params[name]} UTC", "%m/%d/%Y %H:%M %Z")
|
|
45
|
+
rescue ArgumentError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def authenticate_shipstation
|
|
50
|
+
authenticate_or_request_with_http_basic do |username, password|
|
|
51
|
+
integration = store_integration("shipstation")
|
|
52
|
+
ActiveSupport::SecurityUtils.secure_compare(username.to_s, integration.preferred_username.to_s) &&
|
|
53
|
+
ActiveSupport::SecurityUtils.secure_compare(password.to_s, integration.preferred_password.to_s)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "builder"
|
|
4
|
+
|
|
5
|
+
module Spree
|
|
6
|
+
module Shipstation
|
|
7
|
+
module ExportHelper
|
|
8
|
+
DATE_FORMAT = "%m/%d/%Y %H:%M"
|
|
9
|
+
|
|
10
|
+
def self.bill_address(xml, address)
|
|
11
|
+
render_address(xml, address, "BillTo", include_street: false)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.ship_address(xml, address)
|
|
15
|
+
render_address(xml, address, "ShipTo", include_street: true)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def render_address(xml, address, tag_name, include_street:)
|
|
22
|
+
return unless address
|
|
23
|
+
|
|
24
|
+
xml.tag!(tag_name) do
|
|
25
|
+
xml.Name name_from(address)
|
|
26
|
+
xml.Company address.company
|
|
27
|
+
|
|
28
|
+
if include_street
|
|
29
|
+
xml.Address1 address.address1
|
|
30
|
+
xml.Address2 address.address2
|
|
31
|
+
xml.City address.city
|
|
32
|
+
xml.State state_from(address)
|
|
33
|
+
xml.PostalCode address.zipcode
|
|
34
|
+
xml.Country address.country&.iso
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
xml.Phone address.phone
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def name_from(address)
|
|
42
|
+
return address.name if address.respond_to?(:name)
|
|
43
|
+
|
|
44
|
+
address.try(:full_name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def state_from(address)
|
|
48
|
+
address.state&.abbr || address.state_name
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|