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
@@ -0,0 +1,27 @@
1
+ Rails.application.config.after_initialize do
2
+ # Positions 68-70 — right after spree_square's own entries (65-67), so the
3
+ # two extensions' nav items sit together without colliding.
4
+ Spree.admin.navigation.sidebar.add :doordash_credential,
5
+ label: 'DoorDash Connection',
6
+ url: :admin_doordash_credential_path,
7
+ icon: 'plug',
8
+ position: 68,
9
+ active: -> { controller_name == 'doordash_credentials' },
10
+ if: -> { can?(:manage, SpreeDoordash::Credential) }
11
+
12
+ Spree.admin.navigation.sidebar.add :doordash_delivery_mappings,
13
+ label: 'DoorDash Deliveries',
14
+ url: :admin_doordash_delivery_mappings_path,
15
+ icon: 'truck',
16
+ position: 69,
17
+ active: -> { controller_name == 'doordash_delivery_mappings' },
18
+ if: -> { can?(:manage, SpreeDoordash::DeliveryMapping) }
19
+
20
+ Spree.admin.navigation.sidebar.add :doordash_webhook_events,
21
+ label: 'DoorDash Webhooks',
22
+ url: :admin_doordash_webhook_events_path,
23
+ icon: 'webhook',
24
+ position: 70,
25
+ active: -> { controller_name == 'doordash_webhook_events' },
26
+ if: -> { can?(:manage, SpreeDoordash::WebhookEvent) }
27
+ end
@@ -0,0 +1,120 @@
1
+ Rails.application.config.after_initialize do
2
+ # new_resource: false — read-only support/diagnostic tables (no
3
+ # `:new`/`:create` route exists; only: [:index] in config/routes.rb). The
4
+ # default (true) crashes with a routing error the moment the table is
5
+ # ever empty — found live, not from spree_admin's own docs — because the
6
+ # "no resource found" empty-state partial builds a `new_object_url` link
7
+ # unconditionally unless told not to. Same latent exposure exists in
8
+ # spree_square's own admin tables (they don't set this either); flagged
9
+ # separately rather than silently fixed here since that's a different gem.
10
+ Spree.admin.tables.register(:doordash_delivery_mappings, model_class: SpreeDoordash::DeliveryMapping,
11
+ search_param: :external_delivery_id_cont, new_resource: false)
12
+
13
+ Spree.admin.tables.doordash_delivery_mappings.add :order_number,
14
+ label: :order,
15
+ type: :string,
16
+ sortable: false,
17
+ filterable: false,
18
+ default: true,
19
+ position: 10,
20
+ method: ->(mapping) { mapping.order&.number }
21
+
22
+ Spree.admin.tables.doordash_delivery_mappings.add :external_delivery_id,
23
+ label: :external_delivery_id,
24
+ type: :string,
25
+ sortable: true,
26
+ filterable: true,
27
+ default: true,
28
+ position: 20
29
+
30
+ Spree.admin.tables.doordash_delivery_mappings.add :last_status,
31
+ label: :status,
32
+ type: :string,
33
+ sortable: true,
34
+ filterable: true,
35
+ default: true,
36
+ position: 30
37
+
38
+ Spree.admin.tables.doordash_delivery_mappings.add :tracking_url,
39
+ label: :tracking_url,
40
+ type: :string,
41
+ sortable: false,
42
+ filterable: false,
43
+ default: true,
44
+ position: 40
45
+
46
+ Spree.admin.tables.doordash_delivery_mappings.add :dasher_name,
47
+ label: :dasher,
48
+ type: :string,
49
+ sortable: false,
50
+ filterable: false,
51
+ default: true,
52
+ position: 50
53
+
54
+ Spree.admin.tables.doordash_delivery_mappings.add :dispatch_error,
55
+ label: :dispatch_error,
56
+ type: :string,
57
+ sortable: false,
58
+ filterable: false,
59
+ default: true,
60
+ position: 60
61
+
62
+ Spree.admin.tables.doordash_delivery_mappings.add :created_at,
63
+ label: :created_at,
64
+ type: :datetime,
65
+ sortable: true,
66
+ filterable: false,
67
+ default: true,
68
+ position: 70
69
+
70
+ Spree.admin.tables.register(:doordash_webhook_events, model_class: SpreeDoordash::WebhookEvent,
71
+ search_param: :event_name_cont, new_resource: false)
72
+
73
+ Spree.admin.tables.doordash_webhook_events.add :external_delivery_id,
74
+ label: :external_delivery_id,
75
+ type: :string,
76
+ sortable: true,
77
+ filterable: true,
78
+ default: true,
79
+ position: 10
80
+
81
+ Spree.admin.tables.doordash_webhook_events.add :event_name,
82
+ label: :event_name,
83
+ type: :string,
84
+ sortable: true,
85
+ filterable: true,
86
+ default: true,
87
+ position: 20
88
+
89
+ Spree.admin.tables.doordash_webhook_events.add :status,
90
+ label: :status,
91
+ type: :string,
92
+ sortable: true,
93
+ filterable: true,
94
+ default: true,
95
+ position: 30
96
+
97
+ Spree.admin.tables.doordash_webhook_events.add :processed_at,
98
+ label: :processed_at,
99
+ type: :datetime,
100
+ sortable: true,
101
+ filterable: false,
102
+ default: true,
103
+ position: 40
104
+
105
+ Spree.admin.tables.doordash_webhook_events.add :error_message,
106
+ label: :error_message,
107
+ type: :string,
108
+ sortable: false,
109
+ filterable: false,
110
+ default: true,
111
+ position: 50
112
+
113
+ Spree.admin.tables.doordash_webhook_events.add :created_at,
114
+ label: :received_at,
115
+ type: :datetime,
116
+ sortable: true,
117
+ filterable: false,
118
+ default: true,
119
+ position: 60
120
+ end
@@ -0,0 +1,13 @@
1
+ # Spree::ShippingMethod.calculators reads from a hardcoded list
2
+ # (Rails.application.config.spree.calculators.shipping_methods) that
3
+ # spree_core itself populates via its own `config.after_initialize` block
4
+ # (lib/spree/core/engine.rb) — NOT auto-discovery, despite
5
+ # Spree::ShippingCalculator subclasses otherwise looking like a
6
+ # convention-based extension point. spree_core's block does a full array
7
+ # *reassignment* (`= [...]`), so this has to run after it (append, don't
8
+ # replace) or the addition gets silently wiped — safe here because
9
+ # spree_doordash loads after spree/spree_core in the Gemfile, and Rails
10
+ # runs `after_initialize` blocks in engine-load order.
11
+ Rails.application.config.after_initialize do
12
+ Rails.application.config.spree.calculators.shipping_methods << Spree::Calculator::Shipping::DoordashQuote
13
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,25 @@
1
+ Spree::Core::Engine.add_routes do
2
+ # `isolate_namespace Spree` in the engine means a normal `namespace
3
+ # :spree_doordash do ... end` block would resolve controllers as
4
+ # `Spree::SpreeDoordash::...` (the isolated module gets prepended). The
5
+ # leading `/` on the controller path makes it absolute, resolving to the
6
+ # actual `SpreeDoordash::WebhooksController` while keeping the URL prefix
7
+ # — same pattern as spree_square's webhook route.
8
+ post 'spree_doordash/webhooks/doordash', to: '/spree_doordash/webhooks#create'
9
+
10
+ namespace :admin do
11
+ # Plain credential-entry form (developer_id/key_id/signing_secret/webhook
12
+ # Basic Auth token), not an OAuth connect/disconnect flow — DoorDash has
13
+ # no refresh-token dance the way Square's OAuth does. Explicit named
14
+ # routes rather than `resource :doordash_credential` since there's no
15
+ # `new`/`create` — just show the one row for the current store and
16
+ # update it in place, `find_or_initialize_by(store:)` style.
17
+ get 'doordash_credential' => 'doordash_credentials#show', as: :doordash_credential
18
+ patch 'doordash_credential' => 'doordash_credentials#update'
19
+
20
+ # M5 — admin support/diagnostic pages, same read-only shape as
21
+ # spree_square's own (:index only).
22
+ resources :doordash_delivery_mappings, only: [:index]
23
+ resources :doordash_webhook_events, only: [:index]
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ class CreateSpreeDoordashCredentials < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_doordash_credentials do |t|
4
+ t.references :store, null: false, foreign_key: { to_table: :spree_stores }, index: { unique: true }
5
+
6
+ # No OAuth/refresh flow — DoorDash Drive auth is a JWT signed
7
+ # client-side per request from a static access key (developer_id +
8
+ # key_id + signing_secret), created once in DoorDash's Developer
9
+ # Portal. All three encrypted at the application layer (see
10
+ # SpreeDoordash::Credential) even though developer_id/key_id aren't
11
+ # secret on their own, for consistency with signing_secret (which is).
12
+ t.text :developer_id
13
+ t.text :key_id
14
+ t.text :signing_secret
15
+
16
+ # The static Authorization header value configured as this store's
17
+ # webhook Basic Auth in DoorDash's dashboard — DoorDash echoes it back
18
+ # on every webhook call; SpreeDoordash::WebhookVerifier compares
19
+ # against this rather than an HMAC signature (DoorDash has no
20
+ # signing scheme the way Square does).
21
+ t.text :webhook_basic_auth_token
22
+
23
+ t.string :doordash_environment, null: false, default: 'sandbox'
24
+
25
+ t.timestamps
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,22 @@
1
+ class CreateSpreeDoordashLocationMappings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_doordash_location_mappings do |t|
4
+ t.references :stock_location, null: false, foreign_key: { to_table: :spree_stock_locations }, index: { unique: true }
5
+ t.references :store, null: false, foreign_key: { to_table: :spree_stores }
6
+
7
+ # DoorDash's own Business/Store hierarchy — a Business owns multiple
8
+ # Stores, each a physical pickup location. Structural twin of
9
+ # SpreeSquare::LocationMapping (one row per external-system concept,
10
+ # unique index on the external id, no shared/polymorphic mapping
11
+ # table), except this one carries spree_store_id from day one —
12
+ # SpreeSquare's own mapping tables don't, which its README flags as a
13
+ # gap before real multi-store use. Cheap to not repeat here.
14
+ t.string :doordash_store_id, null: false
15
+ t.string :doordash_business_id
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :spree_doordash_location_mappings, :doordash_store_id, unique: true
21
+ end
22
+ end
@@ -0,0 +1,47 @@
1
+ class CreateSpreeDoordashQuoteMappings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_doordash_quote_mappings do |t|
4
+ t.references :order, null: false, foreign_key: { to_table: :spree_orders }, index: { unique: true }
5
+
6
+ # Ours — "spree-doordash-#{order.number}-#{random suffix}" (see
7
+ # SpreeDoordash::Quote; the random suffix is required — DoorDash
8
+ # rejects a second /drive/v2/quotes call reusing the same id with 409
9
+ # duplicate_delivery_id, confirmed against a real Sandbox re-quote).
10
+ # The join key DoorDash's own webhooks and the accept-quote/
11
+ # get-delivery calls all key off, same role as square_order_id plays
12
+ # for spree_square's OrderMapping.
13
+ t.string :external_delivery_id, null: false
14
+
15
+ t.integer :quoted_fee_cents
16
+ t.string :currency, default: 'USD'
17
+
18
+ # A quote is only valid 5 minutes (DoorDash's own limit) — checked by
19
+ # SpreeDoordash::DeliveryDispatcher (M4) to decide whether to accept
20
+ # this quote as-is or re-quote fresh before accepting.
21
+ t.datetime :quote_expires_at
22
+
23
+ t.datetime :pickup_time_estimated
24
+ t.datetime :dropoff_time_estimated
25
+
26
+ # Full response, for debugging/support visibility — same rationale as
27
+ # WebhookEvent#payload elsewhere in this codebase. jsonb on Postgres,
28
+ # json on SQLite — same `respond_to?(:jsonb)` branch spree_core's own
29
+ # migrations use (e.g. add_metadata_to_spree_orders), since SQLite (the
30
+ # dummy app used for specs) has no jsonb type at all. Plain `json`
31
+ # alone is wrong on Postgres specifically: it has no equality
32
+ # operator, which breaks the `SELECT DISTINCT` an admin table listing
33
+ # issues — confirmed live, not hypothetical (spree_host's own
34
+ # /admin/doordash_delivery_mappings page 500'd on exactly this before
35
+ # this fix).
36
+ if t.respond_to?(:jsonb)
37
+ t.jsonb :raw_response
38
+ else
39
+ t.json :raw_response
40
+ end
41
+
42
+ t.timestamps
43
+ end
44
+
45
+ add_index :spree_doordash_quote_mappings, :external_delivery_id, unique: true
46
+ end
47
+ end
@@ -0,0 +1,47 @@
1
+ class CreateSpreeDoordashDeliveryMappings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_doordash_delivery_mappings do |t|
4
+ t.references :order, null: false, foreign_key: { to_table: :spree_orders }, index: { unique: true }
5
+
6
+ # The external_delivery_id of the quote that was accepted — becomes
7
+ # the real delivery's own id once accept succeeds (DoorDash doesn't
8
+ # issue a separate "delivery id"; the quote's external_delivery_id is
9
+ # reused for the whole lifecycle, confirmed via
10
+ # POST /drive/v2/quotes/{external_delivery_id}/accept in DoorDash's
11
+ # own docs).
12
+ # Nullable, unlike QuoteMapping's own external_delivery_id — a
13
+ # DeliveryMapping row can legitimately exist before one is known (see
14
+ # DeliveryDispatchJob's dead-letter block: a dispatch can fail before
15
+ # ever reaching DoorDash's accept endpoint, e.g. no LocationMapping,
16
+ # matching SpreeSquare::OrderMapping#square_order_id's own nullable
17
+ # precedent for the same reason).
18
+ t.string :external_delivery_id
19
+
20
+ # DoorDash's own event_name vocabulary (DASHER_CONFIRMED,
21
+ # DASHER_PICKED_UP, DASHER_DROPPED_OFF, DELIVERY_CANCELLED, ...) —
22
+ # stored verbatim rather than mapped to a smaller enum, same
23
+ # last_status rationale as SpreeSquare::OrderMapping.
24
+ t.string :last_status
25
+
26
+ t.string :tracking_url
27
+ t.string :dasher_name
28
+ t.string :dasher_phone
29
+ t.text :dispatch_error
30
+
31
+ # Full response from the accept call, for debugging/support
32
+ # visibility — same rationale as QuoteMapping#raw_response (see that
33
+ # migration for why this is jsonb-on-Postgres/json-on-SQLite rather
34
+ # than plain json — confirmed live via a real 500 on this table's own
35
+ # admin listing page before the fix).
36
+ if t.respond_to?(:jsonb)
37
+ t.jsonb :raw_response
38
+ else
39
+ t.json :raw_response
40
+ end
41
+
42
+ t.timestamps
43
+ end
44
+
45
+ add_index :spree_doordash_delivery_mappings, :external_delivery_id, unique: true
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ class CreateSpreeDoordashWebhookEvents < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_doordash_webhook_events do |t|
4
+ # DoorDash's webhook payloads carry no single event id of their own
5
+ # (unlike Square's payload['event_id']) — DoorDash "sends each webhook
6
+ # event up to 3 times" on anything other than a 200 (its own docs),
7
+ # so the same body can arrive more than once. The idempotency key is
8
+ # built from what IS in the payload: external_delivery_id + event_name
9
+ # (which event this is) + a digest of the raw body (guards against two
10
+ # genuinely different payloads that happened to share those two
11
+ # fields, e.g. a future field DoorDash adds that changes between
12
+ # otherwise-identical redeliveries).
13
+ t.string :external_delivery_id, null: false
14
+ t.string :event_name, null: false
15
+ t.string :payload_digest, null: false
16
+
17
+ # jsonb on Postgres, json on SQLite (the dummy app used for specs has
18
+ # no jsonb type at all) — see CreateSpreeDoordashQuoteMappings for the
19
+ # full rationale. Plain `json` alone is wrong on Postgres: it has no
20
+ # equality operator, which broke this exact table's own admin listing
21
+ # page (`SELECT DISTINCT`) — found live, not hypothetical.
22
+ #
23
+ # No `default: {}` on either branch — MySQL rejects a literal DEFAULT
24
+ # on BLOB/TEXT/GEOMETRY/JSON columns outright, which would cancel
25
+ # every migration after this one on that adapter (found live via this
26
+ # exact bug in the sibling spree_square gem's own MySQL CI job — not
27
+ # yet hit here since this gem has no MySQL CI, but the same migration
28
+ # would fail identically). Default now lives on the model instead
29
+ # (see WebhookEvent's own `attribute :payload, default: -> { {} }`).
30
+ if t.respond_to?(:jsonb)
31
+ t.jsonb :payload, null: false
32
+ else
33
+ t.json :payload, null: false
34
+ end
35
+ t.datetime :processed_at
36
+ t.string :status, null: false, default: 'pending' # pending, processed, failed
37
+ t.text :error_message
38
+
39
+ t.timestamps
40
+ end
41
+
42
+ add_index :spree_doordash_webhook_events, %i[external_delivery_id event_name payload_digest],
43
+ unique: true, name: 'index_spree_doordash_webhook_events_on_idempotency_key'
44
+ add_index :spree_doordash_webhook_events, :event_name
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ module SpreeDoordash
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ class_option :migrate, type: :boolean, default: true
5
+
6
+ def add_migrations
7
+ run 'bundle exec rake railties:install:migrations FROM=spree_doordash'
8
+ end
9
+
10
+ def run_migrations
11
+ run_migrations = options[:migrate] || ['', 'y', 'Y'].include?(ask('Would you like to run the migrations now? [Y/n]'))
12
+ if run_migrations
13
+ run 'bin/rails db:migrate'
14
+ else
15
+ puts 'Skipping rails db:migrate, don\'t forget to run it!'
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,8 @@
1
+ module SpreeDoordash
2
+ class Configuration < Spree::Preferences::Configuration
3
+ # Some example preferences are shown below, for more information visit:
4
+ # https://docs.spreecommerce.org/developer/contributing/creating-an-extension
5
+
6
+ # preference :enabled, :boolean, default: true
7
+ end
8
+ end
@@ -0,0 +1,44 @@
1
+ module SpreeDoordash
2
+ class Engine < Rails::Engine
3
+ require 'spree/core'
4
+ isolate_namespace Spree
5
+ engine_name 'spree_doordash'
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
10
+
11
+ initializer 'spree_doordash.environment', before: :load_config_initializers do |_app|
12
+ SpreeDoordash::Config = SpreeDoordash::Configuration.new
13
+ end
14
+
15
+ # Same ordering requirement as spree_square's identical initializer —
16
+ # must run before Active Record's own "active_record_encryption.configuration"
17
+ # initializer reads config.active_record.encryption, or Credential's
18
+ # encrypted columns fail with "Missing Active Record encryption
19
+ # credential" the first time they're touched. Shares the same
20
+ # ACTIVE_RECORD_ENCRYPTION_* keys spree_square already sets up (one
21
+ # encryption key set per Rails app, not per extension).
22
+ initializer 'spree_doordash.active_record_encryption', before: 'active_record_encryption.configuration' do |app|
23
+ next if ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].blank?
24
+
25
+ app.config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
26
+ app.config.active_record.encryption.deterministic_key = ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY']
27
+ app.config.active_record.encryption.key_derivation_salt = ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT']
28
+ end
29
+
30
+ # Force-loads decorator files the same way spree_square's engine does —
31
+ # not needed until this extension actually has a decorator (M3+), kept
32
+ # here from the start so adding one later doesn't require remembering
33
+ # this step. See spree_square/lib/spree_square/engine.rb for the full
34
+ # rationale (Zeitwerk lazy-autoloading never triggers a decorator file's
35
+ # `prepend` line otherwise).
36
+ def self.activate
37
+ Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c|
38
+ Rails.application.config.cache_classes ? require(c) : load(c)
39
+ end
40
+ end
41
+
42
+ config.to_prepare(&method(:activate).to_proc)
43
+ end
44
+ end
@@ -0,0 +1,47 @@
1
+ FactoryBot.define do
2
+ # Define your Spree extensions Factories within this file to enable applications, and other extensions to use and override them.
3
+ #
4
+ # Example adding this to your spec_helper will load these Factories for use:
5
+ # require 'spree_doordash/factories'
6
+
7
+ factory :doordash_credential, class: 'SpreeDoordash::Credential' do
8
+ store
9
+ developer_id { 'test-developer-id' }
10
+ key_id { 'test-key-id' }
11
+ # base64url, matching what SpreeDoordash::Client actually decodes with
12
+ # (Base64.urlsafe_decode64) — confirmed against a real DoorDash Sandbox
13
+ # 401 that standard base64 is the wrong alphabet.
14
+ signing_secret { Base64.urlsafe_encode64('test-signing-secret') }
15
+ doordash_environment { 'sandbox' }
16
+ webhook_basic_auth_token { 'Basic dGVzdDp0ZXN0' }
17
+ end
18
+
19
+ factory :doordash_location_mapping, class: 'SpreeDoordash::LocationMapping' do
20
+ stock_location
21
+ store
22
+ sequence(:doordash_store_id) { |i| "DDSTORE#{i}" }
23
+ doordash_business_id { 'default' }
24
+ end
25
+
26
+ factory :doordash_quote_mapping, class: 'SpreeDoordash::QuoteMapping' do
27
+ order
28
+ sequence(:external_delivery_id) { |i| "spree-doordash-R#{i}" }
29
+ quoted_fee_cents { 599 }
30
+ currency { 'USD' }
31
+ quote_expires_at { 5.minutes.from_now }
32
+ end
33
+
34
+ factory :doordash_delivery_mapping, class: 'SpreeDoordash::DeliveryMapping' do
35
+ order
36
+ sequence(:external_delivery_id) { |i| "spree-doordash-R#{i}-accepted" }
37
+ last_status { 'created' }
38
+ tracking_url { 'https://doordash.com/tracking?id=abc123' }
39
+ end
40
+
41
+ factory :doordash_webhook_event, class: 'SpreeDoordash::WebhookEvent' do
42
+ sequence(:external_delivery_id) { |i| "spree-doordash-R#{i}" }
43
+ event_name { 'DASHER_CONFIRMED' }
44
+ sequence(:payload_digest) { |i| "digest-#{i}" }
45
+ payload { { 'event_name' => 'DASHER_CONFIRMED', 'external_delivery_id' => 'spree-doordash-R1' } }
46
+ end
47
+ end
@@ -0,0 +1,7 @@
1
+ module SpreeDoordash
2
+ VERSION = '0.1.0'.freeze
3
+
4
+ def gem_version
5
+ Gem::Version.new(VERSION)
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ require 'spree'
2
+ require 'spree_doordash/engine'
3
+ require 'spree_doordash/version'
4
+ require 'spree_doordash/configuration'
5
+
6
+ module SpreeDoordash
7
+ mattr_accessor :queue
8
+
9
+ def self.queue
10
+ @@queue ||= Spree.queues.default
11
+ end
12
+ end
@@ -0,0 +1,94 @@
1
+ namespace :spree_doordash do
2
+ # M1 verification: proves the JWT this extension signs is actually
3
+ # accepted by a real DoorDash Sandbox endpoint, not just shaped correctly
4
+ # per the documented recipe (already covered by spec/services/spree_doordash/client_spec.rb).
5
+ # Reads real credentials from .env.sandbox (gitignored, never committed —
6
+ # see that file's own comment) rather than the encrypted
7
+ # SpreeDoordash::Credential row, so this can run standalone against the
8
+ # gem's own dummy app without needing a full spree_host install first.
9
+ #
10
+ # Usage:
11
+ # bin/rails spree_doordash:verify_connection
12
+ desc 'Verify DoorDash Sandbox credentials by requesting a real delivery quote'
13
+ task verify_connection: :environment do
14
+ env_path = File.expand_path('../../.env.sandbox', __dir__)
15
+ unless File.exist?(env_path)
16
+ abort "No .env.sandbox found at #{env_path} — see README.md's \"Connecting to DoorDash\" section."
17
+ end
18
+
19
+ require 'dotenv'
20
+ Dotenv.load(env_path)
21
+
22
+ %w[DOORDASH_DEVELOPER_ID DOORDASH_KEY_ID DOORDASH_SIGNING_SECRET].each do |key|
23
+ abort "#{key} is blank in .env.sandbox — fill in all three values first." if ENV[key].blank?
24
+ end
25
+
26
+ credential = SpreeDoordash::Credential.new(
27
+ developer_id: ENV['DOORDASH_DEVELOPER_ID'],
28
+ key_id: ENV['DOORDASH_KEY_ID'],
29
+ signing_secret: ENV['DOORDASH_SIGNING_SECRET'],
30
+ doordash_environment: 'sandbox'
31
+ )
32
+ client = SpreeDoordash::Client.new(credential: credential)
33
+
34
+ # A quote is the safest possible real call to verify against — DoorDash's
35
+ # own docs describe it as side-effect-light (no delivery is created,
36
+ # nothing to clean up afterward), and it's the same payload shape M2's
37
+ # SpreeDoordash::Quote service will build for real.
38
+ payload = {
39
+ external_delivery_id: "verify-#{SecureRandom.hex(4)}",
40
+ pickup_address: '901 Market Street 6th Floor San Francisco, CA 94103',
41
+ pickup_business_name: 'Spree Doordash Verification',
42
+ pickup_phone_number: '+16505555555',
43
+ dropoff_address: '901 Market Street 6th Floor San Francisco, CA 94103',
44
+ dropoff_phone_number: '+16505555555',
45
+ order_value: 1999
46
+ }
47
+
48
+ puts 'Requesting a Sandbox quote from DoorDash...'
49
+ begin
50
+ result = client.create_quote(payload)
51
+ puts '✅ Success — DoorDash accepted the JWT and returned a real quote:'
52
+ puts JSON.pretty_generate(result)
53
+ rescue SpreeDoordash::RequestError => e
54
+ puts "❌ DoorDash rejected the request (HTTP #{e.status}):"
55
+ puts JSON.pretty_generate(e.body)
56
+ exit 1
57
+ end
58
+ end
59
+
60
+ desc 'Map a Spree stock location to a DoorDash store (M5 — a rake task, not a full admin CRUD screen: restaurants have a handful of locations, not hundreds)'
61
+ task :map_location, %i[stock_location doordash_store_id doordash_business_id] => :environment do |_t, args|
62
+ unless args[:stock_location] && args[:doordash_store_id]
63
+ abort <<~USAGE
64
+ Usage:
65
+ bin/rails "spree_doordash:map_location[stock_location_id_or_name,doordash_store_id,doordash_business_id]"
66
+
67
+ doordash_business_id defaults to "default" — DoorDash Sandbox
68
+ auto-assigns a "default" business/store to every access key, so this
69
+ is normally all you need there. Only pass a real business id once
70
+ you've done full Business/Store API onboarding for production.
71
+ USAGE
72
+ end
73
+
74
+ stock_location = Spree::StockLocation.find_by(id: args[:stock_location]) ||
75
+ Spree::StockLocation.find_by(name: args[:stock_location])
76
+ abort "No stock location found matching '#{args[:stock_location]}'" unless stock_location
77
+
78
+ # Spree::StockLocation has no store association of its own in this
79
+ # Spree version (confirmed by reading the model — stock locations
80
+ # aren't Store-scoped) — same single-default-store scope as the rest
81
+ # of this extension (see SpreeDoordash::Quote's own order.store ||
82
+ # Spree::Store.default).
83
+ store = Spree::Store.default
84
+ mapping = SpreeDoordash::LocationMapping.find_or_initialize_by(stock_location: stock_location)
85
+ mapping.assign_attributes(
86
+ store: store,
87
+ doordash_store_id: args[:doordash_store_id],
88
+ doordash_business_id: args[:doordash_business_id].presence || 'default'
89
+ )
90
+ mapping.save!
91
+
92
+ puts "✅ Mapped '#{stock_location.name}' -> DoorDash store #{mapping.doordash_store_id} (business #{mapping.doordash_business_id})"
93
+ end
94
+ end