spree_doordash 0.1.0
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/.github/workflows/test.yml +107 -0
- data/.gitignore +27 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +54 -0
- data/CONTRIBUTING.md +29 -0
- data/Gemfile +27 -0
- data/LICENSE.md +9 -0
- data/README.md +109 -0
- data/Rakefile +23 -0
- data/app/controllers/spree/admin/doordash_credentials_controller.rb +42 -0
- data/app/controllers/spree/admin/doordash_delivery_mappings_controller.rb +12 -0
- data/app/controllers/spree/admin/doordash_webhook_events_controller.rb +11 -0
- data/app/controllers/spree_doordash/webhooks_controller.rb +60 -0
- data/app/jobs/spree_doordash/base_job.rb +5 -0
- data/app/jobs/spree_doordash/delivery_dispatch_job.rb +22 -0
- data/app/jobs/spree_doordash/delivery_webhook_job.rb +17 -0
- data/app/models/spree/calculator/shipping/doordash_quote.rb +40 -0
- data/app/models/spree_doordash/credential.rb +23 -0
- data/app/models/spree_doordash/delivery_mapping.rb +27 -0
- data/app/models/spree_doordash/location_mapping.rb +16 -0
- data/app/models/spree_doordash/quote_mapping.rb +20 -0
- data/app/models/spree_doordash/webhook_event.rb +36 -0
- data/app/services/spree_doordash/alerting.rb +19 -0
- data/app/services/spree_doordash/client.rb +122 -0
- data/app/services/spree_doordash/delivery_dispatcher.rb +54 -0
- data/app/services/spree_doordash/delivery_status_mapper.rb +74 -0
- data/app/services/spree_doordash/quote.rb +118 -0
- data/app/services/spree_doordash/webhook_verifier.rb +17 -0
- data/app/subscribers/spree_doordash/order_completed_subscriber.rb +29 -0
- data/app/views/spree/admin/doordash_credentials/show.html.erb +55 -0
- data/app/views/spree/admin/doordash_delivery_mappings/index.html.erb +5 -0
- data/app/views/spree/admin/doordash_webhook_events/index.html.erb +5 -0
- data/config/brakeman.ignore +28 -0
- data/config/initializers/spree_admin_doordash_navigation.rb +27 -0
- data/config/initializers/spree_admin_doordash_tables.rb +120 -0
- data/config/initializers/spree_doordash_calculators.rb +13 -0
- data/config/routes.rb +25 -0
- data/db/migrate/20260813000001_create_spree_doordash_credentials.rb +28 -0
- data/db/migrate/20260813000002_create_spree_doordash_location_mappings.rb +22 -0
- data/db/migrate/20260813000003_create_spree_doordash_quote_mappings.rb +47 -0
- data/db/migrate/20260813180001_create_spree_doordash_delivery_mappings.rb +47 -0
- data/db/migrate/20260813180002_create_spree_doordash_webhook_events.rb +46 -0
- data/lib/generators/spree_doordash/install/install_generator.rb +20 -0
- data/lib/spree_doordash/configuration.rb +8 -0
- data/lib/spree_doordash/engine.rb +44 -0
- data/lib/spree_doordash/factories.rb +47 -0
- data/lib/spree_doordash/version.rb +7 -0
- data/lib/spree_doordash.rb +12 -0
- data/lib/tasks/spree_doordash.rake +94 -0
- data/spree_doordash.gemspec +48 -0
- metadata +182 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ba867fc23a044f6166e7fcb61f1641a9f3bd79ddc7c08cf1808abf20664defee
|
|
4
|
+
data.tar.gz: c3dba97527799999bcbd5cc9881b936c368a1ff819f89e4bc8cf568e7513f568
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9eccedd59afafb3bc174d769deb3cd47aa6511ca0ec4f6aec3989adab82c148928c74ba0c4416096996d1fa032f49534f1a22736292e670bcacf7ee33a1c090a
|
|
7
|
+
data.tar.gz: f5bb35207b1cfba4544b47c459c56936eb69b163c3deb3f9fd4f5322ad043b6f36b5b7a220d306037dcffa5d68967b39fac1b2bd0bc558715a41ed78aa1da034
|
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 (SpreeDoordash::Credential) so specs can save/load encrypted columns;
|
|
4
|
+
# generated with SecureRandom, no real secret behind them.
|
|
5
|
+
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=CqoBmPrrhhnpIAZcvMblJed9DIzgJKP3
|
|
6
|
+
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=7xq5LaBbvEpGYbdHmtQBIItfWBjHKxgu
|
|
7
|
+
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=AL6p4aeV1OzPeqTVpQPA8nQ4TVRA6QYH
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
test-postgres:
|
|
17
|
+
name: "PostgreSQL"
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
services:
|
|
20
|
+
postgres:
|
|
21
|
+
image: postgres:16
|
|
22
|
+
env:
|
|
23
|
+
POSTGRES_USER: postgres
|
|
24
|
+
POSTGRES_PASSWORD: postgres
|
|
25
|
+
ports:
|
|
26
|
+
- 5432:5432
|
|
27
|
+
options: >-
|
|
28
|
+
--health-cmd pg_isready
|
|
29
|
+
--health-interval 10s
|
|
30
|
+
--health-timeout 5s
|
|
31
|
+
--health-retries 5
|
|
32
|
+
env:
|
|
33
|
+
DB: postgres
|
|
34
|
+
DB_HOST: localhost
|
|
35
|
+
DB_USERNAME: postgres
|
|
36
|
+
DB_PASSWORD: postgres
|
|
37
|
+
BUNDLE_JOBS: 4
|
|
38
|
+
BUNDLE_RETRY: 3
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@v4
|
|
41
|
+
|
|
42
|
+
- uses: ruby/setup-ruby@v1
|
|
43
|
+
with:
|
|
44
|
+
ruby-version: '3.3'
|
|
45
|
+
bundler-cache: true
|
|
46
|
+
|
|
47
|
+
- name: Install libvips
|
|
48
|
+
run: sudo apt-get update && sudo apt-get install -y libvips-dev
|
|
49
|
+
|
|
50
|
+
- name: Create test app
|
|
51
|
+
run: bundle exec rake test_app
|
|
52
|
+
|
|
53
|
+
- name: Run tests
|
|
54
|
+
run: bundle exec rspec --format documentation
|
|
55
|
+
|
|
56
|
+
test-mysql:
|
|
57
|
+
name: "MySQL"
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
services:
|
|
60
|
+
mysql:
|
|
61
|
+
image: mysql:8.0
|
|
62
|
+
env:
|
|
63
|
+
MYSQL_ROOT_PASSWORD: password
|
|
64
|
+
ports:
|
|
65
|
+
- 3306:3306
|
|
66
|
+
options: >-
|
|
67
|
+
--health-cmd="mysqladmin ping"
|
|
68
|
+
--health-interval 10s
|
|
69
|
+
--health-timeout 5s
|
|
70
|
+
--health-retries 5
|
|
71
|
+
env:
|
|
72
|
+
DB: mysql
|
|
73
|
+
DB_HOST: 127.0.0.1
|
|
74
|
+
DB_USERNAME: root
|
|
75
|
+
DB_PASSWORD: password
|
|
76
|
+
BUNDLE_JOBS: 4
|
|
77
|
+
BUNDLE_RETRY: 3
|
|
78
|
+
steps:
|
|
79
|
+
- uses: actions/checkout@v4
|
|
80
|
+
|
|
81
|
+
- uses: ruby/setup-ruby@v1
|
|
82
|
+
with:
|
|
83
|
+
ruby-version: '3.3'
|
|
84
|
+
bundler-cache: true
|
|
85
|
+
|
|
86
|
+
- name: Install libvips
|
|
87
|
+
run: sudo apt-get update && sudo apt-get install -y libvips-dev
|
|
88
|
+
|
|
89
|
+
- name: Create test app
|
|
90
|
+
run: bundle exec rake test_app
|
|
91
|
+
|
|
92
|
+
- name: Run tests
|
|
93
|
+
run: bundle exec rspec --format documentation
|
|
94
|
+
|
|
95
|
+
brakeman:
|
|
96
|
+
name: Brakeman
|
|
97
|
+
runs-on: ubuntu-latest
|
|
98
|
+
steps:
|
|
99
|
+
- uses: actions/checkout@v4
|
|
100
|
+
|
|
101
|
+
- uses: ruby/setup-ruby@v1
|
|
102
|
+
with:
|
|
103
|
+
ruby-version: '3.3'
|
|
104
|
+
bundler-cache: true
|
|
105
|
+
|
|
106
|
+
- name: Run Brakeman
|
|
107
|
+
run: bundle exec brakeman --exit-on-warn --exit-on-error
|
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 DoorDash 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,54 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
## 0.1.0 (unreleased)
|
|
6
|
+
|
|
7
|
+
Initial development. M1 (foundation) — complete and verified against a real DoorDash Sandbox
|
|
8
|
+
endpoint (`bin/rails spree_doordash:verify_connection`, a real accepted quote):
|
|
9
|
+
`SpreeDoordash::Credential` (encrypted per-store DoorDash Drive access key),
|
|
10
|
+
`SpreeDoordash::Client` (JWT signing against the Drive v2 API), and a plain credential-entry
|
|
11
|
+
admin form.
|
|
12
|
+
|
|
13
|
+
Two real JWT-signing bugs only surfaced by that live verification, not by DoorDash's own docs or
|
|
14
|
+
sample code:
|
|
15
|
+
- The signing secret must be **base64url**-decoded, not standard base64.
|
|
16
|
+
- The JWT header needs an explicit `typ: 'JWT'` field — the `jwt` gem doesn't add it
|
|
17
|
+
automatically the way Node's `jsonwebtoken` (what DoorDash's own sample code uses) does.
|
|
18
|
+
|
|
19
|
+
M2 — `SpreeDoordash::LocationMapping` (stock location ↔ DoorDash store), `SpreeDoordash::Quote`
|
|
20
|
+
(builds and persists a live delivery-fee quote for an order). Found and fixed a real ActiveRecord
|
|
21
|
+
association-caching bug: creating a shipment via `create(:shipment, order:, ...)` (setting the FK
|
|
22
|
+
directly) doesn't invalidate an already-loaded `order.shipments` association — fixed by querying
|
|
23
|
+
`Spree::Shipment.where(order_id:)` directly rather than `order.shipments.first`.
|
|
24
|
+
|
|
25
|
+
M3 — `Spree::Calculator::Shipping::DoordashQuote`, wiring a live quote into checkout as an
|
|
26
|
+
ordinary shipping rate; verified end to end against a real order and a real Sandbox quote
|
|
27
|
+
($9.75 for a real DC address). Two real things caught only by that live wiring:
|
|
28
|
+
- `Spree::ShippingMethod.calculators` does **not** auto-discover `ShippingCalculator` subclasses —
|
|
29
|
+
it reads a hardcoded array `spree_core` populates via its own `config.after_initialize`;
|
|
30
|
+
registering the new calculator needed an explicit append in a later-running initializer.
|
|
31
|
+
- Re-quoting the same order (which checkout does on essentially every step) with a stable
|
|
32
|
+
`external_delivery_id` gets rejected by DoorDash's real API with `409 duplicate_delivery_id`,
|
|
33
|
+
even though the prior quote is still open — fixed by appending a random suffix per quote
|
|
34
|
+
attempt.
|
|
35
|
+
|
|
36
|
+
M4 — `SpreeDoordash::DeliveryDispatcher` (accept-or-requote-then-accept), the full webhook chain
|
|
37
|
+
(`WebhooksController`, Basic-Auth `WebhookVerifier`, idempotent `WebhookEvent`,
|
|
38
|
+
`DeliveryStatusMapper`). Verified live via ngrok + DoorDash's Sandbox Delivery Simulator: the full
|
|
39
|
+
`DASHER_CONFIRMED` → … → `DASHER_DROPPED_OFF` event sequence, and a `DELIVERY_CANCELLED` path,
|
|
40
|
+
both against real dispatched orders — confirmed `shipment.ship!` and `order.cancel!` actually
|
|
41
|
+
fire on the real captured payloads. Fixed a `NOT NULL` constraint that broke
|
|
42
|
+
`DeliveryDispatchJob`'s own dead-letter failure recording.
|
|
43
|
+
|
|
44
|
+
M5 — Admin pages (DoorDash Deliveries, DoorDash Webhooks) and a `spree_doordash:map_location`
|
|
45
|
+
rake task. Two real bugs found only by loading the pages against a real Postgres-backed app (the
|
|
46
|
+
SQLite dummy app used for specs has neither problem):
|
|
47
|
+
- Postgres' plain `json` type has no equality operator, and `spree_admin`'s table view issues
|
|
48
|
+
`SELECT DISTINCT` — every `json` column in this gem 500'd its own admin page. Fixed by using
|
|
49
|
+
`jsonb` on Postgres (matching `spree_core`'s own migration convention), plain `json` on SQLite.
|
|
50
|
+
- `Spree.admin.tables.register` defaults to expecting a `:new` route that doesn't exist on a
|
|
51
|
+
read-only resource — 500'd the instant a table was empty. Fixed with `new_resource: false`.
|
|
52
|
+
|
|
53
|
+
⚠️ DoorDash Drive API production access is currently restricted by DoorDash (no committed
|
|
54
|
+
timeline) — this extension targets and is verified against **Sandbox** only until that changes.
|
data/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for considering a contribution to `spree_doordash`.
|
|
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 Gemfile.
|
|
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,109 @@
|
|
|
1
|
+
# Spree DoorDash
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/spree_doordash)
|
|
4
|
+
[](https://github.com/amitkssolanki/spree_doordash/releases)
|
|
5
|
+
[](LICENSE.md)
|
|
6
|
+
|
|
7
|
+
This is a [DoorDash Drive](https://developer.doordash.com) delivery extension for [Spree Commerce](https://spreecommerce.org), an open source e-commerce platform built with Ruby on Rails. It's the companion to [`spree_square`](https://github.com/amitkssolanki/spree_square) — quotes and dispatches DoorDash Drive deliveries for completed Spree orders, keyed off each order's fulfilling location.
|
|
8
|
+
|
|
9
|
+
> ⚠️ **DoorDash Drive API production access is currently restricted by DoorDash**, with no committed timeline — see their [Get Started guide](https://developer.doordash.com/en-US/docs/drive/tutorials/get_started/). Sandbox is fully open, and this extension's entire flow (live quoting, dispatch, webhook-driven status sync, admin diagnostics) is built and verified end to end against Sandbox — including DoorDash's own [Delivery Simulator](https://developer.doordash.com) — without ever needing production approval. Going live requires DoorDash's separate approval on their own timeline.
|
|
10
|
+
|
|
11
|
+
## What this does
|
|
12
|
+
|
|
13
|
+
- **Live delivery-fee quoting at checkout** — `Spree::Calculator::Shipping::DoordashQuote` requests
|
|
14
|
+
a real DoorDash quote and shows it as an ordinary shipping rate, no custom storefront code
|
|
15
|
+
needed.
|
|
16
|
+
- **Dispatch on order completion** — accepts the checkout-time quote (re-quoting first if it's
|
|
17
|
+
expired; DoorDash quotes are only valid 5 minutes) and creates the real delivery.
|
|
18
|
+
- **Delivery status synced back via webhooks** — a Dasher picking up/dropping off/canceling the
|
|
19
|
+
order updates the Spree shipment/order state automatically.
|
|
20
|
+
- **Admin visibility** — DoorDash Deliveries and DoorDash Webhooks pages for support/diagnostics.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
1. Add this extension to your Gemfile with this line:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
bundle add spree_doordash
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
2. Run the install generator
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
bundle exec rails g spree_doordash:install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
3. Restart your server
|
|
37
|
+
|
|
38
|
+
If your server was running, restart it so that it can find the assets properly.
|
|
39
|
+
|
|
40
|
+
## Connecting to DoorDash
|
|
41
|
+
|
|
42
|
+
DoorDash Drive auth has no OAuth/refresh flow — a static access key, created once, signs a fresh
|
|
43
|
+
short-lived JWT on every API call:
|
|
44
|
+
|
|
45
|
+
1. Create a [DoorDash Developer Portal](https://developer.doordash.com) account and create an
|
|
46
|
+
access key (**Credentials** in the left nav). This gives you a `developer_id`, `key_id`, and
|
|
47
|
+
`signing_secret`.
|
|
48
|
+
2. In your Spree admin, go to **DoorDash Connection** in the sidebar and paste the three values
|
|
49
|
+
in. They're encrypted at rest ([ActiveRecord::Encryption](https://guides.rubyonrails.org/active_record_encryption.html)) —
|
|
50
|
+
same mechanism `spree_square` uses for its own credentials, sharing the same
|
|
51
|
+
`ACTIVE_RECORD_ENCRYPTION_*` keys.
|
|
52
|
+
3. Configure a webhook endpoint for delivery status updates in the Developer Portal's
|
|
53
|
+
**Webhooks** page (Sandbox and Production each support one endpoint), protected with Basic
|
|
54
|
+
Auth. Paste that exact `Authorization` header value into the **Webhook Basic Auth value**
|
|
55
|
+
field on the same connection page — it's a static string DoorDash echoes back verbatim on
|
|
56
|
+
every webhook, not a credential DoorDash itself authenticates.
|
|
57
|
+
4. Point the webhook URL at `POST /spree_doordash/webhooks/doordash` on your store (needs to be
|
|
58
|
+
internet-reachable — `ngrok`/`cloudflared` for local dev).
|
|
59
|
+
|
|
60
|
+
Access keys created before requesting production access are Sandbox-only — you can build and
|
|
61
|
+
fully test this extension's entire flow (quotes, dispatch, status webhooks, DoorDash's own
|
|
62
|
+
[Delivery Simulator](https://developer.doordash.com)) without ever needing production approval.
|
|
63
|
+
|
|
64
|
+
## Setting up a DoorDash Delivery shipping method
|
|
65
|
+
|
|
66
|
+
1. Map each fulfilling stock location to a DoorDash store:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
bin/rails "spree_doordash:map_location[stock_location_id_or_name,doordash_store_id,doordash_business_id]"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`doordash_business_id` defaults to `"default"` — DoorDash Sandbox auto-assigns a default
|
|
73
|
+
business/store to every access key, so that's normally all you need there.
|
|
74
|
+
|
|
75
|
+
2. In **Settings → Shipping Methods**, create a new shipping method (e.g. "DoorDash Delivery")
|
|
76
|
+
with calculator **DoordashQuote**, attached to whichever zone/shipping category your delivery
|
|
77
|
+
orders use. `Spree::Stock::Estimator` picks it up automatically — no further wiring needed;
|
|
78
|
+
any code path that estimates shipping rates for an order (checkout, admin, API) will now
|
|
79
|
+
request a live quote for that rate whenever the order has a ship address.
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bundle install
|
|
85
|
+
bundle exec rake test_app # generates spec/dummy
|
|
86
|
+
bundle exec rspec
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
When testing your application's integration with this extension you may use its factories.
|
|
90
|
+
Simply add this require statement to your spec_helper:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
require 'spree_doordash/factories'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Releasing a new version
|
|
97
|
+
|
|
98
|
+
```shell
|
|
99
|
+
bundle exec gem bump -p -t
|
|
100
|
+
bundle exec gem release
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
For more options please see [gem-release README](https://github.com/svenfuchs/gem-release)
|
|
104
|
+
|
|
105
|
+
## Contributing
|
|
106
|
+
|
|
107
|
+
If you'd like to contribute, please take a look at the
|
|
108
|
+
[instructions](CONTRIBUTING.md) for installing dependencies and crafting a good
|
|
109
|
+
pull request.
|
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_doordash'
|
|
20
|
+
Rake::Task['extension:test_app'].execute(
|
|
21
|
+
install_admin: true
|
|
22
|
+
)
|
|
23
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
# Plain credential-entry form for the current store's DoorDash Drive
|
|
4
|
+
# access key — not an OAuth connect/disconnect flow like Square's.
|
|
5
|
+
# DoorDash Drive auth has no redirect dance: an admin creates an access
|
|
6
|
+
# key once in DoorDash's Developer Portal and pastes the three values in
|
|
7
|
+
# here directly (encrypted at rest — see SpreeDoordash::Credential).
|
|
8
|
+
class DoordashCredentialsController < Spree::Admin::BaseController
|
|
9
|
+
def show
|
|
10
|
+
@credential = SpreeDoordash::Credential.find_or_initialize_by(store: current_store)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update
|
|
14
|
+
@credential = SpreeDoordash::Credential.find_or_initialize_by(store: current_store)
|
|
15
|
+
|
|
16
|
+
if @credential.update(credential_params)
|
|
17
|
+
flash[:success] = Spree.t(:doordash_credential_saved, default: 'DoorDash credentials saved.')
|
|
18
|
+
else
|
|
19
|
+
flash[:error] = @credential.errors.full_messages.to_sentence
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
redirect_to admin_doordash_credential_path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Blank secret fields mean "leave unchanged" (the form always renders
|
|
28
|
+
# them empty and never echoes the current value back) — submitting an
|
|
29
|
+
# actually-blank value would otherwise silently overwrite a working
|
|
30
|
+
# credential with an empty string on every save.
|
|
31
|
+
def credential_params
|
|
32
|
+
permitted = params.require(:spree_doordash_credential).permit(
|
|
33
|
+
:developer_id, :key_id, :signing_secret, :webhook_basic_auth_token, :doordash_environment
|
|
34
|
+
)
|
|
35
|
+
%i[signing_secret webhook_basic_auth_token].each do |field|
|
|
36
|
+
permitted.delete(field) if permitted[field].blank?
|
|
37
|
+
end
|
|
38
|
+
permitted
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
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_doordash has already dispatched, not a
|
|
5
|
+
# place to change it. See config/routes.rb (only: [:index]).
|
|
6
|
+
class DoordashDeliveryMappingsController < ResourceController
|
|
7
|
+
def model_class
|
|
8
|
+
SpreeDoordash::DeliveryMapping
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
# Read-only support/diagnostic view of every inbound DoorDash webhook —
|
|
4
|
+
# what arrived, whether it processed, and the error if it didn't.
|
|
5
|
+
class DoordashWebhookEventsController < ResourceController
|
|
6
|
+
def model_class
|
|
7
|
+
SpreeDoordash::WebhookEvent
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# Receives DoorDash Drive webhook notifications. Basic-Auth-verified
|
|
3
|
+
# instead of Square's HMAC signature (see SpreeDoordash::WebhookVerifier)
|
|
4
|
+
# and, like SpreeSquare::WebhooksController, deliberately does the least
|
|
5
|
+
# possible work synchronously: verify, record, ack, hand off to a job.
|
|
6
|
+
# DoorDash "sends each webhook event up to 3 times" on anything other
|
|
7
|
+
# than 200 (its own docs), so a slow or non-2xx response means retries.
|
|
8
|
+
class WebhooksController < ActionController::Base
|
|
9
|
+
# Explicit, not just `skip_before_action :verify_authenticity_token` —
|
|
10
|
+
# this controller doesn't inherit the host app's ApplicationController
|
|
11
|
+
# (where `protect_from_forgery` normally gets declared), so a static
|
|
12
|
+
# analyzer (Brakeman) correctly flags it as never actually configured
|
|
13
|
+
# either way — confirmed by the identical warning on spree_square's own
|
|
14
|
+
# WebhooksController. `:null_session` degrades a forged/missing token
|
|
15
|
+
# to an empty session instead of raising — appropriate here since this
|
|
16
|
+
# endpoint is Basic-Auth-verified, not session-authenticated.
|
|
17
|
+
protect_from_forgery with: :null_session
|
|
18
|
+
|
|
19
|
+
def create
|
|
20
|
+
credential = SpreeDoordash::Credential.find_by(store: Spree::Store.default)
|
|
21
|
+
|
|
22
|
+
unless SpreeDoordash::WebhookVerifier.valid?(
|
|
23
|
+
authorization_header: request.headers['Authorization'],
|
|
24
|
+
expected_token: credential&.webhook_basic_auth_token
|
|
25
|
+
)
|
|
26
|
+
Rails.logger.warn('[SpreeDoordash] webhook auth verification failed')
|
|
27
|
+
return head :unauthorized
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
raw_body = request.raw_post
|
|
31
|
+
payload = JSON.parse(raw_body)
|
|
32
|
+
event = find_or_log_event(raw_body, payload)
|
|
33
|
+
SpreeDoordash::DeliveryWebhookJob.perform_later(event.id) if event.previously_new_record?
|
|
34
|
+
|
|
35
|
+
head :ok
|
|
36
|
+
rescue JSON::ParserError
|
|
37
|
+
head :bad_request
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def find_or_log_event(raw_body, payload)
|
|
43
|
+
SpreeDoordash::WebhookEvent.find_or_create_by!(
|
|
44
|
+
external_delivery_id: payload['external_delivery_id'],
|
|
45
|
+
event_name: payload['event_name'],
|
|
46
|
+
payload_digest: SpreeDoordash::WebhookEvent.digest(raw_body)
|
|
47
|
+
) do |event|
|
|
48
|
+
event.payload = payload
|
|
49
|
+
end
|
|
50
|
+
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
|
|
51
|
+
# Lost a race with a concurrent duplicate delivery — the row exists
|
|
52
|
+
# now either way, and it's already being (or has been) processed once.
|
|
53
|
+
SpreeDoordash::WebhookEvent.find_by!(
|
|
54
|
+
external_delivery_id: payload['external_delivery_id'],
|
|
55
|
+
event_name: payload['event_name'],
|
|
56
|
+
payload_digest: SpreeDoordash::WebhookEvent.digest(raw_body)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
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 SpreeSquare::OrderPushJob for
|
|
6
|
+
# exactly that reason.
|
|
7
|
+
class DeliveryDispatchJob < BaseJob
|
|
8
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 5 do |job, error|
|
|
9
|
+
order = Spree::Order.find_by(id: job.arguments.first)
|
|
10
|
+
SpreeDoordash::DeliveryMapping.find_or_initialize_by(order: order).mark_failed!(error) if order
|
|
11
|
+
SpreeDoordash::Alerting.capture(
|
|
12
|
+
error,
|
|
13
|
+
context: { area: 'delivery_dispatch', order_number: order&.number }
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def perform(order_id)
|
|
18
|
+
order = Spree::Order.find(order_id)
|
|
19
|
+
SpreeDoordash::DeliveryDispatcher.call(order)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# Applies one already-verified, already-deduplicated DoorDash 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
|
+
SpreeDoordash::WebhookEvent.find_by(id: job.arguments.first)&.mark_failed!(error)
|
|
8
|
+
SpreeDoordash::Alerting.capture(error, context: 'delivery_webhook')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def perform(webhook_event_id)
|
|
12
|
+
event = SpreeDoordash::WebhookEvent.find(webhook_event_id)
|
|
13
|
+
SpreeDoordash::DeliveryStatusMapper.call(event.payload)
|
|
14
|
+
event.mark_processed!
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require_dependency 'spree/shipping_calculator'
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
# Compact `module Calculator::Shipping` (not separately-nested `module
|
|
5
|
+
# Calculator; module Shipping`) — Spree::Calculator is already a class,
|
|
6
|
+
# not a module, in spree_core; nesting it that way raises `TypeError:
|
|
7
|
+
# Calculator is not a module`. This is the exact form spree_core's own
|
|
8
|
+
# calculators (e.g. Spree::Calculator::Shipping::FlatRate) use.
|
|
9
|
+
module Calculator::Shipping
|
|
10
|
+
# Prices a "DoorDash Delivery" shipping method against a real, live
|
|
11
|
+
# DoorDash Drive quote rather than a flat/configured rate.
|
|
12
|
+
# Spree::Stock::Estimator calls `calculator.compute(package)`, which
|
|
13
|
+
# Spree::Calculator#compute dispatches to `compute_package` by
|
|
14
|
+
# demodulizing the argument's class name (Spree::Stock::Package ->
|
|
15
|
+
# "package") — no override of #compute itself needed.
|
|
16
|
+
class DoordashQuote < ShippingCalculator
|
|
17
|
+
def self.description
|
|
18
|
+
'DoorDash Drive (live quote)'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def compute_package(package)
|
|
22
|
+
result = SpreeDoordash::Quote.call(package.order)
|
|
23
|
+
return nil unless result
|
|
24
|
+
|
|
25
|
+
result.fee_cents / 100.0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Called by Estimator to filter which shipping methods even attempt
|
|
29
|
+
# a compute_package call. Skips the DoorDash API round-trip entirely
|
|
30
|
+
# for an order with no ship address yet (early checkout, before
|
|
31
|
+
# Estimator would sensibly be asked at all) — the real "can DoorDash
|
|
32
|
+
# serve this address" check still happens inside SpreeDoordash::Quote
|
|
33
|
+
# itself (compute_package returning nil is what actually makes the
|
|
34
|
+
# rate not show up).
|
|
35
|
+
def available?(package)
|
|
36
|
+
package.order.ship_address.present?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# A DoorDash Drive access key + webhook secret for one Spree::Store.
|
|
3
|
+
# Unlike SpreeSquare::Credential, this isn't an OAuth connection — DoorDash
|
|
4
|
+
# Drive auth is a static access key (developer_id/key_id/signing_secret),
|
|
5
|
+
# created once in DoorDash's Developer Portal and entered directly here,
|
|
6
|
+
# used to sign a fresh short-lived JWT per API call (see
|
|
7
|
+
# SpreeDoordash::Client). No refresh flow, so no expires_at/needs_refresh?
|
|
8
|
+
# the way Square's Credential has.
|
|
9
|
+
class Credential < Spree.base_class
|
|
10
|
+
self.table_name = 'spree_doordash_credentials'
|
|
11
|
+
|
|
12
|
+
belongs_to :store, class_name: 'Spree::Store'
|
|
13
|
+
|
|
14
|
+
encrypts :developer_id, :key_id, :signing_secret, :webhook_basic_auth_token
|
|
15
|
+
|
|
16
|
+
validates :store, presence: true, uniqueness: true
|
|
17
|
+
validates :developer_id, :key_id, :signing_secret, presence: true
|
|
18
|
+
|
|
19
|
+
def sandbox?
|
|
20
|
+
doordash_environment == 'sandbox'
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|