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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.github/stale.yml +17 -0
  3. data/.github/workflows/lint.yml +24 -0
  4. data/.github/workflows/security.yml +41 -0
  5. data/.github/workflows/test.yml +32 -0
  6. data/.gitignore +23 -0
  7. data/.rspec +3 -0
  8. data/.standard.yml +3 -0
  9. data/Appraisals +20 -0
  10. data/CHANGELOG.md +43 -0
  11. data/CLAUDE.md +95 -0
  12. data/Gemfile +14 -0
  13. data/LICENSE +21 -0
  14. data/README.md +120 -0
  15. data/Rakefile +69 -0
  16. data/app/assets/config/spree_shipstation_manifest.js +1 -0
  17. data/app/assets/images/integration_icons/shipstation-logo.webp +0 -0
  18. data/app/controllers/spree/shipstation_controller.rb +57 -0
  19. data/app/helpers/spree/shipstation/export_helper.rb +53 -0
  20. data/app/models/spree/integrations/shipstation.rb +61 -0
  21. data/app/models/spree/shipment_decorator.rb +28 -0
  22. data/app/presenters/spree/shipstation/export/item_presenter.rb +52 -0
  23. data/app/presenters/spree/shipstation/export/order_presenter.rb +91 -0
  24. data/app/presenters/spree/shipstation/export/weight.rb +45 -0
  25. data/app/views/spree/admin/integrations/forms/_shipstation.html.erb +6 -0
  26. data/app/views/spree/shipstation/export.xml.builder +59 -0
  27. data/bin/rails +8 -0
  28. data/config/initializers/spree.rb +3 -0
  29. data/config/locales/en.yml +15 -0
  30. data/config/routes.rb +6 -0
  31. data/gemfiles/spree_5_2.gemfile +18 -0
  32. data/gemfiles/spree_5_3.gemfile +18 -0
  33. data/gemfiles/spree_5_4.gemfile +18 -0
  34. data/lib/spree/shipstation/engine.rb +32 -0
  35. data/lib/spree/shipstation/errors.rb +25 -0
  36. data/lib/spree/shipstation/factories.rb +5 -0
  37. data/lib/spree/shipstation/shipment_notice.rb +68 -0
  38. data/lib/spree/shipstation/testing_support/factories/shipstation_integration.rb +9 -0
  39. data/lib/spree/shipstation/version.rb +12 -0
  40. data/lib/spree/shipstation.rb +11 -0
  41. data/lib/spree-shipstation.rb +8 -0
  42. data/spec/fixtures/shipstation_xml_schema.xsd +171 -0
  43. data/spree-shipstation.gemspec +36 -0
  44. 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
@@ -0,0 +1,3 @@
1
+ --color
2
+ -r spec_helper
3
+ -f documentation
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ ---
2
+ ignore:
3
+ - 'spec/dummy/**/*'
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
+ ![CI](https://github.com/aypex-io/spree-shipstation/workflows/CI/badge.svg)
4
+ ![Standard Rb](https://github.com/aypex-io/spree-shipstation/workflows/Standard%20Rb/badge.svg)
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
@@ -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