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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.env +7 -0
  3. data/.github/workflows/test.yml +107 -0
  4. data/.gitignore +27 -0
  5. data/.rspec +3 -0
  6. data/CHANGELOG.md +54 -0
  7. data/CONTRIBUTING.md +29 -0
  8. data/Gemfile +27 -0
  9. data/LICENSE.md +9 -0
  10. data/README.md +109 -0
  11. data/Rakefile +23 -0
  12. data/app/controllers/spree/admin/doordash_credentials_controller.rb +42 -0
  13. data/app/controllers/spree/admin/doordash_delivery_mappings_controller.rb +12 -0
  14. data/app/controllers/spree/admin/doordash_webhook_events_controller.rb +11 -0
  15. data/app/controllers/spree_doordash/webhooks_controller.rb +60 -0
  16. data/app/jobs/spree_doordash/base_job.rb +5 -0
  17. data/app/jobs/spree_doordash/delivery_dispatch_job.rb +22 -0
  18. data/app/jobs/spree_doordash/delivery_webhook_job.rb +17 -0
  19. data/app/models/spree/calculator/shipping/doordash_quote.rb +40 -0
  20. data/app/models/spree_doordash/credential.rb +23 -0
  21. data/app/models/spree_doordash/delivery_mapping.rb +27 -0
  22. data/app/models/spree_doordash/location_mapping.rb +16 -0
  23. data/app/models/spree_doordash/quote_mapping.rb +20 -0
  24. data/app/models/spree_doordash/webhook_event.rb +36 -0
  25. data/app/services/spree_doordash/alerting.rb +19 -0
  26. data/app/services/spree_doordash/client.rb +122 -0
  27. data/app/services/spree_doordash/delivery_dispatcher.rb +54 -0
  28. data/app/services/spree_doordash/delivery_status_mapper.rb +74 -0
  29. data/app/services/spree_doordash/quote.rb +118 -0
  30. data/app/services/spree_doordash/webhook_verifier.rb +17 -0
  31. data/app/subscribers/spree_doordash/order_completed_subscriber.rb +29 -0
  32. data/app/views/spree/admin/doordash_credentials/show.html.erb +55 -0
  33. data/app/views/spree/admin/doordash_delivery_mappings/index.html.erb +5 -0
  34. data/app/views/spree/admin/doordash_webhook_events/index.html.erb +5 -0
  35. data/config/brakeman.ignore +28 -0
  36. data/config/initializers/spree_admin_doordash_navigation.rb +27 -0
  37. data/config/initializers/spree_admin_doordash_tables.rb +120 -0
  38. data/config/initializers/spree_doordash_calculators.rb +13 -0
  39. data/config/routes.rb +25 -0
  40. data/db/migrate/20260813000001_create_spree_doordash_credentials.rb +28 -0
  41. data/db/migrate/20260813000002_create_spree_doordash_location_mappings.rb +22 -0
  42. data/db/migrate/20260813000003_create_spree_doordash_quote_mappings.rb +47 -0
  43. data/db/migrate/20260813180001_create_spree_doordash_delivery_mappings.rb +47 -0
  44. data/db/migrate/20260813180002_create_spree_doordash_webhook_events.rb +46 -0
  45. data/lib/generators/spree_doordash/install/install_generator.rb +20 -0
  46. data/lib/spree_doordash/configuration.rb +8 -0
  47. data/lib/spree_doordash/engine.rb +44 -0
  48. data/lib/spree_doordash/factories.rb +47 -0
  49. data/lib/spree_doordash/version.rb +7 -0
  50. data/lib/spree_doordash.rb +12 -0
  51. data/lib/tasks/spree_doordash.rake +94 -0
  52. data/spree_doordash.gemspec +48 -0
  53. 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
@@ -0,0 +1,3 @@
1
+ --color
2
+ -r spec_helper
3
+ -f documentation
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
+ [![Gem Version](https://img.shields.io/gem/v/spree_doordash.svg)](https://rubygems.org/gems/spree_doordash)
4
+ [![GitHub Release](https://img.shields.io/github/v/release/amitkssolanki/spree_doordash)](https://github.com/amitkssolanki/spree_doordash/releases)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ module SpreeDoordash
2
+ class BaseJob < Spree::BaseJob
3
+ queue_as SpreeDoordash.queue
4
+ end
5
+ 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