spree_uber_direct 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.env +7 -0
- data/.gitignore +27 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +170 -0
- data/CONTRIBUTING.md +29 -0
- data/Gemfile +27 -0
- data/LICENSE.md +9 -0
- data/README.md +74 -0
- data/Rakefile +23 -0
- data/app/controllers/spree/admin/uber_direct_credentials_controller.rb +52 -0
- data/app/controllers/spree/admin/uber_direct_delivery_mappings_controller.rb +12 -0
- data/app/controllers/spree/admin/uber_direct_webhook_events_controller.rb +10 -0
- data/app/controllers/spree_uber_direct/webhooks_controller.rb +112 -0
- data/app/jobs/spree_uber_direct/base_job.rb +5 -0
- data/app/jobs/spree_uber_direct/delivery_dispatch_job.rb +21 -0
- data/app/jobs/spree_uber_direct/delivery_webhook_job.rb +17 -0
- data/app/models/spree/calculator/shipping/uber_direct_quote.rb +62 -0
- data/app/models/spree_uber_direct/credential.rb +33 -0
- data/app/models/spree_uber_direct/delivery_mapping.rb +27 -0
- data/app/models/spree_uber_direct/order_decorator.rb +42 -0
- data/app/models/spree_uber_direct/quote_mapping.rb +25 -0
- data/app/models/spree_uber_direct/refund_event.rb +34 -0
- data/app/models/spree_uber_direct/webhook_event.rb +34 -0
- data/app/services/spree_uber_direct/address_payload.rb +35 -0
- data/app/services/spree_uber_direct/alerting.rb +18 -0
- data/app/services/spree_uber_direct/client.rb +155 -0
- data/app/services/spree_uber_direct/delivery_dispatcher.rb +118 -0
- data/app/services/spree_uber_direct/delivery_status_mapper.rb +73 -0
- data/app/services/spree_uber_direct/quote.rb +97 -0
- data/app/services/spree_uber_direct/webhook_verifier.rb +17 -0
- data/app/subscribers/spree_uber_direct/order_completed_subscriber.rb +30 -0
- data/app/views/spree/admin/uber_direct_credentials/show.html.erb +55 -0
- data/app/views/spree/admin/uber_direct_delivery_mappings/index.html.erb +5 -0
- data/app/views/spree/admin/uber_direct_webhook_events/index.html.erb +5 -0
- data/config/initializers/spree.rb +14 -0
- data/config/initializers/spree_admin_uber_direct_navigation.rb +30 -0
- data/config/initializers/spree_admin_uber_direct_tables.rb +101 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20260821000001_create_spree_uber_direct_credentials.rb +36 -0
- data/db/migrate/20260821030001_create_spree_uber_direct_quote_mappings.rb +48 -0
- data/db/migrate/20260821040001_create_spree_uber_direct_delivery_mappings.rb +42 -0
- data/db/migrate/20260821040002_create_spree_uber_direct_webhook_events.rb +37 -0
- data/db/migrate/20260823010001_create_spree_uber_direct_refund_events.rb +39 -0
- data/lib/spree_uber_direct/configuration.rb +8 -0
- data/lib/spree_uber_direct/engine.rb +45 -0
- data/lib/spree_uber_direct/factories.rb +37 -0
- data/lib/spree_uber_direct/version.rb +7 -0
- data/lib/spree_uber_direct.rb +12 -0
- data/lib/tasks/spree_uber_direct.rake +30 -0
- data/spree_uber_direct.gemspec +51 -0
- metadata +182 -0
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
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,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,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
|