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,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>
|
data/bin/importmap
ADDED
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'
|
data/config/importmap.rb
ADDED
|
@@ -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
|
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
|