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
@@ -0,0 +1,226 @@
1
+ require 'open-uri'
2
+
3
+ module SpreeSquare
4
+ # Maps single Square catalog objects (CATEGORY, ITEM, ITEM_VARIATION) onto
5
+ # Spree records. Square is the source of truth for the menu — this is a
6
+ # one-way mirror, never the reverse.
7
+ #
8
+ # `related_objects_by_id` is a lookup (built by CatalogImporter from a
9
+ # SearchCatalogObjects response's `related_objects`) used to resolve
10
+ # references like an item's `category_id` to the full CatalogObjectCategory,
11
+ # or an image id to its CatalogObjectImage.
12
+ class CatalogObjectMapper
13
+ # One shared OptionType for any item that has more than one variation
14
+ # (e.g. sizes). Single-variation items skip this entirely and use the
15
+ # product's master variant — avoids OptionType/OptionValue bookkeeping for
16
+ # the common case, since most menu items don't have real variations.
17
+ VARIATION_OPTION_TYPE_NAME = 'square_variation'.freeze
18
+
19
+ def initialize(related_objects_by_id: {})
20
+ @related_objects_by_id = related_objects_by_id
21
+ @store = Spree::Store.default
22
+ @shipping_category = Spree::ShippingCategory.find_by(name: 'Default') || Spree::ShippingCategory.first
23
+ @channel = Spree::Channel.find_by(code: 'online') || Spree::Channel.first
24
+ end
25
+
26
+ def map_category(square_object)
27
+ data = square_object.category_data
28
+ name = data.name.presence || 'Uncategorized'
29
+ mapping = SpreeSquare::TaxonMapping.find_or_initialize_by(square_category_id: square_object.id)
30
+
31
+ # Square's SearchCatalogObjects index is only eventually consistent —
32
+ # a category deleted and recreated (or, as observed in practice, a
33
+ # stale duplicate momentarily reappearing alongside the fresh one) can
34
+ # surface a second CatalogObject with a different id but the same
35
+ # name before a TaxonMapping exists for it. Category name+store is
36
+ # unique in Spree (see Spree::Category#requires_taxonomy? — no
37
+ # taxonomy, so uniqueness is scoped to store_id), so falling back to
38
+ # an existing same-named category avoids a hard crash on that race
39
+ # instead of trying (and failing) to create a duplicate.
40
+ taxon = mapping.taxon || Spree::Category.find_by(store: @store, name: name) || Spree::Category.new(store: @store)
41
+ taxon.name = name
42
+ taxon.save!
43
+
44
+ mapping.taxon = taxon
45
+ mapping.square_version = square_object.version
46
+ mapping.save!
47
+
48
+ taxon
49
+ end
50
+
51
+ # A Square MODIFIER_LIST embeds its full MODIFIER objects inline (same
52
+ # pattern as ITEM#variations) — no follow-up API call needed.
53
+ def map_modifier_list(square_object)
54
+ data = square_object.modifier_list_data
55
+ list = SpreeSquare::ModifierList.find_or_initialize_by(square_modifier_list_id: square_object.id)
56
+ list.name = data.name.presence || 'Options'
57
+ list.selection_type = data.selection_type.presence || SpreeSquare::ModifierList::SINGLE
58
+ list.min_selected_modifiers = data.min_selected_modifiers
59
+ list.max_selected_modifiers = data.max_selected_modifiers
60
+ list.square_version = square_object.version
61
+ list.save!
62
+
63
+ Array(data.modifiers).each { |modifier| map_modifier(modifier, list) }
64
+ list
65
+ end
66
+
67
+ def map_modifier(square_object, modifier_list)
68
+ data = square_object.modifier_data
69
+ modifier = SpreeSquare::Modifier.find_or_initialize_by(square_modifier_id: square_object.id)
70
+ modifier.modifier_list = modifier_list
71
+ modifier.name = data.name.presence || 'Option'
72
+ modifier.price_cents = data.price_money&.amount || 0
73
+ modifier.square_version = square_object.version
74
+ modifier.save!
75
+ modifier
76
+ end
77
+
78
+ def map_item(square_object)
79
+ data = square_object.item_data
80
+ mapping = SpreeSquare::CatalogMapping.find_or_initialize_by(
81
+ square_catalog_object_id: square_object.id,
82
+ square_object_type: SpreeSquare::CatalogMapping::ITEM
83
+ )
84
+ return mapping.product if mapping.persisted? && mapping.stale?(square_object.version)
85
+
86
+ product = mapping.product || Spree::Product.new(
87
+ store: @store,
88
+ shipping_category: @shipping_category,
89
+ status: 'active',
90
+ slug: generate_slug(data.name, square_object.id)
91
+ )
92
+ product.name = data.name.presence || 'Untitled item'
93
+ product.description = data.description
94
+ product.save!
95
+
96
+ publish!(product)
97
+ assign_taxons!(product, data)
98
+ assign_modifier_lists!(product, data)
99
+
100
+ mapping.product = product
101
+ mapping.square_version = square_object.version
102
+ mapping.last_synced_at = Time.current
103
+ mapping.save!
104
+
105
+ Array(data.variations).each { |variation| map_variation(variation, product) }
106
+ import_primary_image(product, data.image_ids&.first) if product.images.empty?
107
+
108
+ revalidate!(product)
109
+ product
110
+ end
111
+
112
+ def map_variation(square_object, product)
113
+ data = square_object.item_variation_data
114
+ mapping = SpreeSquare::CatalogMapping.find_or_initialize_by(
115
+ square_catalog_object_id: square_object.id,
116
+ square_object_type: SpreeSquare::CatalogMapping::ITEM_VARIATION
117
+ )
118
+ return mapping.variant if mapping.persisted? && mapping.stale?(square_object.version)
119
+
120
+ variant = mapping.variant || pick_variant(product, data)
121
+ variant.sku = square_object.id if variant.sku.blank?
122
+ variant.save!
123
+
124
+ amount = (data.price_money&.amount || 0) / 100.0
125
+ variant.set_price(data.price_money&.currency || @store.default_currency, amount)
126
+
127
+ mapping.variant = variant
128
+ mapping.square_version = square_object.version
129
+ mapping.last_synced_at = Time.current
130
+ mapping.save!
131
+
132
+ # A variation's own price/sku change touches the product without
133
+ # firing Spree's product.updated event (see Revalidator) — the
134
+ # storefront's product cache needs telling explicitly either way.
135
+ revalidate!(product)
136
+ variant
137
+ end
138
+
139
+ private
140
+
141
+ def revalidate!(product)
142
+ SpreeSquare::Revalidator.call(['products', "product:#{product.slug}"])
143
+ end
144
+
145
+ # The first variation ever imported for a product reuses its master
146
+ # variant (detected by the master not having a SKU yet — we always set one
147
+ # from the Square variation id). Only the second-and-later variations
148
+ # force creating real (non-master) variants with an option value
149
+ # distinguishing them — Spree requires at least one option value on any
150
+ # non-master variant.
151
+ def pick_variant(product, data)
152
+ return product.master if product.master.sku.blank?
153
+
154
+ option_value = find_or_create_option_value(data.name.presence || 'Default')
155
+ variant = product.variants.build
156
+ variant.option_values << option_value
157
+ variant
158
+ end
159
+
160
+ def find_or_create_option_value(name)
161
+ option_type = Spree::OptionType.find_or_create_by!(name: VARIATION_OPTION_TYPE_NAME) do |ot|
162
+ ot.presentation = 'Variation'
163
+ end
164
+ option_type.option_values.find_or_create_by!(name: name.parameterize) do |ov|
165
+ ov.presentation = name
166
+ end
167
+ end
168
+
169
+ def publish!(product)
170
+ return unless @channel
171
+
172
+ product.product_publications.find_or_create_by!(channel: @channel)
173
+ end
174
+
175
+ def assign_taxons!(product, item_data)
176
+ category_ids = Array(item_data.category_id) + Array(item_data.categories).map(&:id)
177
+ taxons = category_ids.uniq.filter_map do |square_category_id|
178
+ SpreeSquare::TaxonMapping.find_by(square_category_id: square_category_id)&.taxon
179
+ end
180
+ product.taxons = taxons if taxons.any?
181
+ end
182
+
183
+ # Square is master: replace the full set each sync so a modifier list
184
+ # detached from an item in Square disappears here too, not just
185
+ # additions.
186
+ def assign_modifier_lists!(product, item_data)
187
+ list_ids = Array(item_data.modifier_list_info)
188
+ .reject { |info| info.enabled == false }
189
+ .map(&:modifier_list_id)
190
+
191
+ lists = SpreeSquare::ModifierList.where(square_modifier_list_id: list_ids)
192
+ existing_ids = SpreeSquare::ProductModifierList.where(product_id: product.id).pluck(:modifier_list_id)
193
+
194
+ (lists.pluck(:id) - existing_ids).each do |list_id|
195
+ SpreeSquare::ProductModifierList.create!(product: product, modifier_list_id: list_id)
196
+ end
197
+ (existing_ids - lists.pluck(:id)).each do |list_id|
198
+ SpreeSquare::ProductModifierList.where(product_id: product.id, modifier_list_id: list_id).destroy_all
199
+ end
200
+ end
201
+
202
+ def import_primary_image(product, image_id)
203
+ return if image_id.blank?
204
+
205
+ image_object = @related_objects_by_id[image_id]
206
+ url = image_object&.image_data&.url
207
+ return if url.blank?
208
+
209
+ downloaded = URI.parse(url).open
210
+ image = product.master.images.build
211
+ image.attachment.attach(
212
+ io: downloaded,
213
+ filename: "#{product.slug}#{File.extname(URI.parse(url).path.to_s).presence || '.jpg'}",
214
+ content_type: downloaded.content_type
215
+ )
216
+ image.save!
217
+ rescue StandardError => e
218
+ Rails.logger.warn("[SpreeSquare] image import failed for product #{product.id}: #{e.message}")
219
+ end
220
+
221
+ def generate_slug(name, square_id)
222
+ base = name.presence || 'item'
223
+ "#{base.parameterize}-#{square_id.downcase}"
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,116 @@
1
+ # Bundler's gem-name-based auto-require doesn't resolve "square.rb" -> "square"
2
+ # (the gem's require path), so it's not auto-required by Bundler.require.
3
+ require 'square'
4
+
5
+ module SpreeSquare
6
+ # Thin wrapper around Square::Client (the current, Fern-generated SDK —
7
+ # `square.rb` v46+). All Square API access in this extension goes through
8
+ # here so credential lookup and environment selection live in one place.
9
+ #
10
+ # Deliberately NOT using square_legacy's client.orders_api.create_order-style
11
+ # API that most tutorials still show — that's the old SDK shape bundled
12
+ # alongside the new one for migration purposes only.
13
+ #
14
+ # Credential resolution, in order:
15
+ # 1. A SpreeSquare::Credential for the given store (self-service "Connect
16
+ # to Square" via OAuth — see SpreeSquare::OauthClient) — refreshed
17
+ # first if it's inside Square's recommended renewal window.
18
+ # 2. SQUARE_ACCESS_TOKEN from ENV — the original single-tenant path, kept
19
+ # so existing sandbox/dev setups (and anyone not yet using OAuth)
20
+ # don't break. Square disallows this path for real multi-merchant use
21
+ # ("partner developers must not request nor use personal access
22
+ # tokens from the sellers who use their application") — it's a dev
23
+ # convenience here, not the production story.
24
+ class Client
25
+ class MissingCredentialsError < StandardError; end
26
+
27
+ # Deliberately NOT memoized: this used to be `@instance ||= new`, but
28
+ # once a store can connect via OAuth mid-process (an admin clicking
29
+ # "Connect to Square" while Puma/Solid Queue keep running), caching the
30
+ # first resolution forever means every job in that process would keep
31
+ # using the stale pre-connection state until a restart. The lookup this
32
+ # re-does each call is one indexed `SpreeSquare::Credential` query — not
33
+ # worth trading correctness for.
34
+ def self.instance
35
+ for_store
36
+ end
37
+
38
+ def self.for_store(store = Spree::Store.default)
39
+ new(credential: SpreeSquare::Credential.find_by(store: store))
40
+ end
41
+
42
+ def initialize(credential: nil)
43
+ @credential = credential
44
+ token = resolve_token
45
+ raise MissingCredentialsError, 'No Square credential connected and SQUARE_ACCESS_TOKEN is not set' if token.blank?
46
+
47
+ @client = Square::Client.new(base_url: base_url, token: token)
48
+ end
49
+
50
+ def catalog = @client.catalog
51
+ def orders = @client.orders
52
+ def payments = @client.payments
53
+ def inventory = @client.inventory
54
+ def locations = @client.locations
55
+ def webhooks = @client.webhooks
56
+
57
+ def location_id
58
+ fetch(:location_id, env_key: 'SQUARE_LOCATION_ID')
59
+ end
60
+
61
+ # App-level, not per-merchant: one webhook subscription/signing key
62
+ # covers every store this deployment serves, same whether a given store
63
+ # authenticates via OAuth or the ENV fallback above.
64
+ def webhook_signature_key
65
+ fetch(:webhook_signature_key, env_key: 'SQUARE_WEBHOOK_SIGNATURE_KEY')
66
+ end
67
+
68
+ def sandbox?
69
+ environment == 'sandbox'
70
+ end
71
+
72
+ private
73
+
74
+ def resolve_token
75
+ return fetch(:access_token, env_key: 'SQUARE_ACCESS_TOKEN') unless @credential
76
+
77
+ refresh_if_needed!
78
+ @credential.access_token
79
+ end
80
+
81
+ def refresh_if_needed!
82
+ return unless @credential.needs_refresh?
83
+
84
+ response = SpreeSquare::OauthClient.refresh(@credential)
85
+ @credential.update!(
86
+ access_token: response.access_token,
87
+ refresh_token: response.refresh_token,
88
+ expires_at: Time.iso8601(response.expires_at),
89
+ refresh_token_expires_at: response.refresh_token_expires_at.present? ? Time.iso8601(response.refresh_token_expires_at) : nil
90
+ )
91
+ rescue StandardError => e
92
+ # A failed refresh shouldn't crash whatever sync job triggered this
93
+ # client lookup — fall through and try the existing (possibly
94
+ # still-valid, or about to 401) access_token rather than raising here.
95
+ # The eventual 401 from Square is a clearer signal than this method
96
+ # raising somewhere deep inside a webhook job.
97
+ Rails.logger.error("[SpreeSquare] token refresh failed for store #{@credential.store_id}: #{e.message}")
98
+ end
99
+
100
+ def base_url
101
+ sandbox? ? Square::Environment::SANDBOX : Square::Environment::PRODUCTION
102
+ end
103
+
104
+ def environment
105
+ @credential&.square_environment || ENV.fetch('SQUARE_ENVIRONMENT', 'sandbox')
106
+ end
107
+
108
+ # Rails credentials first (config/credentials.yml.enc, scoped by
109
+ # environment: production.yml.enc vs sandbox stays in the base file for
110
+ # dev), falling back to plain ENV for 12-factor deployments (Heroku,
111
+ # Render, Fly, and — today — our own .env-driven Docker Compose setup).
112
+ def fetch(key, env_key:)
113
+ Rails.application.credentials.dig(:square, environment.to_sym, key) || ENV[env_key].presence
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,21 @@
1
+ module SpreeSquare
2
+ # Spree::LineItems::FindByVariant matches purely on variant_id — it calls
3
+ # Spree.cart_compare_line_items_service but discards the result without
4
+ # using it to gate anything, so customizing that comparator alone has no
5
+ # effect (verified directly: swapping just the comparator did not stop two
6
+ # differently-modified line items from merging). This finder replaces the
7
+ # matching logic itself, so "Margherita Pizza + extra cheese" and
8
+ # "Margherita Pizza + no cheese" land in separate line items instead of
9
+ # silently merging into one with the first selection's modifiers.
10
+ class FindLineItemByVariant
11
+ def execute(order:, variant:, options: {})
12
+ requested = Array(options[:square_modifier_ids] || options['square_modifier_ids']).sort
13
+
14
+ order.line_items.where(variant_id: variant.id).detect do |line_item|
15
+ existing = SpreeSquare::LineItemModifier.where(line_item_id: line_item.id)
16
+ .pluck(:square_modifier_id).compact.sort
17
+ existing == requested
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ module SpreeSquare
2
+ # Applies a single Square inventory count to the matching Spree::StockItem.
3
+ # No backordering food: backorderable is always forced false regardless of
4
+ # what it was set to before.
5
+ class InventorySync
6
+ def self.call(...) = new.call(...)
7
+
8
+ def call(catalog_object_id:, location_id:, quantity:, state: 'IN_STOCK')
9
+ mapping = SpreeSquare::CatalogMapping.find_by(
10
+ square_catalog_object_id: catalog_object_id,
11
+ square_object_type: SpreeSquare::CatalogMapping::ITEM_VARIATION
12
+ )
13
+ return unless mapping&.variant
14
+
15
+ location_mapping = SpreeSquare::LocationMapping.find_by(square_location_id: location_id)
16
+ return unless location_mapping
17
+
18
+ stock_item = location_mapping.stock_location.stock_item_or_create(mapping.variant)
19
+ stock_item.backorderable = false
20
+ stock_item.set_count_on_hand(state == 'IN_STOCK' ? quantity.to_i : 0)
21
+
22
+ # Stock changes touch product without firing product.updated (see
23
+ # Revalidator) — the storefront's "in stock"/"add to cart" state needs
24
+ # telling explicitly.
25
+ product = mapping.variant.product
26
+ SpreeSquare::Revalidator.call(['products', "product:#{product.slug}"])
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,116 @@
1
+ # Bundler's gem-name-based auto-require doesn't resolve "square.rb" -> "square"
2
+ # (the gem's require path), so it's not auto-required by Bundler.require —
3
+ # see SpreeSquare::Client for the same note.
4
+ require 'square'
5
+ require 'net/http'
6
+
7
+ module SpreeSquare
8
+ # The OAuth half of talking to Square — separate from SpreeSquare::Client
9
+ # (which makes authenticated Catalog/Orders/Payments calls with a token
10
+ # already in hand). This class is how that token gets obtained, refreshed,
11
+ # and revoked in the first place.
12
+ #
13
+ # Scopes requested cover every API spree_square actually calls elsewhere
14
+ # (Client#catalog/orders/payments/inventory/locations/webhooks) — keep this
15
+ # list in sync if a new Square API surface gets used.
16
+ class OauthClient
17
+ class ConfigurationError < StandardError; end
18
+
19
+ SCOPES = %w[
20
+ MERCHANT_PROFILE_READ
21
+ ITEMS_READ
22
+ INVENTORY_READ
23
+ INVENTORY_WRITE
24
+ ORDERS_READ
25
+ ORDERS_WRITE
26
+ PAYMENTS_WRITE
27
+ ].freeze
28
+
29
+ def self.authorize_url(...) = new.authorize_url(...)
30
+ def self.exchange_code(...) = new.exchange_code(...)
31
+ def self.refresh(...) = new.refresh(...)
32
+ def self.revoke(...) = new.revoke(...)
33
+
34
+ def initialize
35
+ @application_id = ENV['SQUARE_APPLICATION_ID'].presence
36
+ @application_secret = ENV['SQUARE_APPLICATION_SECRET'].presence
37
+ raise ConfigurationError, 'SQUARE_APPLICATION_ID is not set' if @application_id.blank?
38
+ raise ConfigurationError, 'SQUARE_APPLICATION_SECRET is not set' if @application_secret.blank?
39
+ end
40
+
41
+ # The URL to send the merchant's browser to. `session: false` forces
42
+ # Square to show its account chooser even if the browser is already
43
+ # signed in to a Square account — without it, a staff member testing
44
+ # this on a shared machine could silently connect the wrong account.
45
+ def authorize_url(redirect_uri:, state:)
46
+ params = {
47
+ client_id: @application_id,
48
+ scope: SCOPES.join(' '),
49
+ session: false,
50
+ state: state,
51
+ redirect_uri: redirect_uri
52
+ }
53
+ "#{base_url}/oauth2/authorize?#{params.to_query}"
54
+ end
55
+
56
+ def exchange_code(code:, redirect_uri:)
57
+ oauth_api.obtain_token(
58
+ client_id: @application_id,
59
+ client_secret: @application_secret,
60
+ code: code,
61
+ grant_type: 'authorization_code',
62
+ redirect_uri: redirect_uri
63
+ )
64
+ end
65
+
66
+ # Code-flow refresh (vs. PKCE) returns the *same* refresh token back —
67
+ # Square's docs call this out explicitly, so callers should always save
68
+ # whatever comes back here rather than assuming the old one still works.
69
+ def refresh(credential)
70
+ oauth_api.obtain_token(
71
+ client_id: @application_id,
72
+ client_secret: @application_secret,
73
+ refresh_token: credential.refresh_token,
74
+ grant_type: 'refresh_token'
75
+ )
76
+ end
77
+
78
+ # Square's RevokeToken endpoint doesn't use the normal `Bearer <token>`
79
+ # scheme every other call in this extension uses — it requires
80
+ # `Authorization: Client <application_secret>` instead, which the SDK's
81
+ # Square::Client can't produce (its Authorization header is fixed to
82
+ # Bearer at construction). Raw HTTP here, deliberately not routed
83
+ # through the SDK.
84
+ def revoke(credential)
85
+ uri = URI("#{base_url}/oauth2/revoke")
86
+ request = Net::HTTP::Post.new(uri)
87
+ request['Content-Type'] = 'application/json'
88
+ request['Authorization'] = "Client #{@application_secret}"
89
+ request.body = { client_id: @application_id, access_token: credential.access_token }.to_json
90
+
91
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
92
+ return if response.is_a?(Net::HTTPSuccess)
93
+
94
+ raise Square::Errors::ResponseError.subclass_for_code(response.code.to_i).new(response.body, code: response.code.to_i)
95
+ end
96
+
97
+ private
98
+
99
+ # A bearer token isn't needed for any of oauth2/authorize, oauth2/token,
100
+ # or oauth2/revoke (the latter uses the Client-secret scheme above
101
+ # instead) — passing `token: nil` here just produces an unused,
102
+ # harmless `Authorization: Bearer ` header on the token-exchange/refresh
103
+ # calls, which those endpoints ignore.
104
+ def oauth_api
105
+ @oauth_api ||= Square::Client.new(base_url: base_url, token: nil).o_auth
106
+ end
107
+
108
+ def base_url
109
+ sandbox? ? Square::Environment::SANDBOX : Square::Environment::PRODUCTION
110
+ end
111
+
112
+ def sandbox?
113
+ ENV.fetch('SQUARE_ENVIRONMENT', 'sandbox') == 'sandbox'
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,79 @@
1
+ module SpreeSquare
2
+ # Builds the Square CreateOrder payload for a completed Spree::Order.
3
+ # Deliberately omits pricing beyond per-line-item base_price_money/modifiers
4
+ # — no taxes/discounts sent — so Square's computed total_money is exactly
5
+ # what OrderPusher then charges via the EXTERNAL payment, with nothing for
6
+ # Square's own tax/discount engine to add on top and create a mismatch.
7
+ class OrderBuilder
8
+ def self.call(...) = new.call(...)
9
+
10
+ def call(order)
11
+ location_mapping = location_mapping_for(order)
12
+ raise "No Square location mapped for order #{order.number}" unless location_mapping
13
+
14
+ {
15
+ location_id: location_mapping.square_location_id,
16
+ reference_id: order.number,
17
+ line_items: order.line_items.map { |line_item| build_line_item(line_item) },
18
+ fulfillments: [build_fulfillment(order)]
19
+ }
20
+ end
21
+
22
+ private
23
+
24
+ # Without a fulfillment, the order has nothing for the kitchen/POS to
25
+ # advance through — it just sits there as a paid ticket with no
26
+ # PROPOSED -> RESERVED -> PREPARED -> COMPLETED progression, and
27
+ # order.fulfillment.updated never fires (confirmed: a pushed order with
28
+ # no fulfillments produced zero fulfillment webhooks). PICKUP for now —
29
+ # "Pay at pickup" is the only payment method configured; DELIVERY comes
30
+ # with DoorDash Drive in phase 2.
31
+ def build_fulfillment(order)
32
+ {
33
+ type: 'PICKUP',
34
+ pickup_details: {
35
+ recipient: {
36
+ display_name: order.bill_address&.full_name || order.email,
37
+ email_address: order.email,
38
+ phone_number: order.bill_address&.phone
39
+ }.compact,
40
+ schedule_type: 'ASAP'
41
+ }
42
+ }
43
+ end
44
+
45
+ def location_mapping_for(order)
46
+ stock_location = order.shipments.first&.stock_location || Spree::StockLocation.find_by(default: true)
47
+ return nil unless stock_location
48
+
49
+ SpreeSquare::LocationMapping.find_by(spree_stock_location_id: stock_location.id)
50
+ end
51
+
52
+ def build_line_item(line_item)
53
+ currency = (line_item.currency || 'USD').upcase
54
+ catalog_mapping = SpreeSquare::CatalogMapping.find_by(
55
+ spree_variant_id: line_item.variant_id,
56
+ square_object_type: SpreeSquare::CatalogMapping::ITEM_VARIATION
57
+ )
58
+ modifiers = SpreeSquare::LineItemModifier.where(line_item_id: line_item.id).to_a
59
+ modifier_total_cents = modifiers.sum(&:price_cents_snapshot)
60
+ base_price_cents = (line_item.price * 100).round - modifier_total_cents
61
+
62
+ {
63
+ quantity: line_item.quantity.to_s,
64
+ name: line_item.name,
65
+ catalog_object_id: catalog_mapping&.square_catalog_object_id,
66
+ base_price_money: { amount: base_price_cents, currency: currency },
67
+ modifiers: modifiers.map { |modifier| build_modifier(modifier, currency) }
68
+ }.compact
69
+ end
70
+
71
+ def build_modifier(modifier, currency)
72
+ {
73
+ catalog_object_id: modifier.square_modifier_id,
74
+ name: modifier.name_snapshot,
75
+ base_price_money: { amount: modifier.price_cents_snapshot, currency: currency }
76
+ }
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,68 @@
1
+ module SpreeSquare
2
+ # Pushes a completed Spree order into Square as a paid ticket: creates the
3
+ # Square Order, then records the payment Spree already collected as an
4
+ # EXTERNAL payment against it (Square's own first-class mechanism for
5
+ # "paid somewhere else" — its docs use exactly this "food delivery
6
+ # service" scenario as the example). No Square payment gateway needed for
7
+ # this to work.
8
+ #
9
+ # Charges exactly what Square computed as the order's own total_money,
10
+ # read back from the create-order response, rather than recomputing a
11
+ # total independently — guarantees the payment can never mismatch the
12
+ # order it's attached to.
13
+ class OrderPusher
14
+ def self.call(...) = new.call(...)
15
+
16
+ def call(order)
17
+ client = SpreeSquare::Client.instance
18
+ mapping = SpreeSquare::OrderMapping.find_or_initialize_by(order: order)
19
+
20
+ square_order = create_order(client, order)
21
+ mapping.update!(
22
+ square_order_id: square_order.id,
23
+ square_location_id: square_order.location_id,
24
+ square_version: square_order.version,
25
+ last_status: square_order.state
26
+ )
27
+
28
+ payment = record_external_payment(client, order, square_order)
29
+
30
+ # Attaching a fully-covering payment can advance Square's own order
31
+ # state immediately (e.g. OPEN -> COMPLETED) — re-fetch so the mapping
32
+ # reflects that instead of the pre-payment snapshot. M6 keeps this
33
+ # correct going forward via order.updated webhooks; this just avoids a
34
+ # misleading stale status in the window before the first one arrives.
35
+ refreshed = client.orders.get(order_id: square_order.id).order
36
+ mapping.update!(
37
+ square_payment_id: payment.id,
38
+ square_version: refreshed.version,
39
+ last_status: refreshed.state
40
+ )
41
+
42
+ mapping
43
+ end
44
+
45
+ private
46
+
47
+ def create_order(client, order)
48
+ payload = SpreeSquare::OrderBuilder.call(order)
49
+ response = client.orders.create(
50
+ idempotency_key: "spree-order-#{order.number}",
51
+ order: payload
52
+ )
53
+ response.order
54
+ end
55
+
56
+ def record_external_payment(client, order, square_order)
57
+ total = square_order.total_money
58
+ response = client.payments.create(
59
+ source_id: 'EXTERNAL',
60
+ idempotency_key: "spree-payment-#{order.number}",
61
+ amount_money: { amount: total.amount, currency: total.currency },
62
+ order_id: square_order.id,
63
+ external_details: { type: 'OTHER', source: 'Spree checkout' }
64
+ )
65
+ response.payment
66
+ end
67
+ end
68
+ end