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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.env +7 -0
  3. data/.gitignore +27 -0
  4. data/.rspec +3 -0
  5. data/CHANGELOG.md +170 -0
  6. data/CONTRIBUTING.md +29 -0
  7. data/Gemfile +27 -0
  8. data/LICENSE.md +9 -0
  9. data/README.md +74 -0
  10. data/Rakefile +23 -0
  11. data/app/controllers/spree/admin/uber_direct_credentials_controller.rb +52 -0
  12. data/app/controllers/spree/admin/uber_direct_delivery_mappings_controller.rb +12 -0
  13. data/app/controllers/spree/admin/uber_direct_webhook_events_controller.rb +10 -0
  14. data/app/controllers/spree_uber_direct/webhooks_controller.rb +112 -0
  15. data/app/jobs/spree_uber_direct/base_job.rb +5 -0
  16. data/app/jobs/spree_uber_direct/delivery_dispatch_job.rb +21 -0
  17. data/app/jobs/spree_uber_direct/delivery_webhook_job.rb +17 -0
  18. data/app/models/spree/calculator/shipping/uber_direct_quote.rb +62 -0
  19. data/app/models/spree_uber_direct/credential.rb +33 -0
  20. data/app/models/spree_uber_direct/delivery_mapping.rb +27 -0
  21. data/app/models/spree_uber_direct/order_decorator.rb +42 -0
  22. data/app/models/spree_uber_direct/quote_mapping.rb +25 -0
  23. data/app/models/spree_uber_direct/refund_event.rb +34 -0
  24. data/app/models/spree_uber_direct/webhook_event.rb +34 -0
  25. data/app/services/spree_uber_direct/address_payload.rb +35 -0
  26. data/app/services/spree_uber_direct/alerting.rb +18 -0
  27. data/app/services/spree_uber_direct/client.rb +155 -0
  28. data/app/services/spree_uber_direct/delivery_dispatcher.rb +118 -0
  29. data/app/services/spree_uber_direct/delivery_status_mapper.rb +73 -0
  30. data/app/services/spree_uber_direct/quote.rb +97 -0
  31. data/app/services/spree_uber_direct/webhook_verifier.rb +17 -0
  32. data/app/subscribers/spree_uber_direct/order_completed_subscriber.rb +30 -0
  33. data/app/views/spree/admin/uber_direct_credentials/show.html.erb +55 -0
  34. data/app/views/spree/admin/uber_direct_delivery_mappings/index.html.erb +5 -0
  35. data/app/views/spree/admin/uber_direct_webhook_events/index.html.erb +5 -0
  36. data/config/initializers/spree.rb +14 -0
  37. data/config/initializers/spree_admin_uber_direct_navigation.rb +30 -0
  38. data/config/initializers/spree_admin_uber_direct_tables.rb +101 -0
  39. data/config/routes.rb +22 -0
  40. data/db/migrate/20260821000001_create_spree_uber_direct_credentials.rb +36 -0
  41. data/db/migrate/20260821030001_create_spree_uber_direct_quote_mappings.rb +48 -0
  42. data/db/migrate/20260821040001_create_spree_uber_direct_delivery_mappings.rb +42 -0
  43. data/db/migrate/20260821040002_create_spree_uber_direct_webhook_events.rb +37 -0
  44. data/db/migrate/20260823010001_create_spree_uber_direct_refund_events.rb +39 -0
  45. data/lib/spree_uber_direct/configuration.rb +8 -0
  46. data/lib/spree_uber_direct/engine.rb +45 -0
  47. data/lib/spree_uber_direct/factories.rb +37 -0
  48. data/lib/spree_uber_direct/version.rb +7 -0
  49. data/lib/spree_uber_direct.rb +12 -0
  50. data/lib/tasks/spree_uber_direct.rake +30 -0
  51. data/spree_uber_direct.gemspec +51 -0
  52. 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