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
|
@@ -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
|