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,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,5 @@
1
+ <% content_for :page_title do %>
2
+ Uber Direct Deliveries
3
+ <% end %>
4
+
5
+ <%= render_table @collection, :uber_direct_delivery_mappings %>
@@ -0,0 +1,5 @@
1
+ <% content_for :page_title do %>
2
+ Uber Direct Webhooks
3
+ <% end %>
4
+
5
+ <%= render_table @collection, :uber_direct_webhook_events %>
@@ -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