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.
- checksums.yaml +7 -0
- data/.env +7 -0
- data/.gem_release.yml +3 -0
- data/.github/.dependabot.yml +11 -0
- data/.github/workflows/tests.yml +107 -0
- data/.gitignore +23 -0
- data/.rspec +3 -0
- data/.rubocop.yml +24 -0
- data/CHANGELOG.md +18 -0
- data/CONTRIBUTING.md +29 -0
- data/Gemfile +28 -0
- data/LICENSE.md +9 -0
- data/README.md +105 -0
- data/Rakefile +23 -0
- data/app/.gitkeep +0 -0
- data/app/assets/config/spree_square_manifest.js +5 -0
- data/app/assets/images/.keep +0 -0
- data/app/controllers/spree/admin/square_oauth_controller.rb +103 -0
- data/app/controllers/spree/admin/square_order_mappings_controller.rb +12 -0
- data/app/controllers/spree/admin/square_webhook_events_controller.rb +11 -0
- data/app/controllers/spree_square/webhooks_controller.rb +56 -0
- data/app/javascript/spree_square/application.js +16 -0
- data/app/javascript/spree_square/controllers/spree_square_controller.js +7 -0
- data/app/jobs/spree_square/base_job.rb +5 -0
- data/app/jobs/spree_square/catalog_webhook_job.rb +20 -0
- data/app/jobs/spree_square/inventory_webhook_job.rb +27 -0
- data/app/jobs/spree_square/order_push_job.rb +23 -0
- data/app/jobs/spree_square/order_webhook_job.rb +51 -0
- data/app/jobs/spree_square/reconciliation_job.rb +36 -0
- data/app/models/spree/line_item_decorator.rb +55 -0
- data/app/models/spree/variant_decorator.rb +19 -0
- data/app/models/spree_square/catalog_mapping.rb +25 -0
- data/app/models/spree_square/credential.rb +41 -0
- data/app/models/spree_square/line_item_modifier.rb +24 -0
- data/app/models/spree_square/location_mapping.rb +14 -0
- data/app/models/spree_square/modifier.rb +14 -0
- data/app/models/spree_square/modifier_list.rb +23 -0
- data/app/models/spree_square/order_mapping.rb +15 -0
- data/app/models/spree_square/product_modifier_list.rb +12 -0
- data/app/models/spree_square/taxon_mapping.rb +25 -0
- data/app/models/spree_square/webhook_event.rb +21 -0
- data/app/serializers/spree_square/line_item_serializer.rb +13 -0
- data/app/serializers/spree_square/product_serializer.rb +47 -0
- data/app/services/spree_square/alerting.rb +17 -0
- data/app/services/spree_square/cart/add_item.rb +53 -0
- data/app/services/spree_square/catalog_importer.rb +57 -0
- data/app/services/spree_square/catalog_object_mapper.rb +226 -0
- data/app/services/spree_square/client.rb +116 -0
- data/app/services/spree_square/find_line_item_by_variant.rb +21 -0
- data/app/services/spree_square/inventory_sync.rb +29 -0
- data/app/services/spree_square/oauth_client.rb +116 -0
- data/app/services/spree_square/order_builder.rb +79 -0
- data/app/services/spree_square/order_pusher.rb +68 -0
- data/app/services/spree_square/order_status_mapper.rb +87 -0
- data/app/services/spree_square/revalidator.rb +55 -0
- data/app/services/spree_square/webhook_verifier.rb +21 -0
- data/app/subscribers/spree_square/order_completed_subscriber.rb +15 -0
- data/app/views/spree/admin/square_oauth/show.html.erb +63 -0
- data/app/views/spree/admin/square_order_mappings/index.html.erb +5 -0
- data/app/views/spree/admin/square_webhook_events/index.html.erb +5 -0
- data/bin/importmap +9 -0
- data/bin/rails +8 -0
- data/config/importmap.rb +6 -0
- data/config/initializers/spree.rb +45 -0
- data/config/initializers/spree_admin_square_navigation.rb +25 -0
- data/config/initializers/spree_admin_square_tables.rb +86 -0
- data/config/locales/en.yml +5 -0
- data/config/routes.rb +25 -0
- data/db/migrate/20260809120001_create_spree_square_location_mappings.rb +12 -0
- data/db/migrate/20260809120002_create_spree_square_taxon_mappings.rb +16 -0
- data/db/migrate/20260809120003_create_spree_square_catalog_mappings.rb +20 -0
- data/db/migrate/20260809140001_create_spree_square_webhook_events.rb +26 -0
- data/db/migrate/20260809160001_create_spree_square_modifier_lists.rb +16 -0
- data/db/migrate/20260809160002_create_spree_square_modifiers.rb +15 -0
- data/db/migrate/20260809160003_create_spree_square_product_modifier_lists.rb +13 -0
- data/db/migrate/20260809160004_create_spree_square_line_item_modifiers.rb +16 -0
- data/db/migrate/20260809170001_create_spree_square_order_mappings.rb +17 -0
- data/db/migrate/20260810180001_create_spree_square_credentials.rb +28 -0
- data/lib/generators/spree_square/install/install_generator.rb +20 -0
- data/lib/spree_square/configuration.rb +13 -0
- data/lib/spree_square/engine.rb +65 -0
- data/lib/spree_square/factories.rb +16 -0
- data/lib/spree_square/version.rb +7 -0
- data/lib/spree_square.rb +12 -0
- data/lib/tasks/spree_square.rake +77 -0
- data/lib/tasks/spree_square_demo.rake +142 -0
- data/spree_square.gemspec +56 -0
- 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,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
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
|
|
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,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
|