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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8e0793a132d883c5102169e0cdde12d41142ff2970a83a562e3a859c9cee04dc
4
+ data.tar.gz: 15ef632876eee5186617f043d917f4ce52c15001c72767a16e3e63535a24c66e
5
+ SHA512:
6
+ metadata.gz: 3a52b2f8721b956c79abab2b34d3e4941a44fa7cc1aa73e1712df17dfcee2b6f3af2ae3f2bb99b1337346793ba0cf4ed702cc560b7e4b205a901fd8dd1d540f2
7
+ data.tar.gz: c251297570f0d3b593e254135f01606563b6718c9712c30a3091c4ccb0434769f6430bb5379b07b7e5d8fae6415c530bb15b2253f8a88a6f52fba06c397f1dd3
data/.env ADDED
@@ -0,0 +1,7 @@
1
+ # Loaded via `dotenv/load` in spec/spec_helper.rb, for the dummy test app
2
+ # only — not read by anything that ships. Test-only Active Record Encryption
3
+ # keys (SpreeUberDirect::Credential) so specs can save/load encrypted
4
+ # columns; generated with SecureRandom, no real secret behind them.
5
+ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=n9E5F4KLb9C8VyQ2ZmXtRj7WuHsPaTgD
6
+ ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=Qw3RtYpAsDfGhJkLzXcVbNm5UiOp9Egh
7
+ ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=Zx8CvBnM4QwErTyU6IoPaSdFgHjKlBcv
data/.gitignore ADDED
@@ -0,0 +1,27 @@
1
+ \#*
2
+ *~
3
+ .#*
4
+ .DS_Store
5
+ .idea
6
+ .localeapp/locales
7
+ .project
8
+ .vscode
9
+ coverage
10
+ default
11
+ Gemfile.lock
12
+ tmp
13
+ nbproject
14
+ pkg
15
+ *.sw?
16
+ spec/dummy
17
+ .rvmrc
18
+ .sass-cache
19
+ public/spree
20
+ .ruby-version
21
+ .ruby-gemset
22
+ *.gem
23
+ */*.gem
24
+
25
+ # Real Uber Direct Sandbox credentials for manual verification — never committed,
26
+ # unlike the test-only encryption keys in .env.
27
+ .env.sandbox
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ -r spec_helper
3
+ -f documentation
data/CHANGELOG.md ADDED
@@ -0,0 +1,170 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ ## 0.1.4
6
+
7
+ Reverses part of 0.1.1's own safety decision, deliberately: Robo Courier is now gated on the credential's
8
+ `uber_environment: sandbox` flag alone again, not also on `!Rails.env.production?`. Explicit, informed call
9
+ by the project owner after 0.1.1's own change shipped — this storefront is a demo running end to end on
10
+ sandbox credentials (Square, DoorDash, Uber alike) regardless of Rails environment, and showing a real
11
+ visitor the full Uber Direct delivery lifecycle live is the intended demo experience, not an accident to
12
+ guard against. Flagged in code review as a real, accepted tradeoff rather than silently dropped: every
13
+ production dispatch now also drives ~6 Robo Courier webhook calls against Uber's real Sandbox rate limit
14
+ (200 req/10min per application_id) — fine at this project's traffic scale, worth revisiting if that changes.
15
+
16
+ ## 0.1.3
17
+
18
+ Closes the `event.refund_request` gap 0.1.2 flagged as a follow-up: those payloads were being acked and
19
+ dropped alongside genuinely-disposable `event.courier_update` pings, but a refund notification carries real,
20
+ unrecoverable data (`data.id`, `currency_code`, `total_partner_refund`, `total_uber_refund`, `refund_fees`,
21
+ `refund_order_items` — confirmed against
22
+ [Uber's own webhook docs](https://developer.uber.com/docs/deliveries/daas/api/webhook-event-refundrequest)).
23
+
24
+ Adds `SpreeUberDirect::RefundEvent` — same idempotency-key shape as `WebhookEvent` (`delivery_id` + a digest
25
+ of the raw body, deduping Uber's at-least-once webhook redelivery) but deliberately without any of
26
+ `WebhookEvent`'s processing-state tracking (`processing_status`/`processed_at`/`error_message`), since
27
+ there's no consumer for this data yet. `WebhooksController#create` now branches on
28
+ `payload['kind'] == 'event.refund_request'` ahead of the generic blank-status drop and persists the full
29
+ payload there instead. No processing/business logic added — purely a durable record for whenever refund
30
+ reconciliation admin UI or accounting sync becomes a real need.
31
+
32
+ **Code review caught a real bug before merge**: the first pass used a bare `RefundEvent.create!`, which
33
+ raised `ActiveRecord::RecordInvalid` (reproduced directly) for any payload missing a top-level `delivery_id`
34
+ — crashing instead of acking, and risking Uber retrying (and eventually disabling) the webhook subscription
35
+ on a payload that would keep failing identically. Fixed by routing through the same
36
+ find-or-create-with-digest + rescue shape `WebhookEvent`'s own path already uses.
37
+
38
+ ## 0.1.2
39
+
40
+ Fixes a real bug found live while verifying 0.1.1's Robo Courier run through a real ngrok-tunneled
41
+ dispatch: Uber's dashboard lets one webhook subscribe to three event kinds on the same endpoint —
42
+ `event.delivery_status` (the only one with a top-level `status` field), `event.courier_update` (a courier
43
+ GPS ping fired every 20s once a courier is assigned), and `event.refund_request` (fired when a refund is
44
+ requested, confirmed against Uber's own webhook docs). Neither of the latter two carries a `status` field,
45
+ but `WebhooksController` always built its `WebhookEvent` with `status: payload['status']`, and
46
+ `WebhookEvent` requires status presence — every courier-location ping was hitting an unhandled
47
+ `ActiveRecord::RecordNotFound` and surfacing as a bare `404` to Uber's webhook delivery system. The
48
+ controller now acks and drops any payload with a blank status before it reaches `WebhookEvent`, with a
49
+ debug-level log line so the drop stays traceable. `event.refund_request` payloads are currently discarded
50
+ the same way — a real gap (refund data isn't persisted anywhere yet) flagged as a follow-up, not built
51
+ here.
52
+
53
+ **Verified live**: re-dispatched a real order through the real `DeliveryDispatcher` with Robo Courier
54
+ enabled, watched it progress through the full real lifecycle (`pending` → `pickup` → `dropoff` →
55
+ `delivered`, 8 real webhooks) through the ngrok tunnel with zero 404s afterward.
56
+
57
+ ## 0.1.1
58
+
59
+ Sandbox delivery dispatch now requests Uber's **Robo Courier** test-automation feature
60
+ (`test_specifications.robo_courier_specification.mode: "auto"` on `POST /deliveries`) — Uber Direct has
61
+ no dashboard "simulate delivery" UI the way DoorDash's own Delivery Simulator works; this is its actual
62
+ equivalent. Without it, a sandbox delivery only ever emitted its initial `pending` webhook and then went
63
+ silent forever (no real driver app was ever going to advance it), which is exactly what this closes.
64
+
65
+ **Verified live**: dispatched a real order through the real `DeliveryDispatcher` (not a console script)
66
+ against a real Uber Direct Sandbox account, tunneled through ngrok to a local dev server with a real
67
+ registered webhook + signing secret — a real `pending` webhook landed, signature-verified, immediately on
68
+ dispatch, closing out the one remaining gap between this extension's Sandbox setup and `spree_doordash`'s.
69
+
70
+ Gated on **two independent conditions**, not just the credential's `uber_environment` field — code review
71
+ caught that this project's own live production deploy runs against a genuinely `uber_environment: sandbox`
72
+ credential (Uber has not yet granted production API access), so trusting that field alone would have
73
+ silently faked delivery-status progression for real customer orders if this ever shipped to production.
74
+ Robo Courier now also requires `!Rails.env.production?`.
75
+
76
+ ## 0.1.0 (released)
77
+
78
+ A second, independent delivery provider alongside `spree_doordash` — same one-extension-per-integration
79
+ pattern already established by `spree_square`/`spree_doordash`/`spree_menu_chat`/`spree_loyalty`, offered
80
+ as a third shipping-method choice (Pickup, DoorDash Delivery, Uber Direct Delivery) rather than a
81
+ replacement for either.
82
+
83
+ **M1 — Foundation.** Gemspec, engine, `Credential` (OAuth2 `client_credentials` — client_id/client_secret/
84
+ customer_id, cached access token with expiry-aware refresh keyed off the credential's own `updated_at` so
85
+ a rotated secret takes effect immediately, webhook signing key), admin credential form, `Client` service
86
+ (token fetch/cache, `create_quote`/`create_delivery`/`get_delivery`/`cancel_delivery`). **Verified against
87
+ a real Uber Direct Sandbox account**: a real OAuth token was fetched from `auth.uber.com/oauth/v2/token`
88
+ and accepted by a real `POST /v1/customers/{customer_id}/delivery_quotes` call (real `400 invalid_params`
89
+ for an intentionally-incomplete payload, not `401`/`403`).
90
+
91
+ **M2 — Quoting.** `QuoteMapping` + `Quote` service. Simpler than DoorDash's own quote tracking in two ways
92
+ confirmed against Uber's real `openapi.yaml` (`github.com/uber/uber-direct-sdk`): the quote id is
93
+ server-generated and returned fresh on every call (no client-side id-collision risk DoorDash's own
94
+ `external_delivery_id` needed a random suffix to dodge), and Uber returns its own real `expires` timestamp
95
+ instead of a hardcoded documented window. Addresses are JSON-encoded strings
96
+ (`{"street_address":[...],"city":...}`), not DoorDash's flat comma-joined string — confirmed directly
97
+ against the schema, not guessed. **Verified live**: a New York dropoff correctly got a real
98
+ `400 address_undeliverable` (412mi from the Fairview Park, OH pickup, outside Uber's 10mi radius — a true
99
+ rejection, not a bug), and a real nearby Ohio address got a real accepted quote ($7.99, 15-minute expiry).
100
+
101
+ **M3 — Storefront quoting.** `Spree::Calculator::Shipping::UberDirectQuote` reuses the same
102
+ zero-registration `Spree::ShippingCalculator` subclass mechanism `DoordashQuote` already proved out, and
103
+ the same `Thread.current` object-identity bridge for pushing an unavailable-quote warning onto the real
104
+ order (`package.order` is a distinct in-memory object from what `create_proposed_shipments` holds — see
105
+ `spree_doordash`'s own 0.1.3 for the full root-cause story this reuses verbatim).
106
+ `SpreeUberDirect::OrderDecorator` does the merge-back half — deliberately namespaced (not the bare
107
+ `Spree::OrderDecorator` name `spree_doordash` already claims) so both gems' prepended modules chain
108
+ correctly via `super` instead of one silently overwriting the other. No storefront (Next.js) code changes
109
+ needed. **Verified live**: a real checkout showed all three shipping methods together — Pickup $0.00, Uber
110
+ Direct Delivery $7.99 (+ tax), DoorDash Delivery $9.75 (+ tax) — confirming both delivery providers coexist
111
+ without either's decorator clobbering the other.
112
+
113
+ **M4 — Dispatch + webhooks.** `DeliveryMapping` (Uber mints a genuinely separate `del_`-prefixed delivery
114
+ id on `POST /deliveries`, unlike DoorDash which reuses the accepted quote's own id for the delivery's whole
115
+ lifecycle), `WebhookEvent` (idempotency key is `delivery_id` + `status` + a payload digest — Uber's
116
+ webhooks carry one event kind, `event.delivery_status`, with no distinct event id, the same gap DoorDash's
117
+ own webhooks have; the DB column holding *our own* pending/processed/failed tracking is named
118
+ `processing_status`, not `status`, specifically to avoid colliding with Uber's own delivery status value
119
+ which is genuinely called `status` in their payload). `WebhookVerifier` is architecturally different from
120
+ DoorDash's Basic-Auth string echo: real HMAC-SHA256 over the raw body via `x-uber-signature`, keyed by a
121
+ dedicated webhook signing secret (confirmed against developer.uber.com's webhook guide — a separate value
122
+ from the OAuth `client_secret`). `DeliveryDispatcher`/`DeliveryStatusMapper`/`WebhooksController`/
123
+ `DeliveryWebhookJob`/`DeliveryDispatchJob`/`OrderCompletedSubscriber`/`Alerting` all mirror their DoorDash
124
+ counterparts' exact shapes, adapted to Uber's real status vocabulary (`pending`/`pickup`/`pickup_complete`/
125
+ `dropoff`/`delivered`/`canceled`/`returned`/`shopping_completed`). Critically included
126
+ `config/initializers/spree.rb` registering the subscriber with `Spree.subscribers` — without it the
127
+ subscriber is real and loadable (specs calling it directly always pass) but never actually wired to a real
128
+ `order.completed` event, the exact silent gap `spree_doordash`'s own CHANGELOG documents finding live.
129
+
130
+ A real bug found live during this milestone's own verification: `DeliveryDispatcher` initially sent only
131
+ `{quote_id: ...}` to `POST /deliveries` — a real Sandbox `400` (`invalid_params`: `pickup_name`/
132
+ `dropoff_name`/`manifest` required) proved Uber's `DeliveryReq` needs a full payload even with a `quote_id`
133
+ attached, unlike DoorDash's `accept_quote` (which only needs the id). Extracted address/phone formatting
134
+ into a shared `AddressPayload` module (used by both `Quote` and `DeliveryDispatcher`) rather than
135
+ duplicating it.
136
+
137
+ **M5 — Admin UI + real Sandbox end-to-end verification.** Read-only admin pages for `DeliveryMapping`/
138
+ `WebhookEvent` (four-file pattern, including the `new_resource: false` fix both `spree_square` and
139
+ `spree_doordash` had to learn the hard way on their first real Postgres run — repeated here from the start
140
+ via `Rails.application.config.after_initialize`, needed because a running dev process doesn't hot-reload a
141
+ newly-added initializer file the way autoloaded `app/` code does, confirmed live via a real
142
+ `NoMethodError: Table 'uber_direct_delivery_mappings' has not been registered` until the process was
143
+ restarted). Nav positions 77–78 (credential itself at 76 from M1).
144
+
145
+ **Verified against real Uber Direct Sandbox infrastructure end to end, not just specs** — the same
146
+ standard every prior integration in this project was held to:
147
+ - A real order dispatched a real delivery using Uber's **Robo Courier** test feature
148
+ (`test_specifications.robo_courier_specification.mode: "auto"` — the Sandbox equivalent of DoorDash's own
149
+ Delivery Simulator, confirmed via live research, not assumed).
150
+ - Polled the real delivery and watched it progress through Uber's actual lifecycle end to end: `pending` →
151
+ `pickup` → `dropoff` → `delivered`, with a real assigned test courier ("Alex H.") and `complete: true`.
152
+ - Fed that real `delivered` payload through `DeliveryStatusMapper` and confirmed it correctly recorded
153
+ `last_status`/`courier_name`/`courier_phone` from Uber's real response shape.
154
+ - Both new admin pages (`/admin/uber_direct_delivery_mappings`, `/admin/uber_direct_webhook_events`) and the
155
+ credential page confirmed loading against real Postgres data — all three real dispatch attempts from this
156
+ session (including the earlier payload-bug 400) visible in the table.
157
+ - **Not live-tested this milestone**: inbound webhook *receiving* (a live POST from Uber hitting a real
158
+ registered endpoint) — no public tunnel/registered webhook URL was set up in this session. The
159
+ controller's signature verification, idempotency, and job dispatch are covered by real HMAC-signed
160
+ request specs instead; genuinely receiving a live webhook remains open for whenever a tunnel is set up.
161
+ - `shipment.ship!` itself was not re-exercised live in this specific run (the verification order was built
162
+ directly via console rather than walked through Spree's full checkout state machine, so the shipment
163
+ never reached `ready`) — covered instead by a passing unit spec using a `ready`-state shipment.
164
+
165
+ 101 examples, 0 failures across the full suite. Not yet tagged, pushed to a remote, or released to
166
+ RubyGems — kept local-only per explicit instruction.
167
+
168
+ **M6 (spec coverage/docs) and M7 (go-live) not started** — M7 is gated entirely on Uber's own production
169
+ approval (billing info + their review, timeline unstated in their docs), a business/account action only
170
+ the project owner can take.
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,29 @@
1
+ # Contributing
2
+
3
+ Thanks for considering a contribution to `spree_uber_direct`.
4
+
5
+ ## Getting set up
6
+
7
+ ```bash
8
+ bundle install
9
+ bundle exec rake test_app # generates spec/dummy, the Rails app specs run against
10
+ bundle exec rspec
11
+ ```
12
+
13
+ ## Making a change
14
+
15
+ 1. Open an issue first for anything beyond a small fix, so the approach can be discussed before
16
+ you put time into it.
17
+ 2. Add or update specs alongside any behavior change — `bundle exec rspec` should stay green.
18
+ 3. Keep decorators as a last resort (see the main README's customization pattern order); prefer
19
+ Spree's Events/Subscribers or Dependencies mechanisms where they fit.
20
+ 4. Open a pull request describing what changed and why.
21
+
22
+ ## Releasing (maintainers)
23
+
24
+ ```bash
25
+ bundle exec gem bump --version [major|minor|patch] -t -m "Release v%s"
26
+ bundle exec gem release
27
+ ```
28
+
29
+ See the [gem-release README](https://github.com/svenfuchs/gem-release) for more options.
data/Gemfile ADDED
@@ -0,0 +1,27 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'rails-controller-testing'
4
+
5
+ # Pinned to the released 5.6.x line (matching spree_host), same rationale as
6
+ # spree_square's and spree_doordash's Gemfiles.
7
+ spree_opts = if ENV['SPREE_PATH']
8
+ { 'path': ENV['SPREE_PATH'] }
9
+ else
10
+ '~> 5.6.0'
11
+ end
12
+ gem 'spree', spree_opts
13
+ gem 'spree_admin', spree_opts
14
+
15
+ gem 'spree_dev_tools', '>= 0.6.0.rc1'
16
+
17
+ if ENV['DB'] == 'mysql'
18
+ gem 'mysql2'
19
+ elsif ENV['DB'] == 'postgres'
20
+ gem 'pg'
21
+ else
22
+ gem 'sqlite3'
23
+ end
24
+
25
+ gem 'propshaft'
26
+
27
+ gemspec
data/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amit Solanki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # SpreeUberDirect
2
+
3
+ Dispatches completed [Spree Commerce](https://spreecommerce.org) orders as [Uber Direct](https://developer.uber.com/docs/deliveries/overview)
4
+ deliveries — live delivery-fee quoting during checkout, dispatch on order completion, and delivery
5
+ status synced back via webhooks.
6
+
7
+ A second, independent delivery provider alongside [`spree_doordash`](https://github.com/amitkssolanki/spree_doordash)
8
+ — both extensions add their own `Spree::ShippingMethod`, so a store can offer either, both, or
9
+ neither, with zero coupling between them.
10
+
11
+ ## Status
12
+
13
+ M1–M5 built and verified against a real Uber Direct Sandbox account end to end — real OAuth token,
14
+ real quotes, a real dispatched delivery driven through Uber's "Robo Courier" test simulator from
15
+ `pending` through `delivered`, both admin pages confirmed loading against real Postgres data. See
16
+ [CHANGELOG.md](CHANGELOG.md) for the full milestone-by-milestone verification detail, including what
17
+ was *not* live-tested (inbound webhook receiving — no public tunnel was set up). M6 (final spec
18
+ polish/docs) and M7 (production go-live, gated on Uber's own approval) haven't started. Not yet
19
+ tagged, pushed to a remote, or released.
20
+
21
+ ## Installation
22
+
23
+ Add to your Gemfile:
24
+
25
+ ```ruby
26
+ gem 'spree_uber_direct', git: 'https://github.com/amitkssolanki/spree_uber_direct.git', tag: 'v0.1.0'
27
+ ```
28
+
29
+ Then:
30
+
31
+ ```bash
32
+ bundle install
33
+ bin/rails spree_uber_direct:install:migrations
34
+ bin/rails db:migrate
35
+ ```
36
+
37
+ ## Setup
38
+
39
+ 1. Create a Direct account at [direct.uber.com](https://direct.uber.com) and log in with your Uber
40
+ account. Sandbox credentials (Client ID, Client Secret, Customer ID) are available immediately
41
+ under the Developer tab in Management — no approval needed for sandbox.
42
+ 2. In your Spree admin, go to **Uber Direct Connection** and paste the three sandbox values.
43
+ 3. Register a webhook endpoint in the Direct dashboard pointing at
44
+ `https://your-store.example.com/spree_uber_direct/webhooks/uber_direct`, then copy its signing
45
+ key into the same admin form.
46
+
47
+ Production access requires providing billing information and Uber's approval — see
48
+ [developer.uber.com/docs/deliveries/get-started](https://developer.uber.com/docs/deliveries/get-started).
49
+ No fixed timeline is published for that approval; sandbox has none of that restriction.
50
+
51
+ ## Environment variables (alternative to the admin form, useful for scripting/rake tasks)
52
+
53
+ ```
54
+ UBER_DIRECT_CLIENT_ID=
55
+ UBER_DIRECT_CLIENT_SECRET=
56
+ UBER_DIRECT_CUSTOMER_ID=
57
+ UBER_DIRECT_ENVIRONMENT=sandbox
58
+ ```
59
+
60
+ ## Testing
61
+
62
+ ```bash
63
+ bundle install
64
+ bundle exec rake test_app
65
+ bundle exec rspec
66
+ ```
67
+
68
+ ## Contributing
69
+
70
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
71
+
72
+ ## License
73
+
74
+ MIT — see [LICENSE.md](LICENSE.md).
data/Rakefile ADDED
@@ -0,0 +1,23 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rspec/core/rake_task'
5
+ require 'spree/testing_support/extension_rake'
6
+
7
+ RSpec::Core::RakeTask.new
8
+
9
+ task :default do
10
+ if Dir['spec/dummy'].empty?
11
+ Rake::Task[:test_app].invoke
12
+ Dir.chdir('../../')
13
+ end
14
+ Rake::Task[:spec].invoke
15
+ end
16
+
17
+ desc 'Generates a dummy app for testing'
18
+ task :test_app do
19
+ ENV['LIB_NAME'] = 'spree_uber_direct'
20
+ Rake::Task['extension:test_app'].execute(
21
+ install_admin: true
22
+ )
23
+ end
@@ -0,0 +1,52 @@
1
+ module Spree
2
+ module Admin
3
+ # Plain credential-entry form for the current store's Uber Direct
4
+ # sandbox/production OAuth application — not a "Connect" redirect dance.
5
+ # An admin creates the application in the Direct dashboard
6
+ # (direct.uber.com) once and pastes the values in here directly
7
+ # (encrypted at rest — see SpreeUberDirect::Credential). Same static-form
8
+ # convention as spree_doordash's own credentials controller, even though
9
+ # the underlying auth is OAuth2 under the hood (client_credentials, not
10
+ # an authorization-code redirect).
11
+ class UberDirectCredentialsController < Spree::Admin::BaseController
12
+ def show
13
+ @credential = SpreeUberDirect::Credential.find_or_initialize_by(store: current_store)
14
+ end
15
+
16
+ def update
17
+ @credential = SpreeUberDirect::Credential.find_or_initialize_by(store: current_store)
18
+
19
+ if @credential.update(credential_params)
20
+ flash[:success] = Spree.t(:uber_direct_credential_saved, default: 'Uber Direct credentials saved.')
21
+ else
22
+ flash[:error] = @credential.errors.full_messages.to_sentence
23
+ end
24
+
25
+ redirect_to admin_uber_direct_credential_path
26
+ end
27
+
28
+ private
29
+
30
+ # Blank secret fields mean "leave unchanged" (the form always renders
31
+ # them empty and never echoes the current value back) — submitting an
32
+ # actually-blank value would otherwise silently overwrite a working
33
+ # credential with an empty string on every save. Also clears the
34
+ # cached access token whenever client_id/client_secret actually
35
+ # change, so a rotated secret takes effect on the very next API call
36
+ # instead of failing against the old token until it naturally expires.
37
+ def credential_params
38
+ permitted = params.require(:spree_uber_direct_credential).permit(
39
+ :client_id, :client_secret, :customer_id, :webhook_signing_secret, :uber_environment
40
+ )
41
+ %i[client_secret webhook_signing_secret].each do |field|
42
+ permitted.delete(field) if permitted[field].blank?
43
+ end
44
+ if permitted[:client_id].present? || permitted[:client_secret].present?
45
+ permitted[:access_token] = nil
46
+ permitted[:access_token_expires_at] = nil
47
+ end
48
+ permitted
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,12 @@
1
+ module Spree
2
+ module Admin
3
+ # Read-only support/diagnostic view — no create/edit/destroy, this is
4
+ # visibility into what spree_uber_direct has already done, not a place
5
+ # to change it. See config/routes.rb (only: [:index]).
6
+ class UberDirectDeliveryMappingsController < ResourceController
7
+ def model_class
8
+ SpreeUberDirect::DeliveryMapping
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ module Spree
2
+ module Admin
3
+ # Read-only support/diagnostic view — same shape as UberDirectDeliveryMappingsController.
4
+ class UberDirectWebhookEventsController < ResourceController
5
+ def model_class
6
+ SpreeUberDirect::WebhookEvent
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,112 @@
1
+ module SpreeUberDirect
2
+ # Receives Uber Direct webhook notifications. HMAC-verified (see
3
+ # SpreeUberDirect::WebhookVerifier) rather than DoorDash's Basic-Auth
4
+ # string-echo. Does the least possible work synchronously: verify,
5
+ # record, ack, hand off to a job — same shape as every sibling
6
+ # WebhooksController in this codebase.
7
+ class WebhooksController < ActionController::Base
8
+ # Explicit, not just `skip_before_action :verify_authenticity_token` —
9
+ # this controller doesn't inherit the host app's ApplicationController,
10
+ # so a static analyzer (Brakeman) correctly flags it as never actually
11
+ # configured either way — same precedent as spree_doordash's and
12
+ # spree_square's own WebhooksController.
13
+ protect_from_forgery with: :null_session
14
+
15
+ def create
16
+ credential = SpreeUberDirect::Credential.find_by(store: Spree::Store.default)
17
+ raw_body = request.raw_post
18
+
19
+ unless SpreeUberDirect::WebhookVerifier.valid?(
20
+ signature_header: request.headers['x-uber-signature'],
21
+ raw_body: raw_body,
22
+ signing_secret: credential&.webhook_signing_secret
23
+ )
24
+ Rails.logger.warn('[SpreeUberDirect] webhook signature verification failed')
25
+ return head :unauthorized
26
+ end
27
+
28
+ payload = JSON.parse(raw_body)
29
+
30
+ # Uber's dashboard lets a webhook subscribe to three event kinds on
31
+ # this one endpoint: `event.delivery_status` (the only one with a
32
+ # `status` field — confirmed live), `event.courier_update` (a
33
+ # courier GPS ping fired every 20s once a courier is assigned — no
34
+ # `status` field, confirmed live), and `event.refund_request` (fired
35
+ # when a refund is requested — no `status` field either, confirmed
36
+ # directly against Uber's own webhook payload docs). Unlike a
37
+ # courier ping, a refund notification carries real, unrecoverable
38
+ # data (data.id, currency_code, total_partner_refund,
39
+ # total_uber_refund, refund_fees, refund_order_items) — worth a
40
+ # durable record even with no consumer yet, so it's persisted to its
41
+ # own RefundEvent table before falling into the generic
42
+ # blank-status drop below.
43
+ if payload['kind'] == 'event.refund_request'
44
+ find_or_log_refund_event(raw_body, payload)
45
+ return head :ok
46
+ end
47
+
48
+ # WebhookEvent's `status` column is specifically Uber's *delivery*
49
+ # status (see its own model comment) and requires presence, so
50
+ # acknowledge and drop anything else that doesn't carry one rather
51
+ # than letting it fail validation and surface as a 404 to Uber's
52
+ # webhook delivery system — a courier-location ping needs no
53
+ # processing from this extension today.
54
+ if payload['status'].blank?
55
+ Rails.logger.debug { "[SpreeUberDirect] dropping webhook with no status (kind=#{payload['kind']}, id=#{payload['id']})" }
56
+ return head :ok
57
+ end
58
+
59
+ event = find_or_log_event(raw_body, payload)
60
+ SpreeUberDirect::DeliveryWebhookJob.perform_later(event.id) if event.previously_new_record?
61
+
62
+ head :ok
63
+ rescue JSON::ParserError
64
+ head :bad_request
65
+ end
66
+
67
+ private
68
+
69
+ def find_or_log_event(raw_body, payload)
70
+ SpreeUberDirect::WebhookEvent.find_or_create_by!(
71
+ delivery_id: payload['delivery_id'],
72
+ status: payload['status'],
73
+ payload_digest: SpreeUberDirect::WebhookEvent.digest(raw_body)
74
+ ) do |event|
75
+ event.payload = payload
76
+ end
77
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
78
+ # Lost a race with a concurrent duplicate delivery — the row exists
79
+ # now either way, and it's already being (or has been) processed once.
80
+ SpreeUberDirect::WebhookEvent.find_by!(
81
+ delivery_id: payload['delivery_id'],
82
+ status: payload['status'],
83
+ payload_digest: SpreeUberDirect::WebhookEvent.digest(raw_body)
84
+ )
85
+ end
86
+
87
+ def find_or_log_refund_event(raw_body, payload)
88
+ digest = SpreeUberDirect::RefundEvent.digest(raw_body)
89
+
90
+ SpreeUberDirect::RefundEvent.find_or_create_by!(
91
+ delivery_id: payload['delivery_id'],
92
+ payload_digest: digest
93
+ ) do |event|
94
+ event.payload = payload
95
+ end
96
+ rescue ActiveRecord::RecordNotUnique
97
+ # Lost a race with a concurrent duplicate delivery — the row exists
98
+ # now either way.
99
+ SpreeUberDirect::RefundEvent.find_by(delivery_id: payload['delivery_id'], payload_digest: digest)
100
+ rescue ActiveRecord::RecordInvalid => e
101
+ # A payload we genuinely can't persist (e.g. no delivery_id at all)
102
+ # — log loudly so the drop stays traceable, but still ack. A
103
+ # refund notification that can't even be stored shouldn't turn
104
+ # into a 4xx/5xx retry storm against Uber's webhook delivery
105
+ # system; Uber would just keep resending the identical payload.
106
+ Rails.logger.error(
107
+ "[SpreeUberDirect] failed to persist refund_request webhook: #{e.message} (id=#{payload['id']})"
108
+ )
109
+ nil
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,5 @@
1
+ module SpreeUberDirect
2
+ class BaseJob < Spree::BaseJob
3
+ queue_as SpreeUberDirect.queue
4
+ end
5
+ end
@@ -0,0 +1,21 @@
1
+ module SpreeUberDirect
2
+ # A failed dispatch is the worst failure mode in this whole extension —
3
+ # payment already taken, kitchen already has the ticket (spree_square's
4
+ # own push is independent), but no one is actually coming to pick it up.
5
+ # Same retry/dead-letter/Alerting shape as SpreeDoordash::DeliveryDispatchJob.
6
+ class DeliveryDispatchJob < BaseJob
7
+ retry_on StandardError, wait: :polynomially_longer, attempts: 5 do |job, error|
8
+ order = Spree::Order.find_by(id: job.arguments.first)
9
+ SpreeUberDirect::DeliveryMapping.find_or_initialize_by(order: order).mark_failed!(error) if order
10
+ SpreeUberDirect::Alerting.capture(
11
+ error,
12
+ context: { area: 'delivery_dispatch', order_number: order&.number }
13
+ )
14
+ end
15
+
16
+ def perform(order_id)
17
+ order = Spree::Order.find(order_id)
18
+ SpreeUberDirect::DeliveryDispatcher.call(order)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ module SpreeUberDirect
2
+ # Applies one already-verified, already-deduplicated Uber Direct webhook
3
+ # event to the order it's mapped to. Same retry/dead-letter/Alerting
4
+ # shape as every other webhook-handling job in this codebase.
5
+ class DeliveryWebhookJob < BaseJob
6
+ retry_on StandardError, wait: :polynomially_longer, attempts: 5 do |job, error|
7
+ SpreeUberDirect::WebhookEvent.find_by(id: job.arguments.first)&.mark_failed!(error)
8
+ SpreeUberDirect::Alerting.capture(error, context: 'delivery_webhook')
9
+ end
10
+
11
+ def perform(webhook_event_id)
12
+ event = SpreeUberDirect::WebhookEvent.find(webhook_event_id)
13
+ SpreeUberDirect::DeliveryStatusMapper.call(event.payload)
14
+ event.mark_processed!
15
+ end
16
+ end
17
+ end