spree_uber_direct 0.1.4
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/.gitignore +27 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +170 -0
- data/CONTRIBUTING.md +29 -0
- data/Gemfile +27 -0
- data/LICENSE.md +9 -0
- data/README.md +74 -0
- data/Rakefile +23 -0
- data/app/controllers/spree/admin/uber_direct_credentials_controller.rb +52 -0
- data/app/controllers/spree/admin/uber_direct_delivery_mappings_controller.rb +12 -0
- data/app/controllers/spree/admin/uber_direct_webhook_events_controller.rb +10 -0
- data/app/controllers/spree_uber_direct/webhooks_controller.rb +112 -0
- data/app/jobs/spree_uber_direct/base_job.rb +5 -0
- data/app/jobs/spree_uber_direct/delivery_dispatch_job.rb +21 -0
- data/app/jobs/spree_uber_direct/delivery_webhook_job.rb +17 -0
- data/app/models/spree/calculator/shipping/uber_direct_quote.rb +62 -0
- data/app/models/spree_uber_direct/credential.rb +33 -0
- data/app/models/spree_uber_direct/delivery_mapping.rb +27 -0
- data/app/models/spree_uber_direct/order_decorator.rb +42 -0
- data/app/models/spree_uber_direct/quote_mapping.rb +25 -0
- data/app/models/spree_uber_direct/refund_event.rb +34 -0
- data/app/models/spree_uber_direct/webhook_event.rb +34 -0
- data/app/services/spree_uber_direct/address_payload.rb +35 -0
- data/app/services/spree_uber_direct/alerting.rb +18 -0
- data/app/services/spree_uber_direct/client.rb +155 -0
- data/app/services/spree_uber_direct/delivery_dispatcher.rb +118 -0
- data/app/services/spree_uber_direct/delivery_status_mapper.rb +73 -0
- data/app/services/spree_uber_direct/quote.rb +97 -0
- data/app/services/spree_uber_direct/webhook_verifier.rb +17 -0
- data/app/subscribers/spree_uber_direct/order_completed_subscriber.rb +30 -0
- data/app/views/spree/admin/uber_direct_credentials/show.html.erb +55 -0
- data/app/views/spree/admin/uber_direct_delivery_mappings/index.html.erb +5 -0
- data/app/views/spree/admin/uber_direct_webhook_events/index.html.erb +5 -0
- data/config/initializers/spree.rb +14 -0
- data/config/initializers/spree_admin_uber_direct_navigation.rb +30 -0
- data/config/initializers/spree_admin_uber_direct_tables.rb +101 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20260821000001_create_spree_uber_direct_credentials.rb +36 -0
- data/db/migrate/20260821030001_create_spree_uber_direct_quote_mappings.rb +48 -0
- data/db/migrate/20260821040001_create_spree_uber_direct_delivery_mappings.rb +42 -0
- data/db/migrate/20260821040002_create_spree_uber_direct_webhook_events.rb +37 -0
- data/db/migrate/20260823010001_create_spree_uber_direct_refund_events.rb +39 -0
- data/lib/spree_uber_direct/configuration.rb +8 -0
- data/lib/spree_uber_direct/engine.rb +45 -0
- data/lib/spree_uber_direct/factories.rb +37 -0
- data/lib/spree_uber_direct/version.rb +7 -0
- data/lib/spree_uber_direct.rb +12 -0
- data/lib/tasks/spree_uber_direct.rake +30 -0
- data/spree_uber_direct.gemspec +51 -0
- metadata +182 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# Requests a live Uber Direct delivery-fee quote for an order — the
|
|
3
|
+
# storefront-facing half of this extension (called from
|
|
4
|
+
# Spree::Calculator::Shipping::UberDirectQuote during checkout, M3) and
|
|
5
|
+
# again at order.completed if the original quote expired (see
|
|
6
|
+
# DeliveryDispatcher, M4).
|
|
7
|
+
#
|
|
8
|
+
# `order.total` at whichever moment this is called is what's sent as
|
|
9
|
+
# manifest_total_value — Spree recalculates on every checkout step
|
|
10
|
+
# already, and Uber isn't in the money path (Spree's own payment method
|
|
11
|
+
# still charges the customer; Uber only uses this for delivery-fee/
|
|
12
|
+
# insurance math) — same rationale SpreeDoordash::Quote documents for its
|
|
13
|
+
# own order_value field.
|
|
14
|
+
class Quote
|
|
15
|
+
Result = Struct.new(:fee_cents, :currency, :external_quote_id, :expires_at, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
def self.call(...) = new.call(...)
|
|
18
|
+
|
|
19
|
+
def call(order)
|
|
20
|
+
return nil unless order.ship_address
|
|
21
|
+
# Same real bug DoorDash's own Quote hit live: with no phone on the
|
|
22
|
+
# ship address, the request is doomed before it's even sent —
|
|
23
|
+
# Uber's own schema requires `^\+[0-9]+$` on both phone fields.
|
|
24
|
+
# Spree::Config[:address_requires_phone] (spree_host) should make
|
|
25
|
+
# this unreachable in the normal storefront checkout flow, but this
|
|
26
|
+
# guard stays as defense in depth for any other path that can create
|
|
27
|
+
# an order (admin, API, migrated data).
|
|
28
|
+
return nil if order.ship_address.phone.blank?
|
|
29
|
+
|
|
30
|
+
stock_location = pickup_location_for(order)
|
|
31
|
+
return nil unless stock_location&.phone.present?
|
|
32
|
+
|
|
33
|
+
client = SpreeUberDirect::Client.for_store(order.store || Spree::Store.default)
|
|
34
|
+
response = client.create_quote(build_payload(order, stock_location))
|
|
35
|
+
mapping = persist_quote!(order, response)
|
|
36
|
+
|
|
37
|
+
Result.new(
|
|
38
|
+
fee_cents: mapping.quoted_fee_cents,
|
|
39
|
+
currency: mapping.currency,
|
|
40
|
+
external_quote_id: mapping.external_quote_id,
|
|
41
|
+
expires_at: mapping.quote_expires_at
|
|
42
|
+
)
|
|
43
|
+
rescue SpreeUberDirect::Client::MissingCredentialsError, SpreeUberDirect::RequestError => e
|
|
44
|
+
# Unserviceable address, no credential connected, Uber-side rejection
|
|
45
|
+
# — none of these should ever raise into checkout. The calculator
|
|
46
|
+
# (M3) treats a nil Result as "this rate isn't available," Spree's
|
|
47
|
+
# normal shape for "can't quote this," same as
|
|
48
|
+
# Spree::Calculator::Shipping::DoordashQuote's own precedent.
|
|
49
|
+
Rails.logger.info("[SpreeUberDirect] quote skipped for order #{order.number}: #{e.message}")
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Queried directly rather than `order.shipments.first` — same reasoning
|
|
56
|
+
# as SpreeDoordash::Quote#location_mapping_for: a shipment created by
|
|
57
|
+
# setting the FK directly (as spec factories and some checkout code
|
|
58
|
+
# paths do) doesn't invalidate an already-loaded `order` object's
|
|
59
|
+
# cached `shipments` association. No LocationMapping join needed here
|
|
60
|
+
# at all (unlike DoorDash) — Uber Direct has no separate store/location
|
|
61
|
+
# registration step; the pickup address is read straight off
|
|
62
|
+
# Spree::StockLocation.
|
|
63
|
+
def pickup_location_for(order)
|
|
64
|
+
Spree::Shipment.where(order_id: order.id).first&.stock_location ||
|
|
65
|
+
Spree::StockLocation.find_by(default: true)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_payload(order, stock_location)
|
|
69
|
+
{
|
|
70
|
+
pickup_address: AddressPayload.format_address(stock_location),
|
|
71
|
+
pickup_phone_number: AddressPayload.format_phone(stock_location.phone),
|
|
72
|
+
dropoff_address: AddressPayload.format_address(order.ship_address),
|
|
73
|
+
dropoff_phone_number: AddressPayload.format_phone(order.ship_address.phone),
|
|
74
|
+
manifest_total_value: (order.total * 100).to_i
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def persist_quote!(order, response)
|
|
79
|
+
mapping = SpreeUberDirect::QuoteMapping.find_or_initialize_by(order: order)
|
|
80
|
+
mapping.update!(
|
|
81
|
+
external_quote_id: response['id'],
|
|
82
|
+
quoted_fee_cents: response['fee'],
|
|
83
|
+
currency: response['currency_type'] || 'USD',
|
|
84
|
+
quote_expires_at: parse_time(response['expires']),
|
|
85
|
+
duration_minutes_estimated: response['duration'],
|
|
86
|
+
pickup_duration_minutes_estimated: response['pickup_duration'],
|
|
87
|
+
dropoff_eta_estimated: parse_time(response['dropoff_eta']),
|
|
88
|
+
raw_response: response
|
|
89
|
+
)
|
|
90
|
+
mapping
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_time(value)
|
|
94
|
+
value.present? ? Time.iso8601(value) : nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# Verifies inbound Uber Direct webhooks. Architecturally different from
|
|
3
|
+
# SpreeDoordash::WebhookVerifier's Basic-Auth string-echo scheme: Uber
|
|
4
|
+
# Direct signs with a real HMAC — confirmed directly against
|
|
5
|
+
# developer.uber.com's webhook guide, not assumed: the `x-uber-signature`
|
|
6
|
+
# header is a lowercase-hex HMAC-SHA256 of the raw request body, keyed by
|
|
7
|
+
# a dedicated webhook signing secret configured per-webhook in the Direct
|
|
8
|
+
# dashboard (NOT the OAuth client_secret — a separate value entirely).
|
|
9
|
+
class WebhookVerifier
|
|
10
|
+
def self.valid?(signature_header:, raw_body:, signing_secret:)
|
|
11
|
+
return false if signature_header.blank? || signing_secret.blank?
|
|
12
|
+
|
|
13
|
+
expected = OpenSSL::HMAC.hexdigest('SHA256', signing_secret, raw_body.to_s)
|
|
14
|
+
ActiveSupport::SecurityUtils.secure_compare(signature_header, expected)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
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. Mirrors
|
|
5
|
+
# SpreeDoordash::OrderCompletedSubscriber's shape exactly; independent of
|
|
6
|
+
# it and of spree_square's own subscriber on the same event — Spree's
|
|
7
|
+
# Events system already fires all three separately for every order
|
|
8
|
+
# (confirmed live earlier in this project: each shows up as its own
|
|
9
|
+
# Spree::Events::SubscriberJob entry for the same event).
|
|
10
|
+
class OrderCompletedSubscriber < Spree::Subscriber
|
|
11
|
+
subscribes_to 'order.completed'
|
|
12
|
+
|
|
13
|
+
def handle(event)
|
|
14
|
+
order = Spree::Order.find_by_prefix_id(event.payload['id'])
|
|
15
|
+
return unless order
|
|
16
|
+
return unless uber_direct_delivery?(order)
|
|
17
|
+
|
|
18
|
+
SpreeUberDirect::DeliveryDispatchJob.perform_later(order.id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Only dispatch orders actually fulfilled via an "Uber Direct Delivery"
|
|
24
|
+
# shipping method — pickup/DoorDash/other-carrier orders should never
|
|
25
|
+
# reach Uber Direct at all.
|
|
26
|
+
def uber_direct_delivery?(order)
|
|
27
|
+
order.shipments.any? { |shipment| shipment.shipping_method&.calculator.is_a?(Spree::Calculator::Shipping::UberDirectQuote) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<% content_for :page_title do %>
|
|
2
|
+
Uber Direct 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.persisted? %>">
|
|
7
|
+
<span class="font-medium">Uber Direct</span>
|
|
8
|
+
<% if @credential.persisted? %>
|
|
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
|
+
Configured
|
|
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 configured
|
|
17
|
+
</span>
|
|
18
|
+
<% end %>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<%= form_for @credential, url: admin_uber_direct_credential_path, method: :patch,
|
|
22
|
+
as: :spree_uber_direct_credential, html: { class: 'card-body space-y-4' } do |f| %>
|
|
23
|
+
<p class="text-sm text-gray-600">
|
|
24
|
+
Create a Direct account at
|
|
25
|
+
<a href="https://direct.uber.com" target="_blank" rel="noopener" class="text-blue-600 underline">direct.uber.com</a>,
|
|
26
|
+
open the Developer tab under Management, and paste its three values below. Stored encrypted.
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<div>
|
|
30
|
+
<%= f.label :uber_environment, 'Environment', class: 'label' %>
|
|
31
|
+
<%= f.select :uber_environment, %w[sandbox production], {}, class: 'select' %>
|
|
32
|
+
</div>
|
|
33
|
+
<div>
|
|
34
|
+
<%= f.label :client_id, 'Client ID', class: 'label' %>
|
|
35
|
+
<%= f.text_field :client_id, class: 'form-control' %>
|
|
36
|
+
</div>
|
|
37
|
+
<div>
|
|
38
|
+
<%= f.label :client_secret, 'Client Secret', class: 'label' %>
|
|
39
|
+
<%= f.password_field :client_secret, value: '', placeholder: (@credential.persisted? ? '•••••••• (leave blank to keep current)' : nil), class: 'form-control' %>
|
|
40
|
+
</div>
|
|
41
|
+
<div>
|
|
42
|
+
<%= f.label :customer_id, 'Customer ID', class: 'label' %>
|
|
43
|
+
<%= f.text_field :customer_id, class: 'form-control' %>
|
|
44
|
+
</div>
|
|
45
|
+
<div>
|
|
46
|
+
<%= f.label :webhook_signing_secret, 'Webhook Signing Key', class: 'label' %>
|
|
47
|
+
<%= f.password_field :webhook_signing_secret, value: '', placeholder: (@credential.webhook_signing_secret.present? ? '•••••••• (leave blank to keep current)' : 'from the webhook entry\'s Edit menu'), class: 'form-control' %>
|
|
48
|
+
<p class="text-xs text-gray-500 mt-1">The signing key shown when editing this store's webhook entry in the Direct dashboard — verifies the x-uber-signature header.</p>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div class="card-footer -mx-4 -mb-4">
|
|
52
|
+
<%= f.submit 'Save', class: 'btn btn-primary' %>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Registers this extension's event subscriber with Spree's event system.
|
|
2
|
+
#
|
|
3
|
+
# Spree::Subscriber's own docstring says subscribers are "automatically
|
|
4
|
+
# registered during Rails initialization" — that's not what actually
|
|
5
|
+
# happens in spree_core 5.6.1: Spree::Events.register_subscribers! only
|
|
6
|
+
# ever iterates the explicit Spree.subscribers array. Without this file,
|
|
7
|
+
# SpreeUberDirect::OrderCompletedSubscriber is a real, loadable class (so
|
|
8
|
+
# specs calling it directly always pass) but is never actually wired to
|
|
9
|
+
# the 'order.completed' event in the running app — this exact gap was
|
|
10
|
+
# found live in spree_doordash before it carried this same fix. Mirrors
|
|
11
|
+
# spree_doordash's and spree_square's own identical registration.
|
|
12
|
+
Rails.application.config.after_initialize do
|
|
13
|
+
Spree.subscribers << SpreeUberDirect::OrderCompletedSubscriber
|
|
14
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Rails.application.config.after_initialize do
|
|
2
|
+
# Position 76 — confirmed the next free slot at time of writing (highest
|
|
3
|
+
# in use across every extension's own nav initializer was 75, spree_loyalty's
|
|
4
|
+
# admin_square_modifier_lists_navigation.rb before that at 73). 77-78 are
|
|
5
|
+
# the M5 Deliveries/Webhooks admin index pages below, mirroring
|
|
6
|
+
# spree_doordash's 68-70 layout (credential + 2 read-only tables).
|
|
7
|
+
Spree.admin.navigation.sidebar.add :uber_direct_credential,
|
|
8
|
+
label: 'Uber Direct Connection',
|
|
9
|
+
url: :admin_uber_direct_credential_path,
|
|
10
|
+
icon: 'plug',
|
|
11
|
+
position: 76,
|
|
12
|
+
active: -> { controller_name == 'uber_direct_credentials' },
|
|
13
|
+
if: -> { can?(:manage, SpreeUberDirect::Credential) }
|
|
14
|
+
|
|
15
|
+
Spree.admin.navigation.sidebar.add :uber_direct_delivery_mappings,
|
|
16
|
+
label: 'Uber Direct Deliveries',
|
|
17
|
+
url: :admin_uber_direct_delivery_mappings_path,
|
|
18
|
+
icon: 'truck',
|
|
19
|
+
position: 77,
|
|
20
|
+
active: -> { controller_name == 'uber_direct_delivery_mappings' },
|
|
21
|
+
if: -> { can?(:manage, SpreeUberDirect::DeliveryMapping) }
|
|
22
|
+
|
|
23
|
+
Spree.admin.navigation.sidebar.add :uber_direct_webhook_events,
|
|
24
|
+
label: 'Uber Direct Webhooks',
|
|
25
|
+
url: :admin_uber_direct_webhook_events_path,
|
|
26
|
+
icon: 'webhook',
|
|
27
|
+
position: 78,
|
|
28
|
+
active: -> { controller_name == 'uber_direct_webhook_events' },
|
|
29
|
+
if: -> { can?(:manage, SpreeUberDirect::WebhookEvent) }
|
|
30
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Rails.application.config.after_initialize do
|
|
2
|
+
# new_resource: false is required — omitting it is a real bug both
|
|
3
|
+
# sibling extensions (spree_square, spree_doordash) hit and fixed on
|
|
4
|
+
# their first real Postgres run: without it, Spree.admin's table
|
|
5
|
+
# renderer assumes a "New" action exists and 500s. Avoided here by
|
|
6
|
+
# copying the already-fixed pattern instead of rediscovering it.
|
|
7
|
+
Spree.admin.tables.register(:uber_direct_delivery_mappings, model_class: SpreeUberDirect::DeliveryMapping,
|
|
8
|
+
search_param: :external_delivery_id_cont, new_resource: false)
|
|
9
|
+
|
|
10
|
+
Spree.admin.tables.uber_direct_delivery_mappings.add :order_number,
|
|
11
|
+
label: :order,
|
|
12
|
+
type: :string,
|
|
13
|
+
sortable: false,
|
|
14
|
+
filterable: false,
|
|
15
|
+
default: true,
|
|
16
|
+
position: 10,
|
|
17
|
+
method: ->(mapping) { mapping.order&.number }
|
|
18
|
+
|
|
19
|
+
Spree.admin.tables.uber_direct_delivery_mappings.add :external_delivery_id,
|
|
20
|
+
label: :external_delivery_id,
|
|
21
|
+
type: :string,
|
|
22
|
+
sortable: true,
|
|
23
|
+
filterable: true,
|
|
24
|
+
default: true,
|
|
25
|
+
position: 20
|
|
26
|
+
|
|
27
|
+
Spree.admin.tables.uber_direct_delivery_mappings.add :last_status,
|
|
28
|
+
label: :status,
|
|
29
|
+
type: :string,
|
|
30
|
+
sortable: true,
|
|
31
|
+
filterable: true,
|
|
32
|
+
default: true,
|
|
33
|
+
position: 30
|
|
34
|
+
|
|
35
|
+
Spree.admin.tables.uber_direct_delivery_mappings.add :tracking_url,
|
|
36
|
+
label: :tracking_url,
|
|
37
|
+
type: :string,
|
|
38
|
+
sortable: false,
|
|
39
|
+
filterable: false,
|
|
40
|
+
default: true,
|
|
41
|
+
position: 40
|
|
42
|
+
|
|
43
|
+
Spree.admin.tables.uber_direct_delivery_mappings.add :dispatch_error,
|
|
44
|
+
label: :dispatch_error,
|
|
45
|
+
type: :string,
|
|
46
|
+
sortable: false,
|
|
47
|
+
filterable: false,
|
|
48
|
+
default: true,
|
|
49
|
+
position: 50
|
|
50
|
+
|
|
51
|
+
Spree.admin.tables.uber_direct_delivery_mappings.add :created_at,
|
|
52
|
+
label: :created_at,
|
|
53
|
+
type: :datetime,
|
|
54
|
+
sortable: true,
|
|
55
|
+
filterable: false,
|
|
56
|
+
default: true,
|
|
57
|
+
position: 60
|
|
58
|
+
|
|
59
|
+
Spree.admin.tables.register(:uber_direct_webhook_events, model_class: SpreeUberDirect::WebhookEvent,
|
|
60
|
+
search_param: :delivery_id_cont, new_resource: false)
|
|
61
|
+
|
|
62
|
+
Spree.admin.tables.uber_direct_webhook_events.add :delivery_id,
|
|
63
|
+
label: :delivery_id,
|
|
64
|
+
type: :string,
|
|
65
|
+
sortable: true,
|
|
66
|
+
filterable: true,
|
|
67
|
+
default: true,
|
|
68
|
+
position: 10
|
|
69
|
+
|
|
70
|
+
Spree.admin.tables.uber_direct_webhook_events.add :status,
|
|
71
|
+
label: :status,
|
|
72
|
+
type: :string,
|
|
73
|
+
sortable: true,
|
|
74
|
+
filterable: true,
|
|
75
|
+
default: true,
|
|
76
|
+
position: 20
|
|
77
|
+
|
|
78
|
+
Spree.admin.tables.uber_direct_webhook_events.add :processing_status,
|
|
79
|
+
label: :processing_status,
|
|
80
|
+
type: :string,
|
|
81
|
+
sortable: true,
|
|
82
|
+
filterable: true,
|
|
83
|
+
default: true,
|
|
84
|
+
position: 30
|
|
85
|
+
|
|
86
|
+
Spree.admin.tables.uber_direct_webhook_events.add :error_message,
|
|
87
|
+
label: :error_message,
|
|
88
|
+
type: :string,
|
|
89
|
+
sortable: false,
|
|
90
|
+
filterable: false,
|
|
91
|
+
default: true,
|
|
92
|
+
position: 40
|
|
93
|
+
|
|
94
|
+
Spree.admin.tables.uber_direct_webhook_events.add :created_at,
|
|
95
|
+
label: :created_at,
|
|
96
|
+
type: :datetime,
|
|
97
|
+
sortable: true,
|
|
98
|
+
filterable: false,
|
|
99
|
+
default: true,
|
|
100
|
+
position: 50
|
|
101
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Spree::Core::Engine.add_routes do
|
|
2
|
+
# Same absolute-path pattern as spree_doordash's own webhook route —
|
|
3
|
+
# isolate_namespace Spree means a plain `namespace :spree_uber_direct`
|
|
4
|
+
# would resolve to Spree::SpreeUberDirect::..., not the real
|
|
5
|
+
# SpreeUberDirect::WebhooksController; the leading `/` makes the
|
|
6
|
+
# controller path absolute while keeping the URL prefix.
|
|
7
|
+
post 'spree_uber_direct/webhooks/uber_direct', to: '/spree_uber_direct/webhooks#create'
|
|
8
|
+
|
|
9
|
+
namespace :admin do
|
|
10
|
+
# Plain credential-entry form (client_id/client_secret/customer_id/
|
|
11
|
+
# webhook signing key) — same explicit named-route shape as
|
|
12
|
+
# spree_doordash's own doordash_credential, not `resource :...` since
|
|
13
|
+
# there's no `new`/`create`, just `find_or_initialize_by(store:)`.
|
|
14
|
+
get 'uber_direct_credential' => 'uber_direct_credentials#show', as: :uber_direct_credential
|
|
15
|
+
patch 'uber_direct_credential' => 'uber_direct_credentials#update'
|
|
16
|
+
|
|
17
|
+
# M5 — admin support/diagnostic pages, same read-only shape as every
|
|
18
|
+
# sibling extension's own (:index only).
|
|
19
|
+
resources :uber_direct_delivery_mappings, only: [:index]
|
|
20
|
+
resources :uber_direct_webhook_events, only: [:index]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
class CreateSpreeUberDirectCredentials < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :spree_uber_direct_credentials do |t|
|
|
4
|
+
t.references :store, null: false, foreign_key: { to_table: :spree_stores }, index: { unique: true }
|
|
5
|
+
|
|
6
|
+
# OAuth2 client_credentials — unlike spree_doordash's static
|
|
7
|
+
# developer_id/key_id/signing_secret (which sign a fresh JWT
|
|
8
|
+
# per-request, nothing to cache), Uber Direct issues an access token
|
|
9
|
+
# from these that genuinely outlives a single request. All three
|
|
10
|
+
# encrypted at the application layer regardless (see
|
|
11
|
+
# SpreeUberDirect::Credential), matching every credential this
|
|
12
|
+
# project stores.
|
|
13
|
+
t.text :client_id
|
|
14
|
+
t.text :client_secret
|
|
15
|
+
t.text :customer_id
|
|
16
|
+
|
|
17
|
+
# Cached OAuth access token + its real expiry, so SpreeUberDirect::Client
|
|
18
|
+
# doesn't fetch a fresh token on every call the way DoorDash's
|
|
19
|
+
# per-request JWT signing does — this is the one genuinely different
|
|
20
|
+
# piece from spree_doordash's Credential. access_token is encrypted;
|
|
21
|
+
# access_token_expires_at is a plain timestamp (not secret, needed for
|
|
22
|
+
# a cheap needs_refresh? comparison).
|
|
23
|
+
t.text :access_token
|
|
24
|
+
t.datetime :access_token_expires_at
|
|
25
|
+
|
|
26
|
+
# The dedicated webhook signing secret configured per-webhook in the
|
|
27
|
+
# Direct dashboard (Edit → signing key) — verifies the x-uber-signature
|
|
28
|
+
# header via HMAC-SHA256. Not the same value as client_secret.
|
|
29
|
+
t.text :webhook_signing_secret
|
|
30
|
+
|
|
31
|
+
t.string :uber_environment, null: false, default: 'sandbox'
|
|
32
|
+
|
|
33
|
+
t.timestamps
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
class CreateSpreeUberDirectQuoteMappings < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :spree_uber_direct_quote_mappings do |t|
|
|
4
|
+
t.references :order, null: false, foreign_key: { to_table: :spree_orders }, index: { unique: true }
|
|
5
|
+
|
|
6
|
+
# Uber's own — returned as `id` on the DeliveryQuoteResp (always
|
|
7
|
+
# prefixed `dqt_`). Unlike SpreeDoordash::QuoteMapping's
|
|
8
|
+
# external_delivery_id (client-generated, sent in the *request*,
|
|
9
|
+
# which is why DoorDash needed a random suffix per attempt to dodge
|
|
10
|
+
# its own 409 duplicate_delivery_id), Uber mints this server-side and
|
|
11
|
+
# returns a fresh one on every /delivery_quotes call — no collision
|
|
12
|
+
# risk to guard against, re-quoting is always safe.
|
|
13
|
+
t.string :external_quote_id, null: false
|
|
14
|
+
|
|
15
|
+
t.integer :quoted_fee_cents
|
|
16
|
+
# currency_type (uppercase ISO), not the deprecated lowercase
|
|
17
|
+
# `currency` field DeliveryQuoteResp also returns — see
|
|
18
|
+
# SpreeUberDirect::Quote.
|
|
19
|
+
t.string :currency, default: 'USD'
|
|
20
|
+
|
|
21
|
+
# Uber returns its own real expiry (`expires`) on every quote — no
|
|
22
|
+
# need to hardcode a documented window the way DoorDash's 5-minute
|
|
23
|
+
# rule required (SpreeDoordash::QuoteMapping had no `expires` field
|
|
24
|
+
# to read at all).
|
|
25
|
+
t.datetime :quote_expires_at
|
|
26
|
+
|
|
27
|
+
# `duration`/`pickup_duration` on DeliveryQuoteResp are estimates in
|
|
28
|
+
# minutes, not timestamps — `dropoff_eta` is the one real (RFC 3339)
|
|
29
|
+
# timestamp field.
|
|
30
|
+
t.integer :duration_minutes_estimated
|
|
31
|
+
t.integer :pickup_duration_minutes_estimated
|
|
32
|
+
t.datetime :dropoff_eta_estimated
|
|
33
|
+
|
|
34
|
+
# Full response, for debugging/support visibility — same rationale
|
|
35
|
+
# (and same jsonb/json Postgres-vs-SQLite branch) as
|
|
36
|
+
# SpreeDoordash::QuoteMapping#raw_response.
|
|
37
|
+
if t.respond_to?(:jsonb)
|
|
38
|
+
t.jsonb :raw_response
|
|
39
|
+
else
|
|
40
|
+
t.json :raw_response
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
t.timestamps
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
add_index :spree_uber_direct_quote_mappings, :external_quote_id, unique: true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
class CreateSpreeUberDirectDeliveryMappings < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :spree_uber_direct_delivery_mappings do |t|
|
|
4
|
+
t.references :order, null: false, foreign_key: { to_table: :spree_orders }, index: { unique: true }
|
|
5
|
+
|
|
6
|
+
# Uber's own delivery id (prefixed `del_`), returned by
|
|
7
|
+
# POST /deliveries once the quote is accepted — a genuinely separate
|
|
8
|
+
# id from the quote's own `dqt_`-prefixed id, unlike DoorDash (which
|
|
9
|
+
# reuses the accepted quote's external_delivery_id as the delivery's
|
|
10
|
+
# id for its whole lifecycle). Nullable for the same reason
|
|
11
|
+
# SpreeDoordash::DeliveryMapping's own external_delivery_id is: a
|
|
12
|
+
# dispatch can fail before Uber ever returns one (e.g. the
|
|
13
|
+
# underlying quote itself failed) — DeliveryDispatchJob's dead-letter
|
|
14
|
+
# block does find_or_initialize_by(order:).mark_failed!(error) on
|
|
15
|
+
# exactly that path.
|
|
16
|
+
t.string :external_delivery_id
|
|
17
|
+
|
|
18
|
+
# Uber's own status vocabulary (pending, pickup, pickup_complete,
|
|
19
|
+
# dropoff, delivered, canceled, returned, shopping_completed) —
|
|
20
|
+
# stored verbatim, same last_status rationale as every sibling
|
|
21
|
+
# mapping table in this project.
|
|
22
|
+
t.string :last_status
|
|
23
|
+
|
|
24
|
+
t.string :tracking_url
|
|
25
|
+
t.string :courier_name
|
|
26
|
+
t.string :courier_phone
|
|
27
|
+
t.text :dispatch_error
|
|
28
|
+
|
|
29
|
+
# Full response, for debugging/support visibility — same
|
|
30
|
+
# jsonb/json Postgres-vs-SQLite branch as every sibling migration.
|
|
31
|
+
if t.respond_to?(:jsonb)
|
|
32
|
+
t.jsonb :raw_response
|
|
33
|
+
else
|
|
34
|
+
t.json :raw_response
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
t.timestamps
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
add_index :spree_uber_direct_delivery_mappings, :external_delivery_id, unique: true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
class CreateSpreeUberDirectWebhookEvents < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :spree_uber_direct_webhook_events do |t|
|
|
4
|
+
# Uber Direct's webhook payload has a single event `kind`
|
|
5
|
+
# (event.delivery_status for every status change — confirmed
|
|
6
|
+
# directly against the real webhook reference, not assumed) rather
|
|
7
|
+
# than a distinct event id, same shape gap DoorDash's own webhooks
|
|
8
|
+
# have. The idempotency key is built the identical way
|
|
9
|
+
# SpreeDoordash::WebhookEvent's own migration explains: delivery_id +
|
|
10
|
+
# status (which transition this is) + a digest of the raw body
|
|
11
|
+
# (guards against two genuinely different payloads sharing those two
|
|
12
|
+
# fields).
|
|
13
|
+
t.string :delivery_id, null: false
|
|
14
|
+
t.string :status, null: false
|
|
15
|
+
t.string :payload_digest, null: false
|
|
16
|
+
|
|
17
|
+
# jsonb on Postgres, json on SQLite — same rationale (and the same
|
|
18
|
+
# real Postgres-only `SELECT DISTINCT` admin-listing bug this
|
|
19
|
+
# pattern was already found live for) as every sibling migration in
|
|
20
|
+
# this project.
|
|
21
|
+
if t.respond_to?(:jsonb)
|
|
22
|
+
t.jsonb :payload, null: false
|
|
23
|
+
else
|
|
24
|
+
t.json :payload, null: false
|
|
25
|
+
end
|
|
26
|
+
t.datetime :processed_at
|
|
27
|
+
t.string :processing_status, null: false, default: 'pending' # pending, processed, failed
|
|
28
|
+
t.text :error_message
|
|
29
|
+
|
|
30
|
+
t.timestamps
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
add_index :spree_uber_direct_webhook_events, %i[delivery_id status payload_digest],
|
|
34
|
+
unique: true, name: 'index_spree_uber_direct_webhook_events_on_idempotency_key'
|
|
35
|
+
add_index :spree_uber_direct_webhook_events, :status
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
class CreateSpreeUberDirectRefundEvents < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :spree_uber_direct_refund_events do |t|
|
|
4
|
+
# `event.refund_request` webhooks carry no top-level `status` field
|
|
5
|
+
# (see WebhooksController's own comment on that), so this is
|
|
6
|
+
# deliberately a separate, simpler table rather than a WebhookEvent
|
|
7
|
+
# row with a null status — the whole point is that refund data has
|
|
8
|
+
# nowhere else to land and can't be recovered later if dropped.
|
|
9
|
+
# Nothing here processes it yet: this is just a durable record of
|
|
10
|
+
# what Uber actually sent (data.id, currency_code,
|
|
11
|
+
# total_partner_refund, total_uber_refund, refund_fees,
|
|
12
|
+
# refund_order_items — all captured for free inside `payload`), for
|
|
13
|
+
# whenever refund reconciliation admin UI or accounting sync
|
|
14
|
+
# actually needs it.
|
|
15
|
+
t.string :delivery_id, null: false
|
|
16
|
+
|
|
17
|
+
# Same idempotency-key shape as WebhookEvent (delivery_id + a digest
|
|
18
|
+
# of the raw body) — Uber's webhook delivery is at-least-once, and
|
|
19
|
+
# without this a retried refund_request would insert a second row
|
|
20
|
+
# for the identical refund.
|
|
21
|
+
t.string :payload_digest, null: false
|
|
22
|
+
|
|
23
|
+
# jsonb on Postgres, json on SQLite — same rationale as every
|
|
24
|
+
# sibling migration in this project (see WebhookEvent's own
|
|
25
|
+
# migration comment for the Postgres-only `SELECT DISTINCT` bug this
|
|
26
|
+
# pattern already avoided once).
|
|
27
|
+
if t.respond_to?(:jsonb)
|
|
28
|
+
t.jsonb :payload, null: false
|
|
29
|
+
else
|
|
30
|
+
t.json :payload, null: false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
t.timestamps
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
add_index :spree_uber_direct_refund_events, %i[delivery_id payload_digest],
|
|
37
|
+
unique: true, name: 'index_spree_uber_direct_refund_events_on_idempotency_key'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
class Configuration < Spree::Preferences::Configuration
|
|
3
|
+
# Some example preferences are shown below, for more information visit:
|
|
4
|
+
# https://docs.spreecommerce.org/developer/contributing/creating-an-extension
|
|
5
|
+
|
|
6
|
+
# preference :enabled, :boolean, default: true
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
class Engine < Rails::Engine
|
|
3
|
+
require 'spree/core'
|
|
4
|
+
isolate_namespace Spree
|
|
5
|
+
engine_name 'spree_uber_direct'
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :rspec
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer 'spree_uber_direct.environment', before: :load_config_initializers do |_app|
|
|
12
|
+
SpreeUberDirect::Config = SpreeUberDirect::Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Same ordering requirement as spree_square's and spree_doordash's
|
|
16
|
+
# identical initializer — must run before Active Record's own
|
|
17
|
+
# "active_record_encryption.configuration" initializer reads
|
|
18
|
+
# config.active_record.encryption, or Credential's encrypted columns
|
|
19
|
+
# fail with "Missing Active Record encryption credential" the first
|
|
20
|
+
# time they're touched. Shares the same ACTIVE_RECORD_ENCRYPTION_* keys
|
|
21
|
+
# the sibling extensions already set up (one encryption key set per
|
|
22
|
+
# Rails app, not per extension).
|
|
23
|
+
initializer 'spree_uber_direct.active_record_encryption', before: 'active_record_encryption.configuration' do |app|
|
|
24
|
+
next if ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].blank?
|
|
25
|
+
|
|
26
|
+
app.config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
|
|
27
|
+
app.config.active_record.encryption.deterministic_key = ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY']
|
|
28
|
+
app.config.active_record.encryption.key_derivation_salt = ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT']
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Force-loads decorator files the same way spree_square's and
|
|
32
|
+
# spree_doordash's engines do — not needed until this extension
|
|
33
|
+
# actually has a decorator, kept here from the start so adding one
|
|
34
|
+
# later doesn't require remembering this step. Zeitwerk's lazy
|
|
35
|
+
# autoloading never triggers a decorator file's `prepend` line
|
|
36
|
+
# otherwise.
|
|
37
|
+
def self.activate
|
|
38
|
+
Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c|
|
|
39
|
+
Rails.application.config.cache_classes ? require(c) : load(c)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
config.to_prepare(&method(:activate).to_proc)
|
|
44
|
+
end
|
|
45
|
+
end
|