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,62 @@
|
|
|
1
|
+
require_dependency 'spree/shipping_calculator'
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
# Compact `module Calculator::Shipping` — same reasoning as
|
|
5
|
+
# Spree::Calculator::Shipping::DoordashQuote's own docstring:
|
|
6
|
+
# Spree::Calculator is already a class in spree_core, not a module;
|
|
7
|
+
# nesting it the "obvious" way raises TypeError.
|
|
8
|
+
module Calculator::Shipping
|
|
9
|
+
# Prices an "Uber Direct Delivery" shipping method against a real,
|
|
10
|
+
# live Uber Direct quote rather than a flat/configured rate. Same
|
|
11
|
+
# zero-registration mechanism as DoordashQuote — any
|
|
12
|
+
# < Spree::ShippingCalculator subclass is auto-discovered by
|
|
13
|
+
# Spree::Stock::Estimator, confirmed directly against spree_core's own
|
|
14
|
+
# shipping_method.rb.
|
|
15
|
+
class UberDirectQuote < ShippingCalculator
|
|
16
|
+
def self.description
|
|
17
|
+
'Uber Direct (live quote)'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def compute_package(package)
|
|
21
|
+
result = SpreeUberDirect::Quote.call(package.order)
|
|
22
|
+
if result.nil?
|
|
23
|
+
# Same object-identity bridge DoordashQuote's own compute_package
|
|
24
|
+
# needs, for the identical reason: `package.order` is not the
|
|
25
|
+
# same in-memory object `create_proposed_shipments` holds as
|
|
26
|
+
# `self` (spree_core's InventoryUnitBuilder deliberately defers
|
|
27
|
+
# loading that association) — a direct
|
|
28
|
+
# `package.order.warnings |= [...]` here would silently mutate a
|
|
29
|
+
# throwaway copy discarded the instant this method returns. See
|
|
30
|
+
# SpreeUberDirect::OrderDecorator for the merge-back half of this
|
|
31
|
+
# bridge, and spree_doordash's own CHANGELOG (0.1.3) for the full
|
|
32
|
+
# story of how this was originally found live.
|
|
33
|
+
self.class.mark_unavailable(package.order.id)
|
|
34
|
+
return nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
result.fee_cents / 100.0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.mark_unavailable(order_id)
|
|
41
|
+
(Thread.current[:spree_uber_direct_quote_unavailable_order_ids] ||= []) << order_id
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.unavailable?(order_id)
|
|
45
|
+
Thread.current[:spree_uber_direct_quote_unavailable_order_ids]&.include?(order_id) || false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.clear_unavailable(order_id)
|
|
49
|
+
Thread.current[:spree_uber_direct_quote_unavailable_order_ids]&.delete(order_id)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Called by Estimator to filter which shipping methods even attempt
|
|
53
|
+
# a compute_package call. Skips the Uber Direct API round-trip
|
|
54
|
+
# entirely for an order with no ship address yet — the real "can
|
|
55
|
+
# Uber Direct serve this address" check still happens inside
|
|
56
|
+
# SpreeUberDirect::Quote itself.
|
|
57
|
+
def available?(package)
|
|
58
|
+
package.order.ship_address.present?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# A Direct account's OAuth client_credentials + Customer ID for one
|
|
3
|
+
# Spree::Store, plus a cached access token. Unlike SpreeDoordash::Credential
|
|
4
|
+
# (a static access key that signs a fresh short-lived JWT per call, nothing
|
|
5
|
+
# to cache), Uber Direct's OAuth2 client_credentials grant issues a genuine
|
|
6
|
+
# bearer token that outlives a single request — SpreeUberDirect::Client
|
|
7
|
+
# caches it here and refreshes it proactively before it actually expires,
|
|
8
|
+
# rather than fetching a fresh one on every call.
|
|
9
|
+
class Credential < Spree.base_class
|
|
10
|
+
self.table_name = 'spree_uber_direct_credentials'
|
|
11
|
+
|
|
12
|
+
belongs_to :store, class_name: 'Spree::Store'
|
|
13
|
+
|
|
14
|
+
encrypts :client_id, :client_secret, :customer_id, :access_token, :webhook_signing_secret
|
|
15
|
+
|
|
16
|
+
validates :store, presence: true, uniqueness: true
|
|
17
|
+
validates :client_id, :client_secret, :customer_id, presence: true
|
|
18
|
+
|
|
19
|
+
def sandbox?
|
|
20
|
+
uber_environment == 'sandbox'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A short margin before the token's real expiry, not right up against
|
|
24
|
+
# it — avoids a request racing the token's actual expiration mid-flight.
|
|
25
|
+
# Mirrors SpreeSquare::Credential's own needs_refresh? margin rationale.
|
|
26
|
+
REFRESH_MARGIN = 60 # seconds
|
|
27
|
+
|
|
28
|
+
def needs_refresh?
|
|
29
|
+
access_token.blank? || access_token_expires_at.blank? ||
|
|
30
|
+
access_token_expires_at <= REFRESH_MARGIN.seconds.from_now
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# Maps a Spree::Order to the Uber Direct delivery it was dispatched to on
|
|
3
|
+
# acceptance — the Uber Direct analog of SpreeDoordash::DeliveryMapping.
|
|
4
|
+
# Unlike DoorDash (which reuses the accepted quote's own id for the
|
|
5
|
+
# delivery's whole lifecycle), Uber mints a genuinely separate
|
|
6
|
+
# `del_`-prefixed id on POST /deliveries (confirmed directly against the
|
|
7
|
+
# real CreateDeliveryResp schema) — external_delivery_id here is always
|
|
8
|
+
# that new id, never the quote's `dqt_...` id.
|
|
9
|
+
class DeliveryMapping < Spree.base_class
|
|
10
|
+
self.table_name = 'spree_uber_direct_delivery_mappings'
|
|
11
|
+
|
|
12
|
+
belongs_to :order, class_name: 'Spree::Order'
|
|
13
|
+
|
|
14
|
+
validates :order, presence: true, uniqueness: true
|
|
15
|
+
# No presence requirement, allow_nil on uniqueness — same rationale as
|
|
16
|
+
# SpreeDoordash::DeliveryMapping's own external_delivery_id: a dispatch
|
|
17
|
+
# can fail before Uber ever returns a delivery id at all (e.g. the
|
|
18
|
+
# underlying quote itself failed), and DeliveryDispatchJob's
|
|
19
|
+
# dead-letter block still needs to record that failure on a row with
|
|
20
|
+
# no id yet.
|
|
21
|
+
validates :external_delivery_id, uniqueness: true, allow_nil: true
|
|
22
|
+
|
|
23
|
+
def mark_failed!(error)
|
|
24
|
+
update!(dispatch_error: error.to_s.truncate(2000))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# Bridges Spree::Calculator::Shipping::UberDirectQuote#compute_package's
|
|
3
|
+
# uber_direct_quote_unavailable warning back onto the real order object —
|
|
4
|
+
# same object-identity bridge and same rationale as spree_doordash's own
|
|
5
|
+
# Spree::OrderDecorator (see that file's docstring for the full
|
|
6
|
+
# root-cause story: package.order is a distinct in-memory object from
|
|
7
|
+
# the order create_proposed_shipments holds, so a direct
|
|
8
|
+
# `package.order.warnings |= [...]` silently mutates a throwaway copy).
|
|
9
|
+
#
|
|
10
|
+
# Deliberately namespaced `SpreeUberDirect::OrderDecorator`, NOT the bare
|
|
11
|
+
# `Spree::OrderDecorator` spree_doordash already defines — both gems
|
|
12
|
+
# prepend a module onto Spree::Order, and reusing the *same* module name
|
|
13
|
+
# would reopen spree_doordash's module and silently overwrite its method
|
|
14
|
+
# instead of creating a second link in Order's prepend chain. Distinct
|
|
15
|
+
# module objects chain correctly via `super`; this is why `super` is
|
|
16
|
+
# called here even though it looks like it "does nothing" locally — it's
|
|
17
|
+
# what lets spree_doordash's own create_proposed_shipments override
|
|
18
|
+
# still run too, regardless of which gem's initializer loads its
|
|
19
|
+
# decorator first.
|
|
20
|
+
module OrderDecorator
|
|
21
|
+
def create_proposed_shipments
|
|
22
|
+
Spree::Calculator::Shipping::UberDirectQuote.clear_unavailable(id)
|
|
23
|
+
result = super
|
|
24
|
+
merge_uber_direct_quote_warning!
|
|
25
|
+
result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def merge_uber_direct_quote_warning!
|
|
31
|
+
return unless Spree::Calculator::Shipping::UberDirectQuote.unavailable?(id)
|
|
32
|
+
|
|
33
|
+
Spree::Calculator::Shipping::UberDirectQuote.clear_unavailable(id)
|
|
34
|
+
self.warnings |= [{
|
|
35
|
+
code: 'uber_direct_quote_unavailable',
|
|
36
|
+
message: 'We could not get an Uber Direct delivery quote for this address.'
|
|
37
|
+
}]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Spree::Order.prepend OrderDecorator
|
|
42
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# Tracks the most recent Uber Direct quote requested for an order,
|
|
3
|
+
# generated as checkout progresses (see Spree::Calculator::Shipping::
|
|
4
|
+
# UberDirectQuote, M3) and consulted again at order.completed to decide
|
|
5
|
+
# whether to accept it as-is or re-quote (see DeliveryDispatcher, M4) —
|
|
6
|
+
# same role as SpreeDoordash::QuoteMapping, checkout can easily outlast a
|
|
7
|
+
# quote's validity window.
|
|
8
|
+
#
|
|
9
|
+
# Unlike DoorDash's 5-minute rule (undocumented on the response itself,
|
|
10
|
+
# just DoorDash's own stated policy), `expired?` here checks Uber's own
|
|
11
|
+
# returned `quote_expires_at` — always accurate to what Uber will
|
|
12
|
+
# actually honor, never a guessed/hardcoded window.
|
|
13
|
+
class QuoteMapping < Spree.base_class
|
|
14
|
+
self.table_name = 'spree_uber_direct_quote_mappings'
|
|
15
|
+
|
|
16
|
+
belongs_to :order, class_name: 'Spree::Order'
|
|
17
|
+
|
|
18
|
+
validates :order, presence: true, uniqueness: true
|
|
19
|
+
validates :external_quote_id, presence: true, uniqueness: true
|
|
20
|
+
|
|
21
|
+
def expired?
|
|
22
|
+
quote_expires_at.blank? || quote_expires_at <= Time.current
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# Durable, unprocessed record of an inbound `event.refund_request`
|
|
3
|
+
# webhook. Uber Direct's refund-request payloads carry no top-level
|
|
4
|
+
# `status`, so WebhooksController can't build a WebhookEvent from one
|
|
5
|
+
# (see that model's own comment) — before this, they were acked and
|
|
6
|
+
# silently dropped alongside genuinely-disposable courier_update pings,
|
|
7
|
+
# even though refund data (data.id, currency_code,
|
|
8
|
+
# total_partner_refund, total_uber_refund, refund_fees,
|
|
9
|
+
# refund_order_items) can't be recovered later once thrown away.
|
|
10
|
+
#
|
|
11
|
+
# Intentionally does none of WebhookEvent's processing-state tracking
|
|
12
|
+
# (processing_status/processed_at/error_message) — there's no consumer
|
|
13
|
+
# for this data yet. It exists purely so refund reconciliation admin UI
|
|
14
|
+
# or accounting sync has something real to read from whenever it's
|
|
15
|
+
# built.
|
|
16
|
+
#
|
|
17
|
+
# Does keep WebhookEvent's idempotency-key shape though (delivery_id +
|
|
18
|
+
# a digest of the raw body) — Uber's webhook delivery is at-least-once,
|
|
19
|
+
# and a retried refund_request shouldn't silently become two rows for
|
|
20
|
+
# the same refund.
|
|
21
|
+
class RefundEvent < Spree.base_class
|
|
22
|
+
self.table_name = 'spree_uber_direct_refund_events'
|
|
23
|
+
|
|
24
|
+
attribute :payload, default: -> { {} }
|
|
25
|
+
|
|
26
|
+
validates :delivery_id, presence: true
|
|
27
|
+
validates :payload, presence: true
|
|
28
|
+
validates :payload_digest, presence: true, uniqueness: { scope: :delivery_id }
|
|
29
|
+
|
|
30
|
+
def self.digest(raw_body)
|
|
31
|
+
Digest::SHA256.hexdigest(raw_body)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# Idempotency + audit log for inbound Uber Direct webhook notifications.
|
|
3
|
+
#
|
|
4
|
+
# Column named `status` on this table is Uber's own delivery status value
|
|
5
|
+
# (pending/pickup/pickup_complete/dropoff/delivered/canceled/returned/
|
|
6
|
+
# shopping_completed) — this model's *own* pending/processed/failed
|
|
7
|
+
# processing state lives in the separate `processing_status` column, to
|
|
8
|
+
# avoid the two genuinely different meanings colliding on one name
|
|
9
|
+
# (DoorDash's own WebhookEvent didn't need this distinction since its
|
|
10
|
+
# payload field is called event_name, not status).
|
|
11
|
+
class WebhookEvent < Spree.base_class
|
|
12
|
+
self.table_name = 'spree_uber_direct_webhook_events'
|
|
13
|
+
|
|
14
|
+
attribute :payload, default: -> { {} }
|
|
15
|
+
|
|
16
|
+
validates :delivery_id, presence: true
|
|
17
|
+
validates :status, presence: true
|
|
18
|
+
validates :payload_digest, presence: true, uniqueness: { scope: %i[delivery_id status] }
|
|
19
|
+
|
|
20
|
+
scope :pending, -> { where(processing_status: 'pending') }
|
|
21
|
+
|
|
22
|
+
def self.digest(raw_body)
|
|
23
|
+
Digest::SHA256.hexdigest(raw_body)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def mark_processed!
|
|
27
|
+
update!(processing_status: 'processed', processed_at: Time.current)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def mark_failed!(error)
|
|
31
|
+
update!(processing_status: 'failed', processed_at: Time.current, error_message: error.to_s.truncate(1000))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# Shared address/phone formatting between Quote (create_quote) and
|
|
3
|
+
# DeliveryDispatcher (create_delivery) — both build a payload from the
|
|
4
|
+
# same order + fulfilling StockLocation shape, and Uber's schema uses
|
|
5
|
+
# the identical JSON-string-address / E.164-phone conventions on both
|
|
6
|
+
# endpoints (confirmed directly against the real openapi.yaml).
|
|
7
|
+
module AddressPayload
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Same E.164 normalization as SpreeDoordash::Quote#format_phone
|
|
11
|
+
# (Uber's own schema pattern, `^\+[0-9]+$`, is the same shape DoorDash
|
|
12
|
+
# rejects anything else against) — US-only, matching the rest of this
|
|
13
|
+
# demo.
|
|
14
|
+
def format_phone(raw)
|
|
15
|
+
digits = raw.to_s.gsub(/\D/, '')
|
|
16
|
+
digits = "1#{digits}" if digits.length == 10
|
|
17
|
+
"+#{digits}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Uber wants a JSON *string* per address field — structured, not
|
|
21
|
+
# DoorDash's flat comma-joined string. Spree::StockLocation and
|
|
22
|
+
# Spree::Address share the same field shape (address1/address2/city/
|
|
23
|
+
# state/zipcode/country) even though they're unrelated classes.
|
|
24
|
+
def format_address(record)
|
|
25
|
+
street = [record.address1, record.try(:address2)].compact_blank
|
|
26
|
+
{
|
|
27
|
+
street_address: street,
|
|
28
|
+
city: record.city,
|
|
29
|
+
state: record.state&.abbr || record.state_name,
|
|
30
|
+
zip_code: record.zipcode,
|
|
31
|
+
country: record.country&.iso
|
|
32
|
+
}.compact.to_json
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# One place for "this needs a human" — used when a job exhausts its
|
|
3
|
+
# retries. A standalone copy of the same pattern SpreeSquare::Alerting
|
|
4
|
+
# and SpreeDoordash::Alerting each already carry, not a dependency on
|
|
5
|
+
# either sibling gem — spree_uber_direct stays independently installable
|
|
6
|
+
# on its own.
|
|
7
|
+
class Alerting
|
|
8
|
+
def self.capture(error, context: {})
|
|
9
|
+
context = { source: 'spree_uber_direct' }.merge(context.is_a?(String) ? { area: context } : context)
|
|
10
|
+
|
|
11
|
+
Rails.logger.error("[SpreeUberDirect] #{context[:area] || 'error'}: #{error.class}: #{error.message}")
|
|
12
|
+
|
|
13
|
+
return unless defined?(Sentry)
|
|
14
|
+
|
|
15
|
+
Sentry.capture_exception(error, extra: context)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
|
|
3
|
+
module SpreeUberDirect
|
|
4
|
+
# Thin wrapper around the Uber Direct API (`api.uber.com/v1/customers/
|
|
5
|
+
# {customer_id}/...`). All Direct API access in this extension goes
|
|
6
|
+
# through here.
|
|
7
|
+
#
|
|
8
|
+
# Auth is fundamentally different from SpreeDoordash::Client: Uber Direct
|
|
9
|
+
# uses OAuth2 client_credentials (`auth.uber.com/oauth/v2/token`), which
|
|
10
|
+
# issues a genuine bearer token that outlives a single request — unlike
|
|
11
|
+
# DoorDash's per-request-signed 5-minute JWT, there's a real token to
|
|
12
|
+
# cache and refresh here. The cache lives on the Credential row itself
|
|
13
|
+
# (see SpreeUberDirect::Credential#needs_refresh?) rather than
|
|
14
|
+
# Rails.cache — no extra infrastructure, and naturally picks up a
|
|
15
|
+
# rotated client_secret on the very next call since a fresh
|
|
16
|
+
# `Credential.find_by` is re-resolved per client instance (not memoized —
|
|
17
|
+
# same rationale as SpreeSquare::Client and SpreeDoordash::Client: an
|
|
18
|
+
# admin can edit the credential mid-process).
|
|
19
|
+
class Client
|
|
20
|
+
class MissingCredentialsError < StandardError; end
|
|
21
|
+
|
|
22
|
+
SANDBOX_BASE_URL = 'https://api.uber.com'.freeze
|
|
23
|
+
AUTH_URL = 'https://auth.uber.com/oauth/v2/token'.freeze
|
|
24
|
+
SCOPE = 'eats.deliveries'.freeze
|
|
25
|
+
|
|
26
|
+
def self.instance
|
|
27
|
+
for_store
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.for_store(store = Spree::Store.default)
|
|
31
|
+
new(credential: SpreeUberDirect::Credential.find_by(store: store))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(credential: nil)
|
|
35
|
+
@credential = credential
|
|
36
|
+
raise MissingCredentialsError, 'No Uber Direct credential connected and UBER_DIRECT_CLIENT_ID is not set' if client_id.blank?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sandbox?
|
|
40
|
+
@credential ? @credential.sandbox? : ENV.fetch('UBER_DIRECT_ENVIRONMENT', 'sandbox') == 'sandbox'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Create Quote — validates coverage/pricing before formally creating a
|
|
44
|
+
# delivery, same "quote first" recommended flow as DoorDash's Drive API.
|
|
45
|
+
def create_quote(payload)
|
|
46
|
+
request(:post, "/v1/customers/#{customer_id}/delivery_quotes", payload)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Create Delivery — formally dispatches from a still-open quote_id.
|
|
50
|
+
def create_delivery(payload)
|
|
51
|
+
request(:post, "/v1/customers/#{customer_id}/deliveries", payload)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get_delivery(delivery_id)
|
|
55
|
+
request(:get, "/v1/customers/#{customer_id}/deliveries/#{delivery_id}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cancel_delivery(delivery_id)
|
|
59
|
+
request(:post, "/v1/customers/#{customer_id}/deliveries/#{delivery_id}/cancel")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def request(method, path, body = nil)
|
|
65
|
+
response = connection.send(method) do |req|
|
|
66
|
+
req.url path
|
|
67
|
+
req.headers['Authorization'] = "Bearer #{access_token}"
|
|
68
|
+
req.headers['Content-Type'] = 'application/json'
|
|
69
|
+
req.body = body.to_json if body
|
|
70
|
+
end
|
|
71
|
+
handle_response(response)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def connection
|
|
75
|
+
@connection ||= Faraday.new(url: SANDBOX_BASE_URL)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle_response(response)
|
|
79
|
+
parsed = response.body.present? ? JSON.parse(response.body) : {}
|
|
80
|
+
return parsed if response.status.between?(200, 299)
|
|
81
|
+
|
|
82
|
+
raise RequestError.new("Uber Direct API error (#{response.status}): #{parsed.inspect}", status: response.status, body: parsed)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def customer_id
|
|
86
|
+
fetch_secret(:customer_id, env_key: 'UBER_DIRECT_CUSTOMER_ID')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def client_id
|
|
90
|
+
fetch_secret(:client_id, env_key: 'UBER_DIRECT_CLIENT_ID')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def client_secret
|
|
94
|
+
fetch_secret(:client_secret, env_key: 'UBER_DIRECT_CLIENT_SECRET')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Credential first, ENV fallback second — same dual-path convention
|
|
98
|
+
# SpreeSquare::Client's own `fetch` uses, kept for the same reason: a
|
|
99
|
+
# one-off admin/dev rake task (mirroring spree_square's
|
|
100
|
+
# `setup_demo_tax`) can run against a broader sandbox credential
|
|
101
|
+
# without needing a Credential row set up first.
|
|
102
|
+
def fetch_secret(key, env_key:)
|
|
103
|
+
(@credential && @credential.public_send(key).presence) || ENV[env_key].presence
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def access_token
|
|
107
|
+
return fetch_token!['access_token'] unless @credential
|
|
108
|
+
|
|
109
|
+
refresh_if_needed!
|
|
110
|
+
@credential.access_token
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def refresh_if_needed!
|
|
114
|
+
return unless @credential.needs_refresh?
|
|
115
|
+
|
|
116
|
+
token_data = fetch_token!
|
|
117
|
+
@credential.update!(
|
|
118
|
+
access_token: token_data['access_token'],
|
|
119
|
+
access_token_expires_at: token_data['expires_in'].to_i.seconds.from_now
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# No caching on the credential-less (ENV-only) path — deliberately
|
|
124
|
+
# simple: that path only exists for occasional ad-hoc scripting, not a
|
|
125
|
+
# hot request path, so re-fetching a token every call costs nothing
|
|
126
|
+
# worth optimizing for.
|
|
127
|
+
def fetch_token!
|
|
128
|
+
response = Faraday.post(AUTH_URL) do |req|
|
|
129
|
+
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
130
|
+
req.body = URI.encode_www_form(
|
|
131
|
+
client_id: client_id,
|
|
132
|
+
client_secret: client_secret,
|
|
133
|
+
grant_type: 'client_credentials',
|
|
134
|
+
scope: SCOPE
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
parsed = JSON.parse(response.body)
|
|
138
|
+
unless response.status.between?(200, 299)
|
|
139
|
+
raise RequestError.new("Uber Direct OAuth error (#{response.status}): #{parsed.inspect}", status: response.status, body: parsed)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
parsed
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
class RequestError < StandardError
|
|
147
|
+
attr_reader :status, :body
|
|
148
|
+
|
|
149
|
+
def initialize(message, status:, body:)
|
|
150
|
+
super(message)
|
|
151
|
+
@status = status
|
|
152
|
+
@body = body
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# Dispatches a completed order to Uber Direct: creates the delivery
|
|
3
|
+
# against its already-open quote if one exists and hasn't expired, or
|
|
4
|
+
# requests a fresh quote and creates the delivery from that instead.
|
|
5
|
+
# Same accept-or-requote shape as SpreeDoordash::DeliveryDispatcher, for
|
|
6
|
+
# the identical reason — checkout can easily outlast a quote's validity
|
|
7
|
+
# window (Uber returns its own real `expires` per quote; see
|
|
8
|
+
# SpreeUberDirect::QuoteMapping#expired?).
|
|
9
|
+
#
|
|
10
|
+
# Architecturally bigger than DoorDash's own accept_quote (which only
|
|
11
|
+
# needs the external_delivery_id, DoorDash already has everything else
|
|
12
|
+
# from the original quote request) — confirmed live against a real
|
|
13
|
+
# Sandbox 400: Uber's POST /deliveries wants a *full* DeliveryReq body
|
|
14
|
+
# (pickup/dropoff name+address+phone, manifest_items,
|
|
15
|
+
# manifest_total_value) even when a quote_id is also provided. Reuses
|
|
16
|
+
# AddressPayload (shared with Quote) rather than duplicating the
|
|
17
|
+
# formatting logic.
|
|
18
|
+
class DeliveryDispatcher
|
|
19
|
+
def self.call(...) = new.call(...)
|
|
20
|
+
|
|
21
|
+
def call(order)
|
|
22
|
+
quote_mapping = quote_mapping_for(order)
|
|
23
|
+
return nil unless quote_mapping
|
|
24
|
+
|
|
25
|
+
stock_location = pickup_location_for(order)
|
|
26
|
+
return nil unless stock_location
|
|
27
|
+
|
|
28
|
+
client = SpreeUberDirect::Client.for_store(order.store || Spree::Store.default)
|
|
29
|
+
response = client.create_delivery(build_payload(order, stock_location, quote_mapping, robo_courier: robo_courier?(client)))
|
|
30
|
+
|
|
31
|
+
persist_delivery!(order, response)
|
|
32
|
+
rescue SpreeUberDirect::Client::MissingCredentialsError, SpreeUberDirect::RequestError => e
|
|
33
|
+
Rails.logger.error("[SpreeUberDirect] dispatch failed for order #{order.number}: #{e.message}")
|
|
34
|
+
SpreeUberDirect::DeliveryMapping.find_or_initialize_by(order: order).mark_failed!(e)
|
|
35
|
+
SpreeUberDirect::Alerting.capture(e, context: { area: 'dispatch', order_number: order.number })
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def quote_mapping_for(order)
|
|
42
|
+
existing = SpreeUberDirect::QuoteMapping.find_by(order: order)
|
|
43
|
+
return existing if existing && !existing.expired?
|
|
44
|
+
|
|
45
|
+
SpreeUberDirect::Quote.call(order)
|
|
46
|
+
SpreeUberDirect::QuoteMapping.find_by(order: order)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Same lookup as SpreeUberDirect::Quote#pickup_location_for — queried
|
|
50
|
+
# directly rather than trusting an already-loaded `order.shipments`
|
|
51
|
+
# association, same reasoning.
|
|
52
|
+
def pickup_location_for(order)
|
|
53
|
+
Spree::Shipment.where(order_id: order.id).first&.stock_location ||
|
|
54
|
+
Spree::StockLocation.find_by(default: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Gated on the credential's own sandbox flag only — deliberately, not
|
|
58
|
+
# also on Rails.env. An earlier version of this method also required
|
|
59
|
+
# !Rails.env.production?, reasoning that this project's live storefront
|
|
60
|
+
# runs real production Rails against a genuinely `uber_environment:
|
|
61
|
+
# sandbox` credential (Uber has not yet granted production API access),
|
|
62
|
+
# so the credential alone couldn't be trusted as "this is just a test".
|
|
63
|
+
# That's still true, but it was the wrong tradeoff for this specific
|
|
64
|
+
# project: the whole storefront is a demo running entirely on sandbox
|
|
65
|
+
# credentials end to end (Square, DoorDash, Uber alike) — showing a
|
|
66
|
+
# real visitor the full Uber Direct delivery lifecycle live is the
|
|
67
|
+
# intended demo experience here, not an accident to guard against.
|
|
68
|
+
# Explicit, informed decision, not a default: confirm this project's
|
|
69
|
+
# storefront is still demo-only before ever reusing this pattern
|
|
70
|
+
# somewhere real money/production access is actually on the line.
|
|
71
|
+
def robo_courier?(client)
|
|
72
|
+
client.sandbox?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_payload(order, stock_location, quote_mapping, robo_courier:)
|
|
76
|
+
payload = {
|
|
77
|
+
quote_id: quote_mapping.external_quote_id,
|
|
78
|
+
pickup_name: stock_location.name,
|
|
79
|
+
pickup_address: AddressPayload.format_address(stock_location),
|
|
80
|
+
pickup_phone_number: AddressPayload.format_phone(stock_location.phone),
|
|
81
|
+
dropoff_name: order.ship_address.full_name,
|
|
82
|
+
dropoff_address: AddressPayload.format_address(order.ship_address),
|
|
83
|
+
dropoff_phone_number: AddressPayload.format_phone(order.ship_address.phone),
|
|
84
|
+
manifest_items: manifest_items(order),
|
|
85
|
+
manifest_total_value: (order.total * 100).to_i
|
|
86
|
+
}
|
|
87
|
+
# Robo Courier: Uber Direct's sandbox-only test-automation feature —
|
|
88
|
+
# there is no dashboard "simulate delivery" UI the way DoorDash has
|
|
89
|
+
# one. Requesting `mode: 'auto'` here makes Uber's own courier bot
|
|
90
|
+
# walk the delivery through real status transitions (assigned →
|
|
91
|
+
# enroute → pickup imminent → picked up → dropoff imminent →
|
|
92
|
+
# delivered) at fixed 30s intervals, firing a real webhook at each
|
|
93
|
+
# stage.
|
|
94
|
+
payload[:test_specifications] = { robo_courier_specification: { mode: 'auto' } } if robo_courier
|
|
95
|
+
payload
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def manifest_items(order)
|
|
99
|
+
order.line_items.map do |item|
|
|
100
|
+
{ name: item.name, quantity: item.quantity }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def persist_delivery!(order, response)
|
|
105
|
+
courier = response['courier'] || {}
|
|
106
|
+
mapping = SpreeUberDirect::DeliveryMapping.find_or_initialize_by(order: order)
|
|
107
|
+
mapping.update!(
|
|
108
|
+
external_delivery_id: response['id'],
|
|
109
|
+
last_status: response['status'],
|
|
110
|
+
tracking_url: response['tracking_url'],
|
|
111
|
+
courier_name: courier['name'],
|
|
112
|
+
courier_phone: courier['phone_number'],
|
|
113
|
+
raw_response: response
|
|
114
|
+
)
|
|
115
|
+
mapping
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module SpreeUberDirect
|
|
2
|
+
# Applies an Uber Direct delivery-status webhook event to the mapped
|
|
3
|
+
# Spree order — the Uber Direct analog of SpreeDoordash::DeliveryStatusMapper.
|
|
4
|
+
#
|
|
5
|
+
# Uber's own status vocabulary (confirmed directly against the real
|
|
6
|
+
# openapi.yaml shipped in uber/uber-direct-sdk, not assumed): `pending`,
|
|
7
|
+
# `pickup` / `pickup_complete`, `dropoff`, `delivered`, `canceled`,
|
|
8
|
+
# `returned`, plus `shopping_completed` for Courier Pick & Pack orders
|
|
9
|
+
# (not used by this extension's own flow, but present in the schema).
|
|
10
|
+
#
|
|
11
|
+
# No version/sequence field in Uber's payload either, same "don't gate on
|
|
12
|
+
# ordering" posture as both DoorDash's and Square's own status mappers —
|
|
13
|
+
# safety against duplicate processing comes entirely from WebhookEvent's
|
|
14
|
+
# idempotency key and from the state-guarded, idempotent operations
|
|
15
|
+
# below. Uber's own webhook ordering behavior across concurrent workers
|
|
16
|
+
# is unverified until actually observed live — flagged here rather than
|
|
17
|
+
# assumed, same precedent as every sibling status mapper's own comment.
|
|
18
|
+
class DeliveryStatusMapper
|
|
19
|
+
SHIP_STATUSES = %w[delivered].freeze
|
|
20
|
+
CANCEL_STATUSES = %w[canceled returned].freeze
|
|
21
|
+
# Real Uber lifecycle statuses with no Spree shipment_state equivalent
|
|
22
|
+
# — recorded as a friendly last_status label only, same "label vs.
|
|
23
|
+
# state transition" split as every sibling status mapper.
|
|
24
|
+
LABEL_ONLY_STATUSES = %w[pending pickup pickup_complete dropoff shopping_completed].freeze
|
|
25
|
+
|
|
26
|
+
def self.call(...) = new.call(...)
|
|
27
|
+
|
|
28
|
+
def call(payload)
|
|
29
|
+
mapping = SpreeUberDirect::DeliveryMapping.find_by(external_delivery_id: payload['delivery_id'])
|
|
30
|
+
return unless mapping
|
|
31
|
+
|
|
32
|
+
status = payload['status']
|
|
33
|
+
order = mapping.order
|
|
34
|
+
data = payload['data'] || {}
|
|
35
|
+
courier = data['courier'] || {}
|
|
36
|
+
|
|
37
|
+
case status
|
|
38
|
+
when *SHIP_STATUSES
|
|
39
|
+
ship!(order)
|
|
40
|
+
when *CANCEL_STATUSES
|
|
41
|
+
cancel!(order)
|
|
42
|
+
when *LABEL_ONLY_STATUSES
|
|
43
|
+
# No Spree-side transition — last_status below is the only effect.
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# tracking_url isn't confirmed present on the webhook payload itself
|
|
47
|
+
# (the real Delivery Status Webhook reference documents delivery_id/
|
|
48
|
+
# status/data.courier/data.pickup/data.dropoff/batch_id/
|
|
49
|
+
# courier_imminent — tracking_url is a top-level field on the fuller
|
|
50
|
+
# CreateDeliveryResp/GetDeliveryResp REST shapes, which webhooks
|
|
51
|
+
# often trim). `.presence || mapping.tracking_url` means a missing
|
|
52
|
+
# field here just keeps whatever DeliveryDispatcher already recorded
|
|
53
|
+
# from the create_delivery response, rather than clobbering it with
|
|
54
|
+
# nil — safe either way, verify the real key at M4's live test.
|
|
55
|
+
mapping.update!(
|
|
56
|
+
last_status: status || mapping.last_status,
|
|
57
|
+
tracking_url: data['tracking_url'].presence || mapping.tracking_url,
|
|
58
|
+
courier_name: courier['name'].presence || mapping.courier_name,
|
|
59
|
+
courier_phone: courier['phone_number'].presence || mapping.courier_phone
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def ship!(order)
|
|
66
|
+
order.shipments.each { |shipment| shipment.ship! if shipment.ready? }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cancel!(order)
|
|
70
|
+
order.cancel! unless order.canceled?
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|