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,87 @@
1
+ module SpreeSquare
2
+ # Applies a Square order/fulfillment status change to the mapped Spree
3
+ # order.
4
+ #
5
+ # Deliberately does NOT gate on `version` being newer than what's
6
+ # recorded, despite that being the pattern used elsewhere in this
7
+ # extension (catalog/inventory sync). Verified directly: Square emits
8
+ # `order.updated` and `order.fulfillment.updated` for the *same*
9
+ # underlying mutation carrying the *same* version number — they're two
10
+ # views of one change, not a sequence. Gating on version meant whichever
11
+ # webhook's job happened to run first "claimed" that version, and the
12
+ # other was dropped as "stale" even though it carried different,
13
+ # necessary information (e.g. the fulfillment transition itself). That
14
+ # bug was caught by testing a real cancellation end-to-end, not by
15
+ # inspecting the code.
16
+ #
17
+ # Safety against duplicate/out-of-order delivery instead comes from two
18
+ # places that don't have this problem: WebhookEvent's uniqueness on
19
+ # Square's own event_id (exact redelivery is a no-op before this class
20
+ # ever runs), and the state-guarded idempotent operations below (`unless
21
+ # canceled?`, `if ready?`) — applying the same transition twice, or an
22
+ # old one after a newer one already landed, does nothing either way.
23
+ class OrderStatusMapper
24
+ FULFILLMENT_SHIP_STATES = %w[COMPLETED].freeze
25
+ # PROPOSED/RESERVED/PREPARED: kitchen-visible progress with no Spree
26
+ # shipment-state equivalent ("food is cooking") — recorded as a friendly
27
+ # label on the mapping, not forced into shipment_state.
28
+ FULFILLMENT_LABEL_STATES = %w[PROPOSED RESERVED PREPARED].freeze
29
+
30
+ def self.call(...) = new.call(...)
31
+
32
+ def call(square_order_id:, version:, order_state: nil, fulfillment_state: nil)
33
+ mapping = SpreeSquare::OrderMapping.find_by(square_order_id: square_order_id)
34
+ return unless mapping
35
+
36
+ order = mapping.order
37
+
38
+ apply_fulfillment_state(order, fulfillment_state) if fulfillment_state
39
+ apply_order_state(order, order_state) if order_state
40
+
41
+ # Recorded for visibility/debugging (admin page, M8) — informational
42
+ # only, never gates whether this call's transitions above applied.
43
+ mapping.update!(square_version: version, last_status: fulfillment_state || order_state || mapping.last_status)
44
+ end
45
+
46
+ private
47
+
48
+ def apply_fulfillment_state(order, state)
49
+ case state
50
+ when *FULFILLMENT_SHIP_STATES
51
+ ship!(order)
52
+ when 'CANCELED', 'FAILED'
53
+ # This — not order.state — is the real-world cancel signal. Square
54
+ # rejects setting order.state to CANCELED once a payment has been
55
+ # processed against it ("Orders cannot be canceled after payments
56
+ # have been processed") — confirmed by hitting that error directly.
57
+ # A paid order that isn't happening gets its *fulfillment* canceled
58
+ # instead (the order itself stays COMPLETED for Square's own
59
+ # accounting); that's the signal to cancel on the Spree side too.
60
+ cancel!(order)
61
+ end
62
+ # FULFILLMENT_LABEL_STATES (PROPOSED/RESERVED/PREPARED) need no
63
+ # Spree-side action beyond the last_status the caller persists —
64
+ # surfaced later as a friendly label on the customer's order status
65
+ # page.
66
+ end
67
+
68
+ def apply_order_state(order, state)
69
+ case state
70
+ when 'CANCELED'
71
+ # Still handled for the (rarer) case of a Square order canceled
72
+ # before any payment was attached.
73
+ cancel!(order)
74
+ when 'COMPLETED'
75
+ ship!(order)
76
+ end
77
+ end
78
+
79
+ def cancel!(order)
80
+ order.cancel! unless order.canceled?
81
+ end
82
+
83
+ def ship!(order)
84
+ order.shipments.each { |shipment| shipment.ship! if shipment.ready? }
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,55 @@
1
+ require 'net/http'
2
+
3
+ module SpreeSquare
4
+ # Tells the Next.js storefront which Cache Components tags to drop after a
5
+ # sync write, so a price/menu/inventory change from Square shows up
6
+ # immediately instead of waiting out the storefront's cacheLife("tenMinutes")
7
+ # window.
8
+ #
9
+ # Deliberately not routed through Spree's own outbound webhook system
10
+ # (Spree::WebhookEndpoint): that system's `product.updated` event is
11
+ # suppressed on touch-only cascades, which is exactly how a price or stock
12
+ # change reaches the product (variant/price/stock_item touch the product,
13
+ # they don't update it directly) — so it would silently miss most of what
14
+ # this extension syncs. This service is called directly from the sync code,
15
+ # which already knows the exact product with no ambiguity.
16
+ #
17
+ # Failure here is logged, never raised — a stale storefront cache for up to
18
+ # 10 minutes is a much smaller problem than a failed revalidation call
19
+ # breaking catalog or inventory sync.
20
+ class Revalidator
21
+ def self.call(...) = new.call(...)
22
+
23
+ def call(tags)
24
+ return if url.blank? || secret.blank? || tags.blank?
25
+
26
+ uri = URI.parse(url)
27
+ http = Net::HTTP.new(uri.host, uri.port)
28
+ http.use_ssl = uri.scheme == 'https'
29
+ http.open_timeout = 2
30
+ http.read_timeout = 2
31
+
32
+ request = Net::HTTP::Post.new(uri.request_uri)
33
+ request['Content-Type'] = 'application/json'
34
+ request['x-revalidate-secret'] = secret
35
+ request.body = { tags: Array(tags) }.to_json
36
+
37
+ response = http.request(request)
38
+ unless response.is_a?(Net::HTTPSuccess)
39
+ Rails.logger.warn("[SpreeSquare] revalidate call failed (#{response.code}): #{response.body}")
40
+ end
41
+ rescue StandardError => e
42
+ Rails.logger.warn("[SpreeSquare] revalidate call errored: #{e.message}")
43
+ end
44
+
45
+ private
46
+
47
+ def url
48
+ ENV['STOREFRONT_REVALIDATE_URL']
49
+ end
50
+
51
+ def secret
52
+ ENV['STOREFRONT_REVALIDATE_SECRET']
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,21 @@
1
+ module SpreeSquare
2
+ # Verifies the `x-square-hmacsha256-signature` header Square sends on every
3
+ # webhook POST. Algorithm per Square's spec (mirrored from the legacy SDK's
4
+ # WebhooksHelper, since the current SDK doesn't ship a verifier — Square's
5
+ # signing scheme itself, not the SDK's shape, is what this depends on):
6
+ #
7
+ # base64(HMAC-SHA256(signing_key, notification_url + raw_request_body))
8
+ #
9
+ # compared against the header using a constant-time comparison.
10
+ class WebhookVerifier
11
+ def self.valid?(url:, body:, signature:, signing_key:)
12
+ return false if body.nil? || signature.blank? || signing_key.blank?
13
+
14
+ payload = "#{url}#{body}".dup.force_encoding('UTF-8')
15
+ digest = OpenSSL::HMAC.digest('sha256', signing_key.dup.force_encoding('UTF-8'), payload)
16
+ expected = Base64.strict_encode64(digest)
17
+
18
+ ActiveSupport::SecurityUtils.secure_compare(expected, signature)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ module SpreeSquare
2
+ # Events & Subscribers is the preferred pattern for this kind of side
3
+ # effect (per this app's own CLAUDE.md conventions) — react to
4
+ # order.completed without touching Spree::Order itself.
5
+ class OrderCompletedSubscriber < Spree::Subscriber
6
+ subscribes_to 'order.completed'
7
+
8
+ def handle(event)
9
+ order = Spree::Order.find_by_prefix_id(event.payload['id'])
10
+ return unless order
11
+
12
+ SpreeSquare::OrderPushJob.perform_later(order.id)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,63 @@
1
+ <% content_for :page_title do %>
2
+ Square Connection
3
+ <% end %>
4
+
5
+ <div class="card w-full max-w-2xl">
6
+ <div class="card-header flex items-center justify-between <%= 'bg-green-50' if @credential %>">
7
+ <span class="font-medium">Square</span>
8
+ <% if @credential %>
9
+ <span class="inline-flex items-center text-xs font-medium text-green-700">
10
+ <span class="w-1.5 h-1.5 rounded-full bg-green-500 mr-1"></span>
11
+ Connected
12
+ </span>
13
+ <% else %>
14
+ <span class="inline-flex items-center text-xs font-medium text-gray-500">
15
+ <span class="w-1.5 h-1.5 rounded-full bg-gray-400 mr-1"></span>
16
+ Not connected
17
+ </span>
18
+ <% end %>
19
+ </div>
20
+
21
+ <div class="card-body">
22
+ <% if @credential %>
23
+ <dl class="space-y-2 text-sm">
24
+ <div class="flex justify-between">
25
+ <dt class="text-gray-500">Merchant ID</dt>
26
+ <dd><%= @credential.square_merchant_id %></dd>
27
+ </div>
28
+ <div class="flex justify-between">
29
+ <dt class="text-gray-500">Environment</dt>
30
+ <dd><%= @credential.square_environment %></dd>
31
+ </div>
32
+ <div class="flex justify-between">
33
+ <dt class="text-gray-500">Token expires</dt>
34
+ <dd><%= @credential.expires_at&.strftime('%b %-d, %Y %H:%M') || '—' %></dd>
35
+ </div>
36
+ <div class="flex justify-between">
37
+ <dt class="text-gray-500">Connected since</dt>
38
+ <dd><%= @credential.created_at.strftime('%b %-d, %Y') %></dd>
39
+ </div>
40
+ </dl>
41
+ <p class="text-xs text-gray-500 mt-4">
42
+ Catalog, order, and inventory sync for this store run against this connection.
43
+ Disconnecting stops all of it immediately — orders already pushed to Square are unaffected.
44
+ </p>
45
+ <% else %>
46
+ <p class="text-sm text-gray-600">
47
+ Connect this store to Square to sync your menu, push orders, and keep fulfillment
48
+ status in sync — no manually-generated access token required.
49
+ </p>
50
+ <% end %>
51
+ </div>
52
+
53
+ <div class="card-footer">
54
+ <% if @credential %>
55
+ <%= button_to admin_disconnect_square_oauth_path, method: :delete, class: 'btn btn-sm btn-light',
56
+ data: { confirm: 'Disconnect this store from Square? Syncing stops immediately.' } do %>
57
+ Disconnect
58
+ <% end %>
59
+ <% else %>
60
+ <%= link_to_with_icon 'plug', 'Connect to Square', admin_connect_square_oauth_path, class: 'btn btn-primary' %>
61
+ <% end %>
62
+ </div>
63
+ </div>
@@ -0,0 +1,5 @@
1
+ <% content_for :page_title do %>
2
+ Square Order Mappings
3
+ <% end %>
4
+
5
+ <%= render_table @collection, :square_order_mappings %>
@@ -0,0 +1,5 @@
1
+ <% content_for :page_title do %>
2
+ Square Webhook Events
3
+ <% end %>
4
+
5
+ <%= render_table @collection, :square_webhook_events %>
data/bin/importmap ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
4
+ require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
5
+
6
+ require 'rails'
7
+
8
+ require 'importmap-rails'
9
+ require 'importmap/commands'
data/bin/rails ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # This command will automatically be run when you run "rails" from the root of your extension
3
+
4
+ ENGINE_ROOT = File.expand_path('../..', __FILE__)
5
+ ENGINE_PATH = File.expand_path('../../lib/spree_square/engine', __FILE__)
6
+
7
+ require 'rails/all'
8
+ require 'rails/engine/commands'
@@ -0,0 +1,6 @@
1
+ pin 'application-spree-square', to: 'spree_square/application.js', preload: false
2
+
3
+ pin_all_from SpreeSquare::Engine.root.join('app/javascript/spree_square/controllers'),
4
+ under: 'spree_square/controllers',
5
+ to: 'spree_square/controllers',
6
+ preload: 'application-spree-square'
@@ -0,0 +1,45 @@
1
+ # Modifier support (M4): let `square_modifier_ids` survive the options
2
+ # hash that Spree::Cart::AddItem filters incoming `options:` params through,
3
+ # so it reaches Variant#square_modifier_ids_price_modifier_amount (the
4
+ # initial price) and LineItem#square_modifier_ids (read by
5
+ # SpreeSquare::Cart::AddItem to build the persistent snapshot rows).
6
+ Spree::PermittedAttributes.line_item_attributes << :square_modifier_ids
7
+
8
+ Spree.dependencies do |dependencies|
9
+ dependencies.cart_add_item_service = 'SpreeSquare::Cart::AddItem'
10
+ # cart_compare_line_items_service is NOT the merge gate it looks like —
11
+ # Spree::LineItems::FindByVariant calls it but discards the result. The
12
+ # finder itself is the actual hook (see find_line_item_by_variant.rb).
13
+ dependencies.line_item_by_variant_finder = 'SpreeSquare::FindLineItemByVariant'
14
+ end
15
+
16
+ Spree::Api::Dependencies.product_serializer = 'SpreeSquare::ProductSerializer'
17
+ Spree::Api::Dependencies.line_item_serializer = 'SpreeSquare::LineItemSerializer'
18
+
19
+ # Uncomment lines below to add your own custom business logic
20
+ # such as promotions, shipping methods, etc.
21
+ Rails.application.config.after_initialize do
22
+ # M5: push a completed order to Square (as an already-paid ticket) as soon
23
+ # as Spree marks it complete.
24
+ Spree.subscribers << SpreeSquare::OrderCompletedSubscriber
25
+
26
+ # Spree.shipping_methods << Spree::ShippingMethods::SuperExpensiveNotVeryFastShipping
27
+ # Spree.payment_methods << Spree::PaymentMethods::VerySafeAndReliablePaymentMethod
28
+
29
+ # Spree.calculators.tax_rates << Spree::TaxRates::FinanceTeamForcedMeToCodeThis
30
+
31
+ # Spree.stock_splitters << Spree::Stock::Splitters::SecretLogicSplitter
32
+
33
+ # Spree.adjusters << Spree::Adjustable::Adjuster::TaxTheRich
34
+
35
+ # Custom promotions
36
+ # Spree.calculators.promotion_actions_create_adjustments << Spree::Calculators::PromotionActions::CreateAdjustments::AddDiscountForFriends
37
+ # Spree.calculators.promotion_actions_create_item_adjustments << Spree::Calculators::PromotionActions::CreateItemAdjustments::FinanceTeamForcedMeToCodeThis
38
+ # Spree.promotions.rules << Spree::Promotions::Rules::OnlyForVIPCustomers
39
+ # Spree.promotions.actions << Spree::Promotions::Actions::GiftWithPurchase
40
+
41
+ # Spree.taxon_rules << Spree::TaxonRules::ProductsWithColor
42
+
43
+ # Spree.exports << Spree::Exports::Payments
44
+ # Spree.reports << Spree::Reports::MassivelyOvercomplexReportForCfo
45
+ end
@@ -0,0 +1,25 @@
1
+ Rails.application.config.after_initialize do
2
+ Spree.admin.navigation.sidebar.add :square_order_mappings,
3
+ label: 'Square Orders',
4
+ url: :admin_square_order_mappings_path,
5
+ icon: 'receipt',
6
+ position: 65,
7
+ active: -> { controller_name == 'square_order_mappings' },
8
+ if: -> { can?(:manage, SpreeSquare::OrderMapping) }
9
+
10
+ Spree.admin.navigation.sidebar.add :square_webhook_events,
11
+ label: 'Square Webhooks',
12
+ url: :admin_square_webhook_events_path,
13
+ icon: 'webhook',
14
+ position: 66,
15
+ active: -> { controller_name == 'square_webhook_events' },
16
+ if: -> { can?(:manage, SpreeSquare::WebhookEvent) }
17
+
18
+ Spree.admin.navigation.sidebar.add :square_oauth,
19
+ label: 'Square Connection',
20
+ url: :admin_square_oauth_path,
21
+ icon: 'plug',
22
+ position: 67,
23
+ active: -> { controller_name == 'square_oauth' },
24
+ if: -> { can?(:manage, SpreeSquare::Credential) }
25
+ end
@@ -0,0 +1,86 @@
1
+ Rails.application.config.after_initialize do
2
+ Spree.admin.tables.register(:square_order_mappings, model_class: SpreeSquare::OrderMapping, search_param: :square_order_id_cont)
3
+
4
+ Spree.admin.tables.square_order_mappings.add :order_number,
5
+ label: :order,
6
+ type: :string,
7
+ sortable: false,
8
+ filterable: false,
9
+ default: true,
10
+ position: 10,
11
+ method: ->(mapping) { mapping.order&.number }
12
+
13
+ Spree.admin.tables.square_order_mappings.add :square_order_id,
14
+ label: :square_order_id,
15
+ type: :string,
16
+ sortable: true,
17
+ filterable: true,
18
+ default: true,
19
+ position: 20
20
+
21
+ Spree.admin.tables.square_order_mappings.add :last_status,
22
+ label: :status,
23
+ type: :string,
24
+ sortable: true,
25
+ filterable: true,
26
+ default: true,
27
+ position: 30
28
+
29
+ Spree.admin.tables.square_order_mappings.add :push_error,
30
+ label: :push_error,
31
+ type: :string,
32
+ sortable: false,
33
+ filterable: false,
34
+ default: true,
35
+ position: 40
36
+
37
+ Spree.admin.tables.square_order_mappings.add :created_at,
38
+ label: :created_at,
39
+ type: :datetime,
40
+ sortable: true,
41
+ filterable: false,
42
+ default: true,
43
+ position: 50
44
+
45
+ Spree.admin.tables.register(:square_webhook_events, model_class: SpreeSquare::WebhookEvent, search_param: :event_type_cont)
46
+
47
+ Spree.admin.tables.square_webhook_events.add :event_type,
48
+ label: :event_type,
49
+ type: :string,
50
+ sortable: true,
51
+ filterable: true,
52
+ default: true,
53
+ position: 10
54
+
55
+ Spree.admin.tables.square_webhook_events.add :status,
56
+ label: :status,
57
+ type: :string,
58
+ sortable: true,
59
+ filterable: true,
60
+ default: true,
61
+ position: 20
62
+
63
+ Spree.admin.tables.square_webhook_events.add :processed_at,
64
+ label: :processed_at,
65
+ type: :datetime,
66
+ sortable: true,
67
+ filterable: false,
68
+ default: true,
69
+ position: 30
70
+
71
+ Spree.admin.tables.square_webhook_events.add :error_message,
72
+ label: :error_message,
73
+ type: :string,
74
+ sortable: false,
75
+ filterable: false,
76
+ default: true,
77
+ position: 40
78
+
79
+ Spree.admin.tables.square_webhook_events.add :created_at,
80
+ label: :received_at,
81
+ type: :datetime,
82
+ sortable: true,
83
+ filterable: false,
84
+ default: true,
85
+ position: 50
86
+ end
@@ -0,0 +1,5 @@
1
+ ---
2
+ en:
3
+ spree:
4
+ square:
5
+ hello: Hello SpreeSquare
data/config/routes.rb ADDED
@@ -0,0 +1,25 @@
1
+ Spree::Core::Engine.add_routes do
2
+ # `isolate_namespace Spree` in the engine means a normal `namespace
3
+ # :spree_square do ... end` block would resolve controllers as
4
+ # `Spree::SpreeSquare::...` (the isolated module gets prepended). The
5
+ # leading `/` on the controller path makes it absolute, resolving to the
6
+ # actual `SpreeSquare::WebhooksController` while keeping the URL prefix.
7
+ post 'spree_square/webhooks/square', to: '/spree_square/webhooks#create'
8
+
9
+ # M8: admin support/diagnostic pages — read-only, hence :index only.
10
+ # Plain `namespace :admin` here (unlike the webhook route above) resolves
11
+ # correctly with no path override needed: Spree::Admin::* is the isolated
12
+ # namespace's own convention, not a mismatch like spree_square/ was.
13
+ namespace :admin do
14
+ resources :square_order_mappings, only: [:index]
15
+ resources :square_webhook_events, only: [:index]
16
+
17
+ # Self-service OAuth connection (see Spree::Admin::SquareOauthController).
18
+ # Explicit named routes rather than `resource :square_oauth` — the
19
+ # callback is a third, non-CRUD GET action Square itself redirects to.
20
+ get 'square_oauth' => 'square_oauth#show', as: :square_oauth
21
+ get 'square_oauth/connect' => 'square_oauth#connect', as: :connect_square_oauth
22
+ get 'square_oauth/callback' => 'square_oauth#callback', as: :callback_square_oauth
23
+ delete 'square_oauth' => 'square_oauth#destroy', as: :disconnect_square_oauth
24
+ end
25
+ end
@@ -0,0 +1,12 @@
1
+ class CreateSpreeSquareLocationMappings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_square_location_mappings do |t|
4
+ t.string :square_location_id, null: false
5
+ t.references :spree_stock_location, null: false, foreign_key: { to_table: :spree_stock_locations }
6
+
7
+ t.timestamps
8
+ end
9
+
10
+ add_index :spree_square_location_mappings, :square_location_id, unique: true
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ class CreateSpreeSquareTaxonMappings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_square_taxon_mappings do |t|
4
+ t.string :square_category_id, null: false
5
+ # References spree_taxons — Spree::Category is an STI subclass sharing
6
+ # that table (see Spree::Category's own comment: it becomes the base
7
+ # class in 6.0, when spree_taxons is renamed to spree_categories).
8
+ t.references :spree_taxon, null: false, foreign_key: { to_table: :spree_taxons }
9
+ t.bigint :square_version
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :spree_square_taxon_mappings, :square_category_id, unique: true
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ class CreateSpreeSquareCatalogMappings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_square_catalog_mappings do |t|
4
+ t.string :square_catalog_object_id, null: false
5
+ # "item" or "item_variation" — which Square object this row mirrors.
6
+ t.string :square_object_type, null: false
7
+ t.references :spree_product, null: true, foreign_key: { to_table: :spree_products }
8
+ t.references :spree_variant, null: true, foreign_key: { to_table: :spree_variants }
9
+ # Square's optimistic-concurrency token. Compared before writing so a
10
+ # webhook that arrives out of order (or a duplicate) can't clobber a
11
+ # newer state with older data.
12
+ t.bigint :square_version
13
+ t.datetime :last_synced_at
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :spree_square_catalog_mappings, :square_catalog_object_id, unique: true
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ class CreateSpreeSquareWebhookEvents < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_square_webhook_events do |t|
4
+ # Square's notification envelope carries its own event_id — the
5
+ # idempotency key. Square retries on a slow/failed ack, so the same
6
+ # event can arrive more than once; the unique index is what makes a
7
+ # duplicate delivery a no-op instead of double-processing.
8
+ t.string :square_event_id, null: false
9
+ t.string :event_type, null: false
10
+ # `json`, not `jsonb`: SQLite (used by the extension's own dummy app
11
+ # for fast specs) has no jsonb type, and nothing here does SQL-level
12
+ # JSON querying — application code only ever reads/writes it as a
13
+ # plain Ruby hash, so the Postgres jsonb-vs-json performance distinction
14
+ # doesn't apply.
15
+ t.json :payload, null: false, default: {}
16
+ t.datetime :processed_at
17
+ t.string :status, null: false, default: 'pending' # pending, processed, failed
18
+ t.text :error_message
19
+
20
+ t.timestamps
21
+ end
22
+
23
+ add_index :spree_square_webhook_events, :square_event_id, unique: true
24
+ add_index :spree_square_webhook_events, :event_type
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ class CreateSpreeSquareModifierLists < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_square_modifier_lists do |t|
4
+ t.string :square_modifier_list_id, null: false
5
+ t.string :name, null: false
6
+ t.string :selection_type, null: false, default: 'SINGLE' # SINGLE or MULTIPLE
7
+ t.integer :min_selected_modifiers
8
+ t.integer :max_selected_modifiers
9
+ t.bigint :square_version
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :spree_square_modifier_lists, :square_modifier_list_id, unique: true
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ class CreateSpreeSquareModifiers < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_square_modifiers do |t|
4
+ t.references :modifier_list, null: false, foreign_key: { to_table: :spree_square_modifier_lists }
5
+ t.string :square_modifier_id, null: false
6
+ t.string :name, null: false
7
+ t.integer :price_cents, null: false, default: 0
8
+ t.bigint :square_version
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :spree_square_modifiers, :square_modifier_id, unique: true
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ class CreateSpreeSquareProductModifierLists < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_square_product_modifier_lists do |t|
4
+ t.references :product, null: false, foreign_key: { to_table: :spree_products }
5
+ t.references :modifier_list, null: false, foreign_key: { to_table: :spree_square_modifier_lists }
6
+
7
+ t.timestamps
8
+ end
9
+
10
+ add_index :spree_square_product_modifier_lists, %i[product_id modifier_list_id],
11
+ unique: true, name: 'index_square_product_modifier_lists_uniq'
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ class CreateSpreeSquareLineItemModifiers < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_square_line_item_modifiers do |t|
4
+ t.references :line_item, null: false, foreign_key: { to_table: :spree_line_items }
5
+ # Nullable + snapshot columns: if the Modifier record is later deleted
6
+ # (menu edit in Square), a past order must still show and total exactly
7
+ # what the customer paid for, unaffected by any later catalog change.
8
+ t.references :modifier, null: true, foreign_key: { to_table: :spree_square_modifiers }
9
+ t.string :square_modifier_id
10
+ t.string :name_snapshot, null: false
11
+ t.integer :price_cents_snapshot, null: false, default: 0
12
+
13
+ t.timestamps
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ class CreateSpreeSquareOrderMappings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_square_order_mappings do |t|
4
+ t.references :spree_order, null: false, foreign_key: { to_table: :spree_orders }, index: { unique: true }
5
+ t.string :square_order_id
6
+ t.string :square_payment_id
7
+ t.string :square_location_id
8
+ t.bigint :square_version
9
+ t.string :last_status
10
+ t.text :push_error
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :spree_square_order_mappings, :square_order_id, unique: true
16
+ end
17
+ end