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
|
@@ -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,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
|