spree_square 0.1.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 (88) hide show
  1. checksums.yaml +7 -0
  2. data/.env +7 -0
  3. data/.gem_release.yml +3 -0
  4. data/.github/.dependabot.yml +11 -0
  5. data/.github/workflows/tests.yml +107 -0
  6. data/.gitignore +23 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +24 -0
  9. data/CHANGELOG.md +18 -0
  10. data/CONTRIBUTING.md +29 -0
  11. data/Gemfile +28 -0
  12. data/LICENSE.md +9 -0
  13. data/README.md +105 -0
  14. data/Rakefile +23 -0
  15. data/app/.gitkeep +0 -0
  16. data/app/assets/config/spree_square_manifest.js +5 -0
  17. data/app/assets/images/.keep +0 -0
  18. data/app/controllers/spree/admin/square_oauth_controller.rb +103 -0
  19. data/app/controllers/spree/admin/square_order_mappings_controller.rb +12 -0
  20. data/app/controllers/spree/admin/square_webhook_events_controller.rb +11 -0
  21. data/app/controllers/spree_square/webhooks_controller.rb +56 -0
  22. data/app/javascript/spree_square/application.js +16 -0
  23. data/app/javascript/spree_square/controllers/spree_square_controller.js +7 -0
  24. data/app/jobs/spree_square/base_job.rb +5 -0
  25. data/app/jobs/spree_square/catalog_webhook_job.rb +20 -0
  26. data/app/jobs/spree_square/inventory_webhook_job.rb +27 -0
  27. data/app/jobs/spree_square/order_push_job.rb +23 -0
  28. data/app/jobs/spree_square/order_webhook_job.rb +51 -0
  29. data/app/jobs/spree_square/reconciliation_job.rb +36 -0
  30. data/app/models/spree/line_item_decorator.rb +55 -0
  31. data/app/models/spree/variant_decorator.rb +19 -0
  32. data/app/models/spree_square/catalog_mapping.rb +25 -0
  33. data/app/models/spree_square/credential.rb +41 -0
  34. data/app/models/spree_square/line_item_modifier.rb +24 -0
  35. data/app/models/spree_square/location_mapping.rb +14 -0
  36. data/app/models/spree_square/modifier.rb +14 -0
  37. data/app/models/spree_square/modifier_list.rb +23 -0
  38. data/app/models/spree_square/order_mapping.rb +15 -0
  39. data/app/models/spree_square/product_modifier_list.rb +12 -0
  40. data/app/models/spree_square/taxon_mapping.rb +25 -0
  41. data/app/models/spree_square/webhook_event.rb +21 -0
  42. data/app/serializers/spree_square/line_item_serializer.rb +13 -0
  43. data/app/serializers/spree_square/product_serializer.rb +47 -0
  44. data/app/services/spree_square/alerting.rb +17 -0
  45. data/app/services/spree_square/cart/add_item.rb +53 -0
  46. data/app/services/spree_square/catalog_importer.rb +57 -0
  47. data/app/services/spree_square/catalog_object_mapper.rb +226 -0
  48. data/app/services/spree_square/client.rb +116 -0
  49. data/app/services/spree_square/find_line_item_by_variant.rb +21 -0
  50. data/app/services/spree_square/inventory_sync.rb +29 -0
  51. data/app/services/spree_square/oauth_client.rb +116 -0
  52. data/app/services/spree_square/order_builder.rb +79 -0
  53. data/app/services/spree_square/order_pusher.rb +68 -0
  54. data/app/services/spree_square/order_status_mapper.rb +87 -0
  55. data/app/services/spree_square/revalidator.rb +55 -0
  56. data/app/services/spree_square/webhook_verifier.rb +21 -0
  57. data/app/subscribers/spree_square/order_completed_subscriber.rb +15 -0
  58. data/app/views/spree/admin/square_oauth/show.html.erb +63 -0
  59. data/app/views/spree/admin/square_order_mappings/index.html.erb +5 -0
  60. data/app/views/spree/admin/square_webhook_events/index.html.erb +5 -0
  61. data/bin/importmap +9 -0
  62. data/bin/rails +8 -0
  63. data/config/importmap.rb +6 -0
  64. data/config/initializers/spree.rb +45 -0
  65. data/config/initializers/spree_admin_square_navigation.rb +25 -0
  66. data/config/initializers/spree_admin_square_tables.rb +86 -0
  67. data/config/locales/en.yml +5 -0
  68. data/config/routes.rb +25 -0
  69. data/db/migrate/20260809120001_create_spree_square_location_mappings.rb +12 -0
  70. data/db/migrate/20260809120002_create_spree_square_taxon_mappings.rb +16 -0
  71. data/db/migrate/20260809120003_create_spree_square_catalog_mappings.rb +20 -0
  72. data/db/migrate/20260809140001_create_spree_square_webhook_events.rb +26 -0
  73. data/db/migrate/20260809160001_create_spree_square_modifier_lists.rb +16 -0
  74. data/db/migrate/20260809160002_create_spree_square_modifiers.rb +15 -0
  75. data/db/migrate/20260809160003_create_spree_square_product_modifier_lists.rb +13 -0
  76. data/db/migrate/20260809160004_create_spree_square_line_item_modifiers.rb +16 -0
  77. data/db/migrate/20260809170001_create_spree_square_order_mappings.rb +17 -0
  78. data/db/migrate/20260810180001_create_spree_square_credentials.rb +28 -0
  79. data/lib/generators/spree_square/install/install_generator.rb +20 -0
  80. data/lib/spree_square/configuration.rb +13 -0
  81. data/lib/spree_square/engine.rb +65 -0
  82. data/lib/spree_square/factories.rb +16 -0
  83. data/lib/spree_square/version.rb +7 -0
  84. data/lib/spree_square.rb +12 -0
  85. data/lib/tasks/spree_square.rake +77 -0
  86. data/lib/tasks/spree_square_demo.rake +142 -0
  87. data/spree_square.gemspec +56 -0
  88. metadata +218 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1bcb9058e713d818be55281c3643f158f79b43576d432f3a365d4b3a83e94c7a
4
+ data.tar.gz: 4ef13d373d9a2065f6103a824b365ed9427bb6c93f5bb93fc222615448ae9d32
5
+ SHA512:
6
+ metadata.gz: b26da9d8e6d45cc8d26c67f0b6100aeab3bb46a8dbacfaa2f5c8725c06abde9df744be9170c75bb7c4ad398df4a5c628b3f316ebb0ac17ef4ecd2ba16cfbcdb0
7
+ data.tar.gz: 0340e00a33c32b51058bb716f4386630d3fa76c1974a98d84c0cea6fe38e6999e2a1ddb29edd25619dd870061a34b633b276ba28cf217cf43efc46e9fef6913f
data/.env ADDED
@@ -0,0 +1,7 @@
1
+ # Loaded via `dotenv/load` in spec/spec_helper.rb, for the dummy test app
2
+ # only — not read by anything that ships. Test-only Active Record Encryption
3
+ # keys (SpreeSquare::Credential) so specs can save/load encrypted columns;
4
+ # generated with SecureRandom, no real secret behind them.
5
+ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=2Ewc8daKheOnrlLa1eN1JKvPj4IiCj2f
6
+ ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=yEjwBJYcxLgE3yP5mD98YVTQMTMzNHix
7
+ ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=8CO9MySzjX3gCDsPB69dhfTiWpIKIigh
data/.gem_release.yml ADDED
@@ -0,0 +1,3 @@
1
+ bump:
2
+ file: "lib/spree_square/version.rb"
3
+
@@ -0,0 +1,11 @@
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for all configuration options:
4
+ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "bundler" # See documentation for possible values
9
+ directory: "/" # Location of package manifests
10
+ schedule:
11
+ interval: "weekly"
@@ -0,0 +1,107 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ test-postgres:
17
+ name: "PostgreSQL"
18
+ runs-on: ubuntu-latest
19
+ services:
20
+ postgres:
21
+ image: postgres:16
22
+ env:
23
+ POSTGRES_USER: postgres
24
+ POSTGRES_PASSWORD: postgres
25
+ ports:
26
+ - 5432:5432
27
+ options: >-
28
+ --health-cmd pg_isready
29
+ --health-interval 10s
30
+ --health-timeout 5s
31
+ --health-retries 5
32
+ env:
33
+ DB: postgres
34
+ DB_HOST: localhost
35
+ DB_USERNAME: postgres
36
+ DB_PASSWORD: postgres
37
+ BUNDLE_JOBS: 4
38
+ BUNDLE_RETRY: 3
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+
42
+ - uses: ruby/setup-ruby@v1
43
+ with:
44
+ ruby-version: '3.3'
45
+ bundler-cache: true
46
+
47
+ - name: Install libvips
48
+ run: sudo apt-get update && sudo apt-get install -y libvips-dev
49
+
50
+ - name: Create test app
51
+ run: bundle exec rake test_app
52
+
53
+ - name: Run tests
54
+ run: bundle exec rspec --format documentation
55
+
56
+ test-mysql:
57
+ name: "MySQL"
58
+ runs-on: ubuntu-latest
59
+ services:
60
+ mysql:
61
+ image: mysql:8.0
62
+ env:
63
+ MYSQL_ROOT_PASSWORD: password
64
+ ports:
65
+ - 3306:3306
66
+ options: >-
67
+ --health-cmd="mysqladmin ping"
68
+ --health-interval 10s
69
+ --health-timeout 5s
70
+ --health-retries 5
71
+ env:
72
+ DB: mysql
73
+ DB_HOST: 127.0.0.1
74
+ DB_USERNAME: root
75
+ DB_PASSWORD: password
76
+ BUNDLE_JOBS: 4
77
+ BUNDLE_RETRY: 3
78
+ steps:
79
+ - uses: actions/checkout@v4
80
+
81
+ - uses: ruby/setup-ruby@v1
82
+ with:
83
+ ruby-version: '3.3'
84
+ bundler-cache: true
85
+
86
+ - name: Install libvips
87
+ run: sudo apt-get update && sudo apt-get install -y libvips-dev
88
+
89
+ - name: Create test app
90
+ run: bundle exec rake test_app
91
+
92
+ - name: Run tests
93
+ run: bundle exec rspec --format documentation
94
+
95
+ brakeman:
96
+ name: Brakeman
97
+ runs-on: ubuntu-latest
98
+ steps:
99
+ - uses: actions/checkout@v4
100
+
101
+ - uses: ruby/setup-ruby@v1
102
+ with:
103
+ ruby-version: '3.3'
104
+ bundler-cache: true
105
+
106
+ - name: Run Brakeman
107
+ run: bundle exec brakeman --exit-on-warn --exit-on-error
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
+ *.gem
23
+ */*.gem
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ -r spec_helper
3
+ -f documentation
data/.rubocop.yml ADDED
@@ -0,0 +1,24 @@
1
+ plugins:
2
+ - rubocop-rails
3
+
4
+ AllCops:
5
+ DisplayCopNames: true
6
+ TargetRubyVersion: 3.2
7
+ Include:
8
+ - '**/Gemfile'
9
+ - '**/Rakefile'
10
+ - '**/Appraisals'
11
+ Exclude:
12
+ - 'spec/dummy/**/*'
13
+ - 'lib/generators/**/*'
14
+
15
+ Layout/LineLength:
16
+ Max: 150
17
+
18
+ # DISABLED
19
+
20
+ Style/Documentation:
21
+ Enabled: false
22
+
23
+ Style/FrozenStringLiteralComment:
24
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ ## 0.1.0
6
+
7
+ Initial public release.
8
+
9
+ - Square catalog sync (items, variations, categories, images, modifier lists) into Spree, via a
10
+ full importer and real-time webhooks (`catalog.version.updated`, `inventory.count.updated`).
11
+ - Order push: completed Spree orders are pushed into Square as paid tickets (`EXTERNAL` payment),
12
+ targeting the Square location mapped from the order's stock location.
13
+ - Order status sync-back: `order.updated` / `order.fulfillment.updated` webhooks keep the Spree
14
+ order's shipment/cancellation state in sync with Square.
15
+ - Self-service OAuth ("Connect to Square" in the admin) alongside a hand-issued
16
+ `SQUARE_ACCESS_TOKEN` for local development — see the README's "Connecting to Square" section.
17
+ - Nightly reconciliation job, dead-letter alerting on failed order pushes, and admin pages listing
18
+ order mappings and webhook events for support visibility.
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,29 @@
1
+ # Contributing
2
+
3
+ Thanks for considering a contribution to `spree_square`.
4
+
5
+ ## Getting set up
6
+
7
+ ```bash
8
+ bundle install
9
+ bundle exec rake test_app # generates spec/dummy, the Rails app specs run against
10
+ bundle exec rspec
11
+ ```
12
+
13
+ ## Making a change
14
+
15
+ 1. Open an issue first for anything beyond a small fix, so the approach can be discussed before
16
+ you put time into it.
17
+ 2. Add or update specs alongside any behavior change — `bundle exec rspec` should stay green.
18
+ 3. Keep decorators as a last resort (see the main README's customization pattern order); prefer
19
+ Spree's Events/Subscribers or Dependencies mechanisms where they fit.
20
+ 4. Open a pull request describing what changed and why.
21
+
22
+ ## Releasing (maintainers)
23
+
24
+ ```bash
25
+ bundle exec gem bump --version [major|minor|patch] -t -m "Release v%s"
26
+ bundle exec gem release
27
+ ```
28
+
29
+ See the [gem-release README](https://github.com/svenfuchs/gem-release) for more options.
data/Gemfile ADDED
@@ -0,0 +1,28 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'rails-controller-testing'
4
+
5
+ # Pinned to the released 5.6.x line (matching spree_host) rather than tracking
6
+ # spree/spree's main branch, which is mid-refactor toward v6 (e.g. spree_admin
7
+ # has moved around between tags and main) and isn't safe to develop against.
8
+ spree_opts = if ENV['SPREE_PATH']
9
+ { 'path': ENV['SPREE_PATH'] }
10
+ else
11
+ '~> 5.6.0'
12
+ end
13
+ gem 'spree', spree_opts
14
+ gem 'spree_admin', spree_opts
15
+
16
+ gem 'spree_dev_tools', '>= 0.6.0.rc1'
17
+
18
+ if ENV['DB'] == 'mysql'
19
+ gem 'mysql2'
20
+ elsif ENV['DB'] == 'postgres'
21
+ gem 'pg'
22
+ else
23
+ gem 'sqlite3'
24
+ end
25
+
26
+ gem 'propshaft'
27
+
28
+ gemspec
data/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amit Solanki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # Spree Square
2
+
3
+ This is a Square extension for [Spree Commerce](https://spreecommerce.org), an open source e-commerce platform built with Ruby on Rails.
4
+
5
+ ## Installation
6
+
7
+ 1. Add this extension to your Gemfile with this line:
8
+
9
+ ```ruby
10
+ bundle add spree_square
11
+ ```
12
+
13
+ 2. Run the install generator
14
+
15
+ ```ruby
16
+ bundle exec rails g spree_square:install
17
+ ```
18
+
19
+ 3. Restart your server
20
+
21
+ If your server was running, restart it so that it can find the assets properly.
22
+
23
+ ## Connecting to Square
24
+
25
+ Two ways to authenticate, in order of preference:
26
+
27
+ ### OAuth — "Connect to Square" (recommended)
28
+
29
+ Lets a store owner connect their own Square account from the Spree admin, without you
30
+ hand-generating an access token for them. This is also the auth model Square requires before
31
+ an app can be listed on the App Marketplace — personal access tokens are explicitly disallowed
32
+ for multi-merchant use.
33
+
34
+ One-time setup, per environment (sandbox and production each need their own app):
35
+
36
+ 1. Go to the [Square Developer Dashboard](https://developer.squareup.com/apps) and open (or
37
+ create) an Application.
38
+ 2. On its **OAuth** page, add a redirect URL:
39
+ `https://your-store.example.com/admin/square_oauth/callback` (must be `https` in production;
40
+ `http://localhost:3000/admin/square_oauth/callback` is fine for local dev).
41
+ 3. Copy the **Application ID** (same value you'd use for `SQUARE_APPLICATION_ID`) and the
42
+ **Application Secret**, and set both in `.env`:
43
+
44
+ ```
45
+ SQUARE_APPLICATION_ID=sandbox-sq0idb-...
46
+ SQUARE_APPLICATION_SECRET=sq0csp-...
47
+ ```
48
+
49
+ 4. Recreate (not just restart) the container so the new `.env` values actually load —
50
+ `docker compose up -d web`, not `docker compose restart web`; Compose only re-reads `.env` on
51
+ the former.
52
+ 5. In the Spree admin, go to **Square Connection** in the sidebar and click **Connect to
53
+ Square**.
54
+
55
+ Tokens are stored per-store in `SpreeSquare::Credential`, encrypted at rest (Active Record
56
+ Encryption — keys generated once via `bin/rails db:encryption:init`, wired up in
57
+ `lib/spree_square/engine.rb`, sourced from `ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY` /
58
+ `_DETERMINISTIC_KEY` / `_KEY_DERIVATION_SALT` in `.env`). `SpreeSquare::Client` refreshes the
59
+ access token automatically whenever it's within Square's recommended 7-day renewal window
60
+ (tokens expire every 30 days) — no cron job or manual step needed once connected.
61
+
62
+ ### `SQUARE_ACCESS_TOKEN` — direct token (dev/sandbox fallback)
63
+
64
+ The original single-tenant path: generate a token yourself from the Developer Dashboard and set
65
+ `SQUARE_ACCESS_TOKEN` in `.env`. `SpreeSquare::Client` falls back to this automatically for any
66
+ store that hasn't connected via OAuth — convenient for local development, but not something
67
+ Square allows for real multi-merchant production use.
68
+
69
+ ## Developing
70
+
71
+ 1. Create a dummy app
72
+
73
+ ```bash
74
+ bundle update
75
+ bundle exec rake test_app
76
+ ```
77
+
78
+ 2. Add your new code
79
+ 3. Run tests
80
+
81
+ ```bash
82
+ bundle exec rspec
83
+ ```
84
+
85
+ When testing your applications integration with this extension you may use it's factories.
86
+ Simply add this require statement to your spec_helper:
87
+
88
+ ```ruby
89
+ require 'spree_square/factories'
90
+ ```
91
+
92
+ ## Releasing a new version
93
+
94
+ ```shell
95
+ bundle exec gem bump -p -t
96
+ bundle exec gem release
97
+ ```
98
+
99
+ For more options please see [gem-release README](https://github.com/svenfuchs/gem-release)
100
+
101
+ ## Contributing
102
+
103
+ If you'd like to contribute, please take a look at the
104
+ [instructions](CONTRIBUTING.md) for installing dependencies and crafting a good
105
+ pull request.
data/Rakefile ADDED
@@ -0,0 +1,23 @@
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_square'
20
+ Rake::Task['extension:test_app'].execute(
21
+ install_admin: true
22
+ )
23
+ end
data/app/.gitkeep ADDED
File without changes
@@ -0,0 +1,5 @@
1
+ //= link_tree ../images
2
+ //= link spree_square/application.js
3
+ //= link_tree ../../javascript/spree_square/controllers .js
4
+ //= link_tree ../../../vendor/javascript .js
5
+ //= link_tree ../../../vendor/stylesheets .css
File without changes
@@ -0,0 +1,103 @@
1
+ module Spree
2
+ module Admin
3
+ # Self-service "Connect to Square" flow for the current store — the
4
+ # OAuth replacement for hand-generating SQUARE_ACCESS_TOKEN in .env.
5
+ # Square requires multi-merchant apps to use OAuth rather than personal
6
+ # access tokens (a prerequisite for App Marketplace listing), and this is
7
+ # the admin-facing half of that: authorize -> callback -> store an
8
+ # encrypted SpreeSquare::Credential -> SpreeSquare::Client picks it up
9
+ # automatically from here on (see Client.for_store).
10
+ class SquareOauthController < Spree::Admin::BaseController
11
+ before_action :ensure_oauth_configured, only: %i[connect callback]
12
+
13
+ def show
14
+ @credential = SpreeSquare::Credential.find_by(store: current_store)
15
+ end
16
+
17
+ # Kicks off the OAuth authorization-code flow: redirect the admin's
18
+ # browser to Square's own consent page. `state` is a CSRF token,
19
+ # verified on the way back in #callback — without it, an attacker
20
+ # could trick an admin into connecting *the attacker's* Square account
21
+ # to this store by crafting their own callback link.
22
+ def connect
23
+ state = SecureRandom.hex(24)
24
+ session[:square_oauth_state] = state
25
+
26
+ redirect_to SpreeSquare::OauthClient.authorize_url(
27
+ redirect_uri: admin_callback_square_oauth_url,
28
+ state: state
29
+ ), allow_other_host: true
30
+ end
31
+
32
+ def callback
33
+ expected_state = session.delete(:square_oauth_state)
34
+
35
+ if params[:error].present?
36
+ flash[:error] = Spree.t(:square_oauth_denied, default: "Square authorization was cancelled: #{params[:error_description] || params[:error]}")
37
+ return redirect_to admin_square_oauth_path
38
+ end
39
+
40
+ if expected_state.blank? || !ActiveSupport::SecurityUtils.secure_compare(expected_state, params[:state].to_s)
41
+ flash[:error] = Spree.t(:square_oauth_state_mismatch, default: 'Square authorization could not be verified (invalid state) — please try connecting again.')
42
+ return redirect_to admin_square_oauth_path
43
+ end
44
+
45
+ response = SpreeSquare::OauthClient.exchange_code(code: params[:code], redirect_uri: admin_callback_square_oauth_url)
46
+ save_credential!(response)
47
+
48
+ flash[:success] = Spree.t(:square_oauth_connected, default: 'Connected to Square.')
49
+ redirect_to admin_square_oauth_path
50
+ rescue Square::Errors::ResponseError => e
51
+ Rails.logger.error("[SpreeSquare] OAuth token exchange failed: #{e.message}")
52
+ flash[:error] = Spree.t(:square_oauth_exchange_failed, default: 'Could not connect to Square — please try again.')
53
+ redirect_to admin_square_oauth_path
54
+ end
55
+
56
+ def destroy
57
+ credential = SpreeSquare::Credential.find_by(store: current_store)
58
+ if credential
59
+ begin
60
+ SpreeSquare::OauthClient.revoke(credential)
61
+ rescue StandardError => e
62
+ # A failed remote revoke (token already invalid, network blip)
63
+ # shouldn't trap the admin into a "disconnect" button that never
64
+ # works — the local side is what actually stops this store's
65
+ # syncing, so proceed to destroy the row regardless.
66
+ Rails.logger.warn("[SpreeSquare] OAuth revoke failed, disconnecting locally anyway: #{e.message}")
67
+ end
68
+ credential.destroy!
69
+ end
70
+
71
+ flash[:success] = Spree.t(:square_oauth_disconnected, default: 'Disconnected from Square.')
72
+ redirect_to admin_square_oauth_path
73
+ end
74
+
75
+ private
76
+
77
+ def ensure_oauth_configured
78
+ return if ENV['SQUARE_APPLICATION_SECRET'].present?
79
+
80
+ flash[:error] = 'SQUARE_APPLICATION_SECRET is not set — add it to .env first (see spree_square/README.md).'
81
+ redirect_to admin_square_oauth_path
82
+ end
83
+
84
+ def save_credential!(oauth_response)
85
+ credential = SpreeSquare::Credential.find_or_initialize_by(store: current_store)
86
+ credential.assign_attributes(
87
+ square_merchant_id: oauth_response.merchant_id,
88
+ square_environment: ENV.fetch('SQUARE_ENVIRONMENT', 'sandbox'),
89
+ access_token: oauth_response.access_token,
90
+ refresh_token: oauth_response.refresh_token,
91
+ expires_at: parse_time(oauth_response.expires_at),
92
+ refresh_token_expires_at: parse_time(oauth_response.refresh_token_expires_at),
93
+ scopes: SpreeSquare::OauthClient::SCOPES
94
+ )
95
+ credential.save!
96
+ end
97
+
98
+ def parse_time(value)
99
+ value.present? ? Time.iso8601(value) : nil
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,12 @@
1
+ module Spree
2
+ module Admin
3
+ # Read-only support/diagnostic view — no create/edit/destroy, this is
4
+ # visibility into what spree_square has already done, not a place to
5
+ # change it. See config/routes.rb (only: [:index]).
6
+ class SquareOrderMappingsController < ResourceController
7
+ def model_class
8
+ SpreeSquare::OrderMapping
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ module Spree
2
+ module Admin
3
+ # Read-only support/diagnostic view of every inbound Square webhook —
4
+ # what arrived, whether it processed, and the error if it didn't.
5
+ class SquareWebhookEventsController < ResourceController
6
+ def model_class
7
+ SpreeSquare::WebhookEvent
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,56 @@
1
+ module SpreeSquare
2
+ # Receives Square webhook notifications. Square-signature-verified instead
3
+ # of Spree/Devise-authenticated, and deliberately does the least possible
4
+ # work synchronously: verify, record, ack, hand off to a job. Square treats
5
+ # a slow or non-2xx response as a delivery failure and retries.
6
+ class WebhooksController < ActionController::Base
7
+ skip_before_action :verify_authenticity_token, raise: false
8
+
9
+ def create
10
+ raw_body = request.raw_post
11
+ signature = request.headers['x-square-hmacsha256-signature']
12
+
13
+ unless SpreeSquare::WebhookVerifier.valid?(
14
+ url: request.original_url,
15
+ body: raw_body,
16
+ signature: signature,
17
+ signing_key: SpreeSquare::Client.instance.webhook_signature_key
18
+ )
19
+ Rails.logger.warn('[SpreeSquare] webhook signature verification failed')
20
+ return head :unauthorized
21
+ end
22
+
23
+ payload = JSON.parse(raw_body)
24
+ event = find_or_log_event(payload)
25
+ enqueue_job(event) if event.previously_new_record?
26
+
27
+ head :ok
28
+ rescue JSON::ParserError
29
+ head :bad_request
30
+ end
31
+
32
+ private
33
+
34
+ def find_or_log_event(payload)
35
+ SpreeSquare::WebhookEvent.find_or_create_by!(square_event_id: payload['event_id']) do |event|
36
+ event.event_type = payload['type']
37
+ event.payload = payload
38
+ end
39
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
40
+ # Lost a race with a concurrent duplicate delivery — the row exists now
41
+ # either way, and it's already being (or has been) processed once.
42
+ SpreeSquare::WebhookEvent.find_by!(square_event_id: payload['event_id'])
43
+ end
44
+
45
+ def enqueue_job(event)
46
+ case event.event_type
47
+ when 'catalog.version.updated'
48
+ SpreeSquare::CatalogWebhookJob.perform_later(event.id)
49
+ when 'inventory.count.updated'
50
+ SpreeSquare::InventoryWebhookJob.perform_later(event.id)
51
+ when 'order.updated', 'order.fulfillment.updated'
52
+ SpreeSquare::OrderWebhookJob.perform_later(event.id)
53
+ end
54
+ end
55
+ end
56
+ 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 SpreeSquareController from 'spree_square/controllers/spree_square_controller'
15
+
16
+ application.register('spree_square', SpreeSquareController)
@@ -0,0 +1,7 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ console.log('Hello, SpreeSquare!')
6
+ }
7
+ }
@@ -0,0 +1,5 @@
1
+ module SpreeSquare
2
+ class BaseJob < Spree::BaseJob
3
+ queue_as SpreeSquare.queue
4
+ end
5
+ end
@@ -0,0 +1,20 @@
1
+ module SpreeSquare
2
+ # Handles `catalog.version.updated`. Square's payload is deliberately
3
+ # coarse — it says the catalog changed, not what changed — so the handler
4
+ # just re-runs a full import. Fine for a restaurant-sized catalog; would
5
+ # need to become a scoped/incremental fetch for a larger one.
6
+ class CatalogWebhookJob < BaseJob
7
+ # A block passed to retry_on runs only once retries are exhausted —
8
+ # normal transient failures retry silently; only a truly stuck job
9
+ # reaches here and gets flagged.
10
+ retry_on StandardError, wait: :polynomially_longer, attempts: 5 do |job, error|
11
+ SpreeSquare::WebhookEvent.find_by(id: job.arguments.first)&.mark_failed!(error)
12
+ SpreeSquare::Alerting.capture(error, context: 'catalog_webhook')
13
+ end
14
+
15
+ def perform(webhook_event_id)
16
+ SpreeSquare::CatalogImporter.call
17
+ SpreeSquare::WebhookEvent.find(webhook_event_id).mark_processed!
18
+ end
19
+ end
20
+ end