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,27 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Handles `inventory.count.updated`. Unlike the catalog webhook, this
|
|
3
|
+
# payload is fully actionable on its own — catalog_object_id, location_id,
|
|
4
|
+
# and the new quantity all arrive inline, no follow-up API call needed.
|
|
5
|
+
class InventoryWebhookJob < BaseJob
|
|
6
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 5 do |job, error|
|
|
7
|
+
SpreeSquare::WebhookEvent.find_by(id: job.arguments.first)&.mark_failed!(error)
|
|
8
|
+
SpreeSquare::Alerting.capture(error, context: 'inventory_webhook')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def perform(webhook_event_id)
|
|
12
|
+
event = SpreeSquare::WebhookEvent.find(webhook_event_id)
|
|
13
|
+
|
|
14
|
+
counts = event.payload.dig('data', 'object', 'inventory_counts') || []
|
|
15
|
+
counts.each do |count|
|
|
16
|
+
SpreeSquare::InventorySync.call(
|
|
17
|
+
catalog_object_id: count['catalog_object_id'],
|
|
18
|
+
location_id: count['location_id'],
|
|
19
|
+
quantity: count['quantity'],
|
|
20
|
+
state: count['state']
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
event.mark_processed!
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# A failed order push is the worst failure mode in this whole system —
|
|
3
|
+
# payment already taken, kitchen never sees the ticket. Retries with
|
|
4
|
+
# backoff; if genuinely exhausted, this is the one alert in the whole
|
|
5
|
+
# extension that should always reach a human regardless of Sentry config,
|
|
6
|
+
# which is why Alerting always logs at error level even when Sentry isn't
|
|
7
|
+
# available.
|
|
8
|
+
class OrderPushJob < BaseJob
|
|
9
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 5 do |job, error|
|
|
10
|
+
order = Spree::Order.find_by(id: job.arguments.first)
|
|
11
|
+
SpreeSquare::OrderMapping.find_or_initialize_by(order: order).mark_failed!(error) if order
|
|
12
|
+
SpreeSquare::Alerting.capture(
|
|
13
|
+
error,
|
|
14
|
+
context: { area: 'order_push', order_number: order&.number }
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def perform(order_id)
|
|
19
|
+
order = Spree::Order.find(order_id)
|
|
20
|
+
SpreeSquare::OrderPusher.call(order)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Handles `order.updated` and `order.fulfillment.updated`. Payload shapes
|
|
3
|
+
# verified against real Square test webhooks (not assumed from docs):
|
|
4
|
+
#
|
|
5
|
+
# order.updated: data.object.order_updated
|
|
6
|
+
# { order_id, state, version, ... }
|
|
7
|
+
# order.fulfillment.updated: data.object.order_fulfillment_updated
|
|
8
|
+
# { order_id, version, fulfillment_update:
|
|
9
|
+
# [{ fulfillment_uid, new_state, old_state }] }
|
|
10
|
+
class OrderWebhookJob < BaseJob
|
|
11
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 5 do |job, error|
|
|
12
|
+
SpreeSquare::WebhookEvent.find_by(id: job.arguments.first)&.mark_failed!(error)
|
|
13
|
+
SpreeSquare::Alerting.capture(error, context: 'order_webhook')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def perform(webhook_event_id)
|
|
17
|
+
event = SpreeSquare::WebhookEvent.find(webhook_event_id)
|
|
18
|
+
|
|
19
|
+
case event.event_type
|
|
20
|
+
when 'order.updated'
|
|
21
|
+
handle_order_updated(event.payload)
|
|
22
|
+
when 'order.fulfillment.updated'
|
|
23
|
+
handle_fulfillment_updated(event.payload)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
event.mark_processed!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def handle_order_updated(payload)
|
|
32
|
+
data = payload.dig('data', 'object', 'order_updated') || {}
|
|
33
|
+
SpreeSquare::OrderStatusMapper.call(
|
|
34
|
+
square_order_id: data['order_id'],
|
|
35
|
+
version: data['version'],
|
|
36
|
+
order_state: data['state']
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def handle_fulfillment_updated(payload)
|
|
41
|
+
data = payload.dig('data', 'object', 'order_fulfillment_updated') || {}
|
|
42
|
+
Array(data['fulfillment_update']).each do |update|
|
|
43
|
+
SpreeSquare::OrderStatusMapper.call(
|
|
44
|
+
square_order_id: data['order_id'],
|
|
45
|
+
version: data['version'],
|
|
46
|
+
fulfillment_state: update['new_state']
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Nightly safety net for missed or failed webhooks — re-runs the same
|
|
3
|
+
# catalog import and inventory sync services M2/M3 already built (no new
|
|
4
|
+
# sync logic), just as a full pass instead of an incremental one. Cheap
|
|
5
|
+
# insurance for a restaurant-sized catalog; not built for a bulk catalog.
|
|
6
|
+
class ReconciliationJob < BaseJob
|
|
7
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 3 do |_job, error|
|
|
8
|
+
SpreeSquare::Alerting.capture(error, context: 'reconciliation')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def perform
|
|
12
|
+
SpreeSquare::CatalogImporter.call
|
|
13
|
+
reconcile_inventory
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def reconcile_inventory
|
|
19
|
+
client = SpreeSquare::Client.instance
|
|
20
|
+
location_ids = SpreeSquare::LocationMapping.pluck(:square_location_id)
|
|
21
|
+
return if location_ids.empty?
|
|
22
|
+
|
|
23
|
+
# No `states:` filter — an item that went out of stock is exactly the
|
|
24
|
+
# kind of drift this job exists to catch, and it wouldn't show up in
|
|
25
|
+
# an IN_STOCK-only result.
|
|
26
|
+
client.inventory.batch_get_counts(location_ids: location_ids).each do |count|
|
|
27
|
+
SpreeSquare::InventorySync.call(
|
|
28
|
+
catalog_object_id: count.catalog_object_id,
|
|
29
|
+
location_id: count.location_id,
|
|
30
|
+
quantity: count.quantity,
|
|
31
|
+
state: count.state
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# Zeitwerk requires this module's name to match its file path exactly
|
|
3
|
+
# (app/models/spree/line_item_decorator.rb -> Spree::LineItemDecorator) —
|
|
4
|
+
# under dev's lazy autoloading, a mismatched name (e.g. a
|
|
5
|
+
# SpreeSquare-prefixed one) means nothing ever triggers loading this file
|
|
6
|
+
# at all, silently, since nothing references the name Zeitwerk expects.
|
|
7
|
+
module LineItemDecorator
|
|
8
|
+
# Transient carrier for selected modifier ids from add-to-cart through to
|
|
9
|
+
# SpreeSquare::Cart::AddItem, which reads it right after `super` to build
|
|
10
|
+
# the persistent LineItemModifier snapshot rows. Never persisted itself —
|
|
11
|
+
# `square_modifier_ids=` is populated via LineItem#options= (see
|
|
12
|
+
# `variant_decorator.rb`'s `square_modifier_ids_price_modifier_amount`,
|
|
13
|
+
# dispatched from the same options hash), which only lives for the
|
|
14
|
+
# request that created the line item.
|
|
15
|
+
attr_accessor :square_modifier_ids
|
|
16
|
+
|
|
17
|
+
# Both of these reset price to the variant's base price with no idea
|
|
18
|
+
# about our modifier selections (which live in a separate table
|
|
19
|
+
# specifically so a later Square catalog edit can't retroactively change
|
|
20
|
+
# what a customer already paid for) — necessary so a genuine price
|
|
21
|
+
# change from Square (M3) takes effect, but it means the modifier delta
|
|
22
|
+
# has to be re-added every time, not just once at add-to-cart.
|
|
23
|
+
#
|
|
24
|
+
# Confirmed (by grepping all of spree_core, not sampling) these are the
|
|
25
|
+
# *only* two places core reassigns line item price outside our own code:
|
|
26
|
+
# `recalculate_price` fires on cart mutations (Cart::AddItem calls it
|
|
27
|
+
# directly); `update_price` is what Order#update_line_item_prices! calls
|
|
28
|
+
# on every line item via a `before_transition from: :address` checkout
|
|
29
|
+
# callback — the bug that surfaced this: modifier pricing was correct
|
|
30
|
+
# right after add-to-cart, then silently reverted to base price the
|
|
31
|
+
# moment checkout address was submitted.
|
|
32
|
+
def recalculate_price
|
|
33
|
+
super
|
|
34
|
+
apply_square_modifier_delta!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def update_price
|
|
38
|
+
super
|
|
39
|
+
apply_square_modifier_delta!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def apply_square_modifier_delta!
|
|
45
|
+
return unless persisted?
|
|
46
|
+
|
|
47
|
+
delta_cents = SpreeSquare::LineItemModifier.where(line_item_id: id).sum(:price_cents_snapshot)
|
|
48
|
+
return if delta_cents.zero?
|
|
49
|
+
|
|
50
|
+
update_columns(price: price + (delta_cents / 100.0), updated_at: Time.current)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
LineItem.prepend(LineItemDecorator)
|
|
55
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# Zeitwerk-compliant name required for this file to autoload at all — see
|
|
3
|
+
# the comment in line_item_decorator.rb.
|
|
4
|
+
module VariantDecorator
|
|
5
|
+
# Hooks into Spree's own pluggable price-modifier dispatch
|
|
6
|
+
# (Variant#price_modifier_amount calls "#{key}_price_modifier_amount" for
|
|
7
|
+
# each key present in the line item's `options`) so the *initial*
|
|
8
|
+
# add-to-cart price is correct immediately, before LineItemModifier rows
|
|
9
|
+
# even exist yet. The decorated LineItem#recalculate_price is what keeps
|
|
10
|
+
# it correct after that.
|
|
11
|
+
def square_modifier_ids_price_modifier_amount(modifier_ids)
|
|
12
|
+
return 0 if modifier_ids.blank?
|
|
13
|
+
|
|
14
|
+
SpreeSquare::Modifier.where(square_modifier_id: Array(modifier_ids)).sum(:price_cents) / 100.0
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Variant.prepend(VariantDecorator)
|
|
19
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Maps a Square catalog object (ITEM or ITEM_VARIATION) to the Spree record
|
|
3
|
+
# it was imported into. `square_version` is Square's optimistic-concurrency
|
|
4
|
+
# token — compared before writing so out-of-order or duplicate webhook
|
|
5
|
+
# delivery (M3) can't clobber newer data with older data.
|
|
6
|
+
class CatalogMapping < Spree.base_class
|
|
7
|
+
self.table_name = 'spree_square_catalog_mappings'
|
|
8
|
+
|
|
9
|
+
ITEM = 'item'.freeze
|
|
10
|
+
ITEM_VARIATION = 'item_variation'.freeze
|
|
11
|
+
|
|
12
|
+
belongs_to :product, class_name: 'Spree::Product', foreign_key: 'spree_product_id', optional: true
|
|
13
|
+
belongs_to :variant, class_name: 'Spree::Variant', foreign_key: 'spree_variant_id', optional: true
|
|
14
|
+
|
|
15
|
+
validates :square_catalog_object_id, presence: true, uniqueness: true
|
|
16
|
+
validates :square_object_type, presence: true, inclusion: { in: [ITEM, ITEM_VARIATION] }
|
|
17
|
+
|
|
18
|
+
# True if `incoming_version` is not newer than what we already recorded —
|
|
19
|
+
# i.e. it's safe to skip re-processing (duplicate or out-of-order
|
|
20
|
+
# delivery).
|
|
21
|
+
def stale?(incoming_version)
|
|
22
|
+
square_version.present? && incoming_version.present? && incoming_version <= square_version
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# A Square OAuth connection for one Spree::Store. Replaces the
|
|
3
|
+
# SQUARE_ACCESS_TOKEN env var with a per-store, self-service "Connect to
|
|
4
|
+
# Square" flow (see SpreeSquare::OauthClient and
|
|
5
|
+
# Spree::Admin::SquareOauthController) — the access-token mechanism Square
|
|
6
|
+
# requires multi-merchant apps to move away from before listing on the App
|
|
7
|
+
# Marketplace (personal access tokens are explicitly disallowed there).
|
|
8
|
+
#
|
|
9
|
+
# `access_token`/`refresh_token` are encrypted at rest (ActiveRecord::
|
|
10
|
+
# Encryption — see config/initializers/active_record_encryption.rb); Square
|
|
11
|
+
# tokens expire every 30 days and Square recommends refreshing every 7 or
|
|
12
|
+
# fewer, hence REFRESH_BUFFER below.
|
|
13
|
+
class Credential < Spree.base_class
|
|
14
|
+
self.table_name = 'spree_square_credentials'
|
|
15
|
+
|
|
16
|
+
REFRESH_BUFFER = 7.days
|
|
17
|
+
|
|
18
|
+
belongs_to :store, class_name: 'Spree::Store'
|
|
19
|
+
|
|
20
|
+
encrypts :access_token, :refresh_token
|
|
21
|
+
|
|
22
|
+
validates :store, presence: true, uniqueness: true
|
|
23
|
+
validates :square_merchant_id, presence: true, uniqueness: true
|
|
24
|
+
|
|
25
|
+
def sandbox?
|
|
26
|
+
square_environment == 'sandbox'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def expired?
|
|
30
|
+
expires_at.present? && expires_at <= Time.current
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# True once we're inside Square's recommended refresh window — checked
|
|
34
|
+
# before every API call (see SpreeSquare::Client#ensure_fresh!) rather
|
|
35
|
+
# than on a schedule, so a credential that's gone briefly unused still
|
|
36
|
+
# gets refreshed the moment it's needed again.
|
|
37
|
+
def needs_refresh?
|
|
38
|
+
expires_at.present? && expires_at <= REFRESH_BUFFER.from_now
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# A modifier selected on a specific line item, snapshotted at add-to-cart
|
|
3
|
+
# time. The snapshot (not a live reference) is deliberate: a later Square
|
|
4
|
+
# menu edit must never retroactively change what a past order shows or
|
|
5
|
+
# cost.
|
|
6
|
+
class LineItemModifier < Spree.base_class
|
|
7
|
+
self.table_name = 'spree_square_line_item_modifiers'
|
|
8
|
+
|
|
9
|
+
belongs_to :line_item, class_name: 'Spree::LineItem'
|
|
10
|
+
belongs_to :modifier, class_name: 'SpreeSquare::Modifier', optional: true
|
|
11
|
+
|
|
12
|
+
validates :name_snapshot, presence: true
|
|
13
|
+
validates :price_cents_snapshot, numericality: true
|
|
14
|
+
|
|
15
|
+
def self.build_from(modifier)
|
|
16
|
+
new(
|
|
17
|
+
modifier: modifier,
|
|
18
|
+
square_modifier_id: modifier.square_modifier_id,
|
|
19
|
+
name_snapshot: modifier.name,
|
|
20
|
+
price_cents_snapshot: modifier.price_cents
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Maps a Square Location 1:1 to a Spree::StockLocation. Restaurant branches
|
|
3
|
+
# map onto Spree's existing multi-warehouse StockLocation model —
|
|
4
|
+
# StockItem's per-location inventory tracking is exactly what "86'd at this
|
|
5
|
+
# branch but not that one" needs, for free.
|
|
6
|
+
class LocationMapping < Spree.base_class
|
|
7
|
+
self.table_name = 'spree_square_location_mappings'
|
|
8
|
+
|
|
9
|
+
belongs_to :stock_location, class_name: 'Spree::StockLocation', foreign_key: 'spree_stock_location_id'
|
|
10
|
+
|
|
11
|
+
validates :square_location_id, presence: true, uniqueness: true
|
|
12
|
+
validates :stock_location, presence: true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Mirrors a single Square MODIFIER (e.g. "Extra cheese", +$1.50).
|
|
3
|
+
class Modifier < Spree.base_class
|
|
4
|
+
self.table_name = 'spree_square_modifiers'
|
|
5
|
+
|
|
6
|
+
belongs_to :modifier_list, class_name: 'SpreeSquare::ModifierList'
|
|
7
|
+
|
|
8
|
+
validates :square_modifier_id, presence: true, uniqueness: true
|
|
9
|
+
validates :name, presence: true
|
|
10
|
+
validates :price_cents, numericality: { greater_than_or_equal_to: 0 }
|
|
11
|
+
|
|
12
|
+
def price = price_cents / 100.0
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Mirrors a Square MODIFIER_LIST (e.g. "Choose your sauce"). Modeled
|
|
3
|
+
# separately from Spree's OptionType/Variant system on purpose — Square
|
|
4
|
+
# modifiers are multi-select, per-line-item customizations, not mutually
|
|
5
|
+
# exclusive product variations, and forcing them through OptionType would
|
|
6
|
+
# explode the variant matrix combinatorially.
|
|
7
|
+
class ModifierList < Spree.base_class
|
|
8
|
+
self.table_name = 'spree_square_modifier_lists'
|
|
9
|
+
|
|
10
|
+
SINGLE = 'SINGLE'.freeze
|
|
11
|
+
MULTIPLE = 'MULTIPLE'.freeze
|
|
12
|
+
|
|
13
|
+
has_many :modifiers, class_name: 'SpreeSquare::Modifier', dependent: :destroy
|
|
14
|
+
has_many :product_modifier_lists, class_name: 'SpreeSquare::ProductModifierList', dependent: :destroy
|
|
15
|
+
has_many :products, class_name: 'Spree::Product', through: :product_modifier_lists
|
|
16
|
+
|
|
17
|
+
validates :square_modifier_list_id, presence: true, uniqueness: true
|
|
18
|
+
validates :name, presence: true
|
|
19
|
+
validates :selection_type, inclusion: { in: [SINGLE, MULTIPLE] }
|
|
20
|
+
|
|
21
|
+
def multiple? = selection_type == MULTIPLE
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Maps a Spree::Order to the Square Order (and its EXTERNAL payment) it was
|
|
3
|
+
# pushed to on completion.
|
|
4
|
+
class OrderMapping < Spree.base_class
|
|
5
|
+
self.table_name = 'spree_square_order_mappings'
|
|
6
|
+
|
|
7
|
+
belongs_to :order, class_name: 'Spree::Order', foreign_key: 'spree_order_id'
|
|
8
|
+
|
|
9
|
+
validates :order, presence: true, uniqueness: true
|
|
10
|
+
|
|
11
|
+
def mark_failed!(error)
|
|
12
|
+
update!(push_error: error.to_s.truncate(2000))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Join: which modifier lists apply to which product (a product can have
|
|
3
|
+
# several — e.g. "Choose your sauce" + "Extra toppings").
|
|
4
|
+
class ProductModifierList < Spree.base_class
|
|
5
|
+
self.table_name = 'spree_square_product_modifier_lists'
|
|
6
|
+
|
|
7
|
+
belongs_to :product, class_name: 'Spree::Product'
|
|
8
|
+
belongs_to :modifier_list, class_name: 'SpreeSquare::ModifierList'
|
|
9
|
+
|
|
10
|
+
validates :modifier_list_id, uniqueness: { scope: :product_id }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Maps a Square Category to a Spree::Category — the modern, store-scoped
|
|
3
|
+
# replacement for the legacy Taxonomy-backed Taxon system (see
|
|
4
|
+
# Spree::Category's own comment; it becomes the base class in Spree 6.0).
|
|
5
|
+
#
|
|
6
|
+
# Spree::Category isn't true STI (spree_taxons has no `type` column to
|
|
7
|
+
# dispatch on) — it's a plain Taxon subclass distinguished only by
|
|
8
|
+
# `default_scope { manual }` and an overridden `requires_taxonomy?`
|
|
9
|
+
# (false, vs. Taxon's own `true`). That means the association's
|
|
10
|
+
# `class_name` is what determines which class `#taxon` instantiates as,
|
|
11
|
+
# not the row's own data. Declaring it as `'Spree::Taxon'` here meant
|
|
12
|
+
# every re-fetched taxon silently reverted to the base class — Category's
|
|
13
|
+
# `requires_taxonomy?` override never applied, so any re-save (e.g. a
|
|
14
|
+
# name update from a later Square sync) failed with a bogus "Taxonomy
|
|
15
|
+
# can't be blank", even though the taxon was created as a Category with
|
|
16
|
+
# no taxonomy in the first place.
|
|
17
|
+
class TaxonMapping < Spree.base_class
|
|
18
|
+
self.table_name = 'spree_square_taxon_mappings'
|
|
19
|
+
|
|
20
|
+
belongs_to :taxon, class_name: 'Spree::Category', foreign_key: 'spree_taxon_id'
|
|
21
|
+
|
|
22
|
+
validates :square_category_id, presence: true, uniqueness: true
|
|
23
|
+
validates :taxon, presence: true
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Idempotency + audit log for inbound Square webhook notifications. The
|
|
3
|
+
# unique index on square_event_id is what makes Square's at-least-once
|
|
4
|
+
# delivery safe to process without duplicating side effects.
|
|
5
|
+
class WebhookEvent < Spree.base_class
|
|
6
|
+
self.table_name = 'spree_square_webhook_events'
|
|
7
|
+
|
|
8
|
+
validates :square_event_id, presence: true, uniqueness: true
|
|
9
|
+
validates :event_type, presence: true
|
|
10
|
+
|
|
11
|
+
scope :pending, -> { where(status: 'pending') }
|
|
12
|
+
|
|
13
|
+
def mark_processed!
|
|
14
|
+
update!(status: 'processed', processed_at: Time.current)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def mark_failed!(error)
|
|
18
|
+
update!(status: 'failed', processed_at: Time.current, error_message: error.to_s.truncate(1000))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Exposes the modifiers selected on a line item (for cart/checkout
|
|
3
|
+
# display) using the snapshot recorded at add-to-cart time — never a live
|
|
4
|
+
# lookup, so a later Square menu edit can't change what's shown for an
|
|
5
|
+
# existing cart/order.
|
|
6
|
+
class LineItemSerializer < Spree::Api::V3::LineItemSerializer
|
|
7
|
+
attribute :square_modifiers do |line_item|
|
|
8
|
+
SpreeSquare::LineItemModifier.where(line_item_id: line_item.id).map do |lim|
|
|
9
|
+
{ name: lim.name_snapshot, price_cents: lim.price_cents_snapshot, display_price: lim.price_cents_snapshot / 100.0 }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Adds the modifier lists attached to a product (see
|
|
3
|
+
# customization/api.mdx's documented pattern: subclass the core V3
|
|
4
|
+
# serializer and register via Spree::Api::Dependencies, rather than
|
|
5
|
+
# decorating Spree::Api::V3::ProductSerializer directly).
|
|
6
|
+
class ProductSerializer < Spree::Api::V3::ProductSerializer
|
|
7
|
+
# Re-declares the `categories` field from the core serializer (Alba
|
|
8
|
+
# associations are keyed by name, so redeclaring here overrides it) to
|
|
9
|
+
# fix a NoMethodError on `Spree::Category` taxons.
|
|
10
|
+
#
|
|
11
|
+
# The core field is `taxons.select { |t| t.taxonomy.store_id == ... }`,
|
|
12
|
+
# which assumes every taxon belongs to a Taxonomy. `Spree::Category` (see
|
|
13
|
+
# its own comment: the store-scoped, taxonomy-free replacement — becomes
|
|
14
|
+
# the base class in Spree 6.0) has `taxonomy_id: nil`, so `t.taxonomy` is
|
|
15
|
+
# nil and `.store_id` raises. Any `expand=categories` (or
|
|
16
|
+
# `categories.ancestors`, as the Next.js storefront's PDP always
|
|
17
|
+
# requests) 500s for every product in this catalog, since it's entirely
|
|
18
|
+
# Category-based. A Category is already store-scoped via its own
|
|
19
|
+
# `store_id` column (no taxonomy indirection needed), so that's the
|
|
20
|
+
# correct check to fall back to when `taxonomy` is nil.
|
|
21
|
+
many :taxons,
|
|
22
|
+
proc { |taxons, params|
|
|
23
|
+
taxons.select { |t| (t.taxonomy&.store_id || t.store_id) == params[:store].id }
|
|
24
|
+
},
|
|
25
|
+
key: :categories,
|
|
26
|
+
resource: proc { Spree.api.category_serializer },
|
|
27
|
+
if: proc { expand?('categories') }
|
|
28
|
+
|
|
29
|
+
attribute :modifier_lists do |product|
|
|
30
|
+
SpreeSquare::ProductModifierList.where(product_id: product.id)
|
|
31
|
+
.includes(modifier_list: :modifiers)
|
|
32
|
+
.map do |pml|
|
|
33
|
+
list = pml.modifier_list
|
|
34
|
+
{
|
|
35
|
+
id: list.square_modifier_list_id,
|
|
36
|
+
name: list.name,
|
|
37
|
+
selection_type: list.selection_type,
|
|
38
|
+
min_selected_modifiers: list.min_selected_modifiers,
|
|
39
|
+
max_selected_modifiers: list.max_selected_modifiers,
|
|
40
|
+
modifiers: list.modifiers.map do |m|
|
|
41
|
+
{ id: m.square_modifier_id, name: m.name, price_cents: m.price_cents, display_price: m.price }
|
|
42
|
+
end
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# One place for "this needs a human" — used when a job exhausts its
|
|
3
|
+
# retries. Reports to Sentry (already in this app's stack) when available,
|
|
4
|
+
# always logs at error level regardless so nothing depends on Sentry being
|
|
5
|
+
# configured to at least be visible in the server log.
|
|
6
|
+
class Alerting
|
|
7
|
+
def self.capture(error, context: {})
|
|
8
|
+
context = { source: 'spree_square' }.merge(context.is_a?(String) ? { area: context } : context)
|
|
9
|
+
|
|
10
|
+
Rails.logger.error("[SpreeSquare] #{context[:area] || 'error'}: #{error.class}: #{error.message}")
|
|
11
|
+
|
|
12
|
+
return unless defined?(Sentry)
|
|
13
|
+
|
|
14
|
+
Sentry.capture_exception(error, extra: context)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
module Cart
|
|
3
|
+
# Mirrors Spree::Cart::AddItem's own step sequence (see CLAUDE.md pattern
|
|
4
|
+
# #2 — swap the service, don't decorate it) with one inserted step.
|
|
5
|
+
#
|
|
6
|
+
# Selected modifier ids arrive via the line item's transient
|
|
7
|
+
# `square_modifier_ids` (set by LineItem#options=, populated from the
|
|
8
|
+
# `options:` param — see variant_decorator.rb's dispatch target and the
|
|
9
|
+
# `Spree::PermittedAttributes.line_item_attributes` addition in this
|
|
10
|
+
# extension's initializer). That gets the *initial* price right via
|
|
11
|
+
# Spree's own price-modifier mechanism, but the persistent
|
|
12
|
+
# LineItemModifier snapshot rows — what survives future recalculations
|
|
13
|
+
# and what M5's order push reads — don't exist until this step creates
|
|
14
|
+
# them. `add_to_line_item` already called `recalculate_price` once
|
|
15
|
+
# (before these rows existed, so it found no delta); calling it again
|
|
16
|
+
# here is what makes the response actually reflect the final price.
|
|
17
|
+
class AddItem < Spree::Cart::AddItem
|
|
18
|
+
# Spree::ServiceModule::Base is prepended onto the *parent* class
|
|
19
|
+
# (Spree::Cart::AddItem). A subclass's own `call` sits ahead of that
|
|
20
|
+
# prepended module in ITS OWN ancestor chain (subclassing doesn't lower
|
|
21
|
+
# the subclass's method priority below modules prepended only onto the
|
|
22
|
+
# ancestor) — without re-prepending here, `run` blows up on a nil
|
|
23
|
+
# `@_passed_input` because Base#call never got a chance to initialize it.
|
|
24
|
+
prepend Spree::ServiceModule::Base
|
|
25
|
+
|
|
26
|
+
def call(order:, variant:, quantity: nil, metadata: {}, public_metadata: {}, private_metadata: {}, options: {})
|
|
27
|
+
ApplicationRecord.transaction do
|
|
28
|
+
run :add_to_line_item
|
|
29
|
+
run :attach_square_modifiers
|
|
30
|
+
run :handle_stock_reservations
|
|
31
|
+
run Spree.cart_recalculate_service
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def attach_square_modifiers(order:, line_item:, line_item_created:, options:)
|
|
38
|
+
modifier_ids = Array(line_item.square_modifier_ids)
|
|
39
|
+
|
|
40
|
+
if modifier_ids.present? && line_item_created
|
|
41
|
+
SpreeSquare::Modifier.where(square_modifier_id: modifier_ids).find_each do |modifier|
|
|
42
|
+
lim = SpreeSquare::LineItemModifier.build_from(modifier)
|
|
43
|
+
lim.line_item = line_item
|
|
44
|
+
lim.save!
|
|
45
|
+
end
|
|
46
|
+
line_item.recalculate_price
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
success(order: order, line_item: line_item, line_item_created: line_item_created, options: options)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module SpreeSquare
|
|
2
|
+
# Full catalog import: pulls every ITEM + CATEGORY + MODIFIER_LIST from
|
|
3
|
+
# Square (with related objects — images, referenced categories — inlined
|
|
4
|
+
# via `include_related_objects`) and upserts them into Spree via
|
|
5
|
+
# CatalogObjectMapper. Categories and modifier lists are imported before
|
|
6
|
+
# items so an item can resolve its `category_id` / `modifier_list_info` to
|
|
7
|
+
# an already-mapped record.
|
|
8
|
+
class CatalogImporter
|
|
9
|
+
Result = Struct.new(:categories_count, :modifier_lists_count, :items_count, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def self.call = new.call
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
client = SpreeSquare::Client.instance
|
|
15
|
+
objects, related_by_id = fetch_all(client)
|
|
16
|
+
|
|
17
|
+
by_type = objects.group_by(&:type)
|
|
18
|
+
categories = by_type.fetch('CATEGORY', [])
|
|
19
|
+
modifier_lists = by_type.fetch('MODIFIER_LIST', [])
|
|
20
|
+
items = by_type.fetch('ITEM', [])
|
|
21
|
+
|
|
22
|
+
mapper = CatalogObjectMapper.new(related_objects_by_id: related_by_id)
|
|
23
|
+
categories.each { |category| mapper.map_category(category) }
|
|
24
|
+
modifier_lists.each { |list| mapper.map_modifier_list(list) }
|
|
25
|
+
items.each { |item| mapper.map_item(item) }
|
|
26
|
+
|
|
27
|
+
Result.new(categories_count: categories.size, modifier_lists_count: modifier_lists.size, items_count: items.size)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Square's search is a single page per call; loop on `cursor` until
|
|
33
|
+
# exhausted. Fine for a restaurant-sized catalog (dozens to low hundreds
|
|
34
|
+
# of items) — not built for bulk/enterprise catalogs.
|
|
35
|
+
def fetch_all(client)
|
|
36
|
+
objects = []
|
|
37
|
+
related_by_id = {}
|
|
38
|
+
cursor = nil
|
|
39
|
+
|
|
40
|
+
loop do
|
|
41
|
+
response = client.catalog.search(
|
|
42
|
+
object_types: %w[ITEM CATEGORY MODIFIER_LIST],
|
|
43
|
+
include_related_objects: true,
|
|
44
|
+
cursor: cursor
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
objects.concat(Array(response.objects))
|
|
48
|
+
Array(response.related_objects).each { |o| related_by_id[o.id] = o }
|
|
49
|
+
|
|
50
|
+
cursor = response.cursor
|
|
51
|
+
break if cursor.blank?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
[objects, related_by_id]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|