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