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
|
+
module SpreeDoordash
|
|
2
|
+
# Maps a Spree::Order to the DoorDash delivery it was dispatched to on
|
|
3
|
+
# acceptance — the DoorDash analog of SpreeSquare::OrderMapping.
|
|
4
|
+
# external_delivery_id is the accepted quote's id, reused as the
|
|
5
|
+
# delivery's own id for its whole lifecycle (DoorDash issues no separate
|
|
6
|
+
# delivery id).
|
|
7
|
+
class DeliveryMapping < Spree.base_class
|
|
8
|
+
self.table_name = 'spree_doordash_delivery_mappings'
|
|
9
|
+
|
|
10
|
+
belongs_to :order, class_name: 'Spree::Order'
|
|
11
|
+
|
|
12
|
+
validates :order, presence: true, uniqueness: true
|
|
13
|
+
# No presence requirement — mirrors SpreeSquare::OrderMapping's own
|
|
14
|
+
# square_order_id: DeliveryDispatchJob's dead-letter block does
|
|
15
|
+
# `find_or_initialize_by(order:).mark_failed!(error)` on a dispatch that
|
|
16
|
+
# can fail before a delivery (and therefore an external_delivery_id)
|
|
17
|
+
# ever exists — e.g. no LocationMapping, or the underlying quote itself
|
|
18
|
+
# failed. allow_nil so two such failed-before-dispatch rows don't
|
|
19
|
+
# collide on the uniqueness check (nil is a valid, repeatable "no
|
|
20
|
+
# delivery yet" state, not itself a real id).
|
|
21
|
+
validates :external_delivery_id, uniqueness: true, allow_nil: true
|
|
22
|
+
|
|
23
|
+
def mark_failed!(error)
|
|
24
|
+
update!(dispatch_error: error.to_s.truncate(2000))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# Maps a DoorDash Store (their own Business/Store hierarchy — a Business
|
|
3
|
+
# owns multiple physical pickup Stores) 1:1 to a Spree::StockLocation.
|
|
4
|
+
# Structural twin of SpreeSquare::LocationMapping — see that class's own
|
|
5
|
+
# comment for why restaurant branches map onto Spree's existing
|
|
6
|
+
# multi-warehouse StockLocation model rather than a bespoke concept.
|
|
7
|
+
class LocationMapping < Spree.base_class
|
|
8
|
+
self.table_name = 'spree_doordash_location_mappings'
|
|
9
|
+
|
|
10
|
+
belongs_to :stock_location, class_name: 'Spree::StockLocation'
|
|
11
|
+
belongs_to :store, class_name: 'Spree::Store'
|
|
12
|
+
|
|
13
|
+
validates :doordash_store_id, presence: true, uniqueness: true
|
|
14
|
+
validates :stock_location, presence: true, uniqueness: true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# Tracks the most recent DoorDash Drive quote requested for an order,
|
|
3
|
+
# generated as checkout progresses (see Spree::Calculator::Shipping::
|
|
4
|
+
# DoordashQuote) and consulted again at order.completed to decide whether
|
|
5
|
+
# to accept it as-is or re-quote (see SpreeDoordash::DeliveryDispatcher,
|
|
6
|
+
# M4) — DoorDash quotes are only valid 5 minutes, and checkout can easily
|
|
7
|
+
# take longer than that.
|
|
8
|
+
class QuoteMapping < Spree.base_class
|
|
9
|
+
self.table_name = 'spree_doordash_quote_mappings'
|
|
10
|
+
|
|
11
|
+
belongs_to :order, class_name: 'Spree::Order'
|
|
12
|
+
|
|
13
|
+
validates :order, presence: true, uniqueness: true
|
|
14
|
+
validates :external_delivery_id, presence: true, uniqueness: true
|
|
15
|
+
|
|
16
|
+
def expired?
|
|
17
|
+
quote_expires_at.blank? || quote_expires_at <= Time.current
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# Idempotency + audit log for inbound DoorDash webhook notifications.
|
|
3
|
+
#
|
|
4
|
+
# Unlike SpreeSquare::WebhookEvent, there's no single event_id in the
|
|
5
|
+
# payload to key off — the unique index instead covers
|
|
6
|
+
# (external_delivery_id, event_name, payload_digest), see the migration
|
|
7
|
+
# for why each piece is needed. That's what makes DoorDash's documented
|
|
8
|
+
# up-to-3x redelivery-on-non-200 safe to process without duplicating side
|
|
9
|
+
# effects.
|
|
10
|
+
class WebhookEvent < Spree.base_class
|
|
11
|
+
self.table_name = 'spree_doordash_webhook_events'
|
|
12
|
+
|
|
13
|
+
# Ruby-level, not a DB-level `default: {}` on the migration — MySQL
|
|
14
|
+
# rejects a literal DEFAULT on a JSON column outright (see that
|
|
15
|
+
# migration's own comment). Works identically on every adapter.
|
|
16
|
+
attribute :payload, default: -> { {} }
|
|
17
|
+
|
|
18
|
+
validates :external_delivery_id, presence: true
|
|
19
|
+
validates :event_name, presence: true
|
|
20
|
+
validates :payload_digest, presence: true, uniqueness: { scope: %i[external_delivery_id event_name] }
|
|
21
|
+
|
|
22
|
+
scope :pending, -> { where(status: 'pending') }
|
|
23
|
+
|
|
24
|
+
def self.digest(raw_body)
|
|
25
|
+
Digest::SHA256.hexdigest(raw_body)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def mark_processed!
|
|
29
|
+
update!(status: 'processed', processed_at: Time.current)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def mark_failed!(error)
|
|
33
|
+
update!(status: 'failed', processed_at: Time.current, error_message: error.to_s.truncate(1000))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# One place for "this needs a human" — used when a job exhausts its
|
|
3
|
+
# retries. A standalone copy of SpreeSquare::Alerting's pattern rather
|
|
4
|
+
# than a dependency on that gem — spree_doordash stays independently
|
|
5
|
+
# installable on its own. Reports to Sentry when available, always logs
|
|
6
|
+
# at error level regardless so nothing depends on Sentry being configured
|
|
7
|
+
# to at least be visible in the server log.
|
|
8
|
+
class Alerting
|
|
9
|
+
def self.capture(error, context: {})
|
|
10
|
+
context = { source: 'spree_doordash' }.merge(context.is_a?(String) ? { area: context } : context)
|
|
11
|
+
|
|
12
|
+
Rails.logger.error("[SpreeDoordash] #{context[:area] || 'error'}: #{error.class}: #{error.message}")
|
|
13
|
+
|
|
14
|
+
return unless defined?(Sentry)
|
|
15
|
+
|
|
16
|
+
Sentry.capture_exception(error, extra: context)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require 'jwt'
|
|
2
|
+
require 'faraday'
|
|
3
|
+
|
|
4
|
+
module SpreeDoordash
|
|
5
|
+
# Thin wrapper around the DoorDash Drive API (v2). All Drive API access in
|
|
6
|
+
# this extension goes through here.
|
|
7
|
+
#
|
|
8
|
+
# Auth is fundamentally different from SpreeSquare::Client: DoorDash Drive
|
|
9
|
+
# has no OAuth/refresh flow. A static access key (developer_id/key_id/
|
|
10
|
+
# signing_secret), created once in DoorDash's Developer Portal, signs a
|
|
11
|
+
# fresh short-lived (5 min) JWT on every call — there's nothing to cache
|
|
12
|
+
# or refresh, unlike Square's 30-day access token.
|
|
13
|
+
class Client
|
|
14
|
+
class MissingCredentialsError < StandardError; end
|
|
15
|
+
|
|
16
|
+
BASE_URL = 'https://openapi.doordash.com'.freeze
|
|
17
|
+
JWT_TTL = 300 # seconds — matches DoorDash's own documented recipe
|
|
18
|
+
|
|
19
|
+
# Deliberately NOT memoized, same rationale as SpreeSquare::Client: a
|
|
20
|
+
# store's credential can be created/edited by an admin mid-process, and
|
|
21
|
+
# nothing here is expensive enough to cache (a JWT is cheap to sign, and
|
|
22
|
+
# is only valid 5 minutes anyway).
|
|
23
|
+
def self.instance
|
|
24
|
+
for_store
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.for_store(store = Spree::Store.default)
|
|
28
|
+
new(credential: SpreeDoordash::Credential.find_by(store: store))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(credential: nil)
|
|
32
|
+
@credential = credential
|
|
33
|
+
raise MissingCredentialsError, 'No DoorDash credential connected for this store' unless @credential
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def sandbox?
|
|
37
|
+
@credential.sandbox?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Validates coverage/pricing for a delivery before formally creating it —
|
|
41
|
+
# DoorDash's own recommended first call (see docs/how_to/quote_deliveries).
|
|
42
|
+
# Quotes are valid 5 minutes; accept_quote (M2) formally creates the
|
|
43
|
+
# delivery.
|
|
44
|
+
def create_quote(payload)
|
|
45
|
+
request(:post, '/drive/v2/quotes', payload)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Formally creates the delivery from a still-open quote. Must be called
|
|
49
|
+
# within 5 minutes of the quote (DoorDash's own documented limit — see
|
|
50
|
+
# SpreeDoordash::QuoteMapping#expired?). external_delivery_id is reused
|
|
51
|
+
# as the delivery's own id for its whole lifecycle; DoorDash issues no
|
|
52
|
+
# separate one (confirmed against
|
|
53
|
+
# developer.doordash.com/en-US/docs/drive/how_to/quote_deliveries/).
|
|
54
|
+
def accept_quote(external_delivery_id, tip_cents: nil)
|
|
55
|
+
body = tip_cents ? { tip: tip_cents } : {}
|
|
56
|
+
request(:post, "/drive/v2/quotes/#{external_delivery_id}/accept", body)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def request(method, path, body = nil)
|
|
62
|
+
response = connection.send(method) do |req|
|
|
63
|
+
req.url path
|
|
64
|
+
req.headers['Authorization'] = "Bearer #{jwt}"
|
|
65
|
+
req.headers['Content-Type'] = 'application/json'
|
|
66
|
+
req.body = body.to_json if body
|
|
67
|
+
end
|
|
68
|
+
handle_response(response)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def connection
|
|
72
|
+
@connection ||= Faraday.new(url: BASE_URL)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def handle_response(response)
|
|
76
|
+
parsed = response.body.present? ? JSON.parse(response.body) : {}
|
|
77
|
+
return parsed if response.status.between?(200, 299)
|
|
78
|
+
|
|
79
|
+
raise RequestError.new("DoorDash API error (#{response.status}): #{parsed.inspect}", status: response.status, body: parsed)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# JWT claims exactly matching DoorDash's own documented recipe —
|
|
83
|
+
# including `kid` living in the payload/claims rather than the JWT
|
|
84
|
+
# header, which is non-standard (kid is normally a header parameter)
|
|
85
|
+
# but is what DoorDash's API actually expects; `dd-ver` is the one true
|
|
86
|
+
# header field.
|
|
87
|
+
def jwt
|
|
88
|
+
now = Time.now.to_i
|
|
89
|
+
payload = {
|
|
90
|
+
aud: 'doordash',
|
|
91
|
+
iss: @credential.developer_id,
|
|
92
|
+
kid: @credential.key_id,
|
|
93
|
+
iat: now,
|
|
94
|
+
exp: now + JWT_TTL
|
|
95
|
+
}
|
|
96
|
+
# base64url, NOT standard base64 — confirmed directly against a real
|
|
97
|
+
# Sandbox 401 ("please make sure... the signing secret was base64url
|
|
98
|
+
# decoded prior to signing") the first time this was tested live.
|
|
99
|
+
# DoorDash's own JS tutorial code uses plain `Buffer.from(secret,
|
|
100
|
+
# 'base64')`, which is misleading/wrong (or Node's 'base64' decoder
|
|
101
|
+
# happens to be lenient about the url-safe alphabet in a way Ruby's
|
|
102
|
+
# strict Base64.decode64 is not) — trust the API's own error over the
|
|
103
|
+
# sample code.
|
|
104
|
+
key = Base64.urlsafe_decode64(@credential.signing_secret)
|
|
105
|
+
# `typ: 'JWT'` explicitly — the `jwt` gem (v3.x here) doesn't add it
|
|
106
|
+
# automatically the way Node's `jsonwebtoken` (DoorDash's own sample
|
|
107
|
+
# code) does, and DoorDash's validator rejects a JWT with no `typ` at
|
|
108
|
+
# all rather than treating it as implied.
|
|
109
|
+
JWT.encode(payload, key, 'HS256', { 'dd-ver' => 'DD-JWT-V1', typ: 'JWT' })
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class RequestError < StandardError
|
|
114
|
+
attr_reader :status, :body
|
|
115
|
+
|
|
116
|
+
def initialize(message, status:, body:)
|
|
117
|
+
super(message)
|
|
118
|
+
@status = status
|
|
119
|
+
@body = body
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# Dispatches a completed order to DoorDash: accepts its already-open
|
|
3
|
+
# quote if one exists and hasn't expired, or requests a fresh quote and
|
|
4
|
+
# accepts that instead. Quotes are only valid 5 minutes (DoorDash's own
|
|
5
|
+
# limit) — checkout can easily take longer, so by the time an order
|
|
6
|
+
# actually completes the quote generated during checkout (M3) may already
|
|
7
|
+
# be stale.
|
|
8
|
+
class DeliveryDispatcher
|
|
9
|
+
def self.call(...) = new.call(...)
|
|
10
|
+
|
|
11
|
+
def call(order)
|
|
12
|
+
quote_mapping = quote_mapping_for(order)
|
|
13
|
+
return nil unless quote_mapping
|
|
14
|
+
|
|
15
|
+
client = SpreeDoordash::Client.for_store(order.store || Spree::Store.default)
|
|
16
|
+
response = client.accept_quote(quote_mapping.external_delivery_id)
|
|
17
|
+
|
|
18
|
+
persist_delivery!(order, quote_mapping.external_delivery_id, response)
|
|
19
|
+
rescue SpreeDoordash::Client::MissingCredentialsError, SpreeDoordash::RequestError => e
|
|
20
|
+
Rails.logger.error("[SpreeDoordash] dispatch failed for order #{order.number}: #{e.message}")
|
|
21
|
+
SpreeDoordash::DeliveryMapping.find_or_initialize_by(order: order).mark_failed!(e)
|
|
22
|
+
SpreeDoordash::Alerting.capture(e, context: { area: 'dispatch', order_number: order.number })
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# A fresh quote (not the order's existing one, if any) — same rationale
|
|
29
|
+
# as SpreeDoordash::Quote's own external_delivery_id comment: reusing an
|
|
30
|
+
# id DoorDash has already seen gets rejected. Requesting via the same
|
|
31
|
+
# Quote service keeps this in one place rather than duplicating
|
|
32
|
+
# quote-building here.
|
|
33
|
+
def quote_mapping_for(order)
|
|
34
|
+
existing = SpreeDoordash::QuoteMapping.find_by(order: order)
|
|
35
|
+
return existing if existing && !existing.expired?
|
|
36
|
+
|
|
37
|
+
SpreeDoordash::Quote.call(order)
|
|
38
|
+
SpreeDoordash::QuoteMapping.find_by(order: order)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def persist_delivery!(order, external_delivery_id, response)
|
|
42
|
+
mapping = SpreeDoordash::DeliveryMapping.find_or_initialize_by(order: order)
|
|
43
|
+
mapping.update!(
|
|
44
|
+
external_delivery_id: external_delivery_id,
|
|
45
|
+
last_status: response['delivery_status'],
|
|
46
|
+
tracking_url: response['tracking_url'],
|
|
47
|
+
dasher_name: response['dasher_name'],
|
|
48
|
+
dasher_phone: response['dasher_dropoff_phone_number'],
|
|
49
|
+
raw_response: response
|
|
50
|
+
)
|
|
51
|
+
mapping
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# Applies a DoorDash delivery webhook event to the mapped Spree order —
|
|
3
|
+
# the DoorDash analog of SpreeSquare::OrderStatusMapper.
|
|
4
|
+
#
|
|
5
|
+
# DoorDash's own event_name vocabulary (verified against
|
|
6
|
+
# developer.doordash.com/en-US/docs/drive/reference/webhooks/, not
|
|
7
|
+
# assumed): DASHER_CONFIRMED, DASHER_CONFIRMED_PICKUP_ARRIVAL,
|
|
8
|
+
# DASHER_PICKED_UP, DASHER_CONFIRMED_DROPOFF_ARRIVAL, DASHER_DROPPED_OFF,
|
|
9
|
+
# DELIVERY_CANCELLED, plus return-to-pickup-only events
|
|
10
|
+
# (DELIVERY_RETURN_INITIALIZED, DASHER_CONFIRMED_RETURN_ARRIVAL,
|
|
11
|
+
# DELIVERY_RETURNED) and DELIVERY_BATCHED for pre-staged batches.
|
|
12
|
+
#
|
|
13
|
+
# No version/sequence field exists in DoorDash's payload the way Square's
|
|
14
|
+
# does, so — like OrderStatusMapper — this doesn't attempt to gate on
|
|
15
|
+
# ordering. Safety against duplicate delivery instead comes entirely from
|
|
16
|
+
# WebhookEvent's idempotency key (a genuine redelivery never reaches this
|
|
17
|
+
# class a second time) and from the state-guarded, idempotent operations
|
|
18
|
+
# below. DoorDash's own docs note events are sent "as soon as the event
|
|
19
|
+
# takes place," in the natural lifecycle order, but that ordering
|
|
20
|
+
# guarantee (or lack of one) across concurrent webhook workers is
|
|
21
|
+
# unverified until seen live — flagged here rather than assumed, same
|
|
22
|
+
# precedent as OrderStatusMapper's own comment.
|
|
23
|
+
class DeliveryStatusMapper
|
|
24
|
+
SHIP_EVENTS = %w[DASHER_DROPPED_OFF].freeze
|
|
25
|
+
CANCEL_EVENTS = %w[DELIVERY_CANCELLED].freeze
|
|
26
|
+
# Real, DoorDash-lifecycle events with no Spree shipment_state
|
|
27
|
+
# equivalent — recorded as a friendly last_status label only, same
|
|
28
|
+
# "label vs. state transition" split as OrderStatusMapper's
|
|
29
|
+
# FULFILLMENT_LABEL_STATES. DELIVERY_RETURNED (the order came back to
|
|
30
|
+
# the store, never reached the customer) arguably deserves the same
|
|
31
|
+
# treatment as a cancellation, but is left label-only until this
|
|
32
|
+
# extension has actually observed one live — not assumed.
|
|
33
|
+
LABEL_ONLY_EVENTS = %w[
|
|
34
|
+
DASHER_CONFIRMED DASHER_CONFIRMED_PICKUP_ARRIVAL DASHER_CONFIRMED_DROPOFF_ARRIVAL
|
|
35
|
+
DELIVERY_RETURN_INITIALIZED DASHER_CONFIRMED_RETURN_ARRIVAL DELIVERY_RETURNED DELIVERY_BATCHED
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
def self.call(...) = new.call(...)
|
|
39
|
+
|
|
40
|
+
def call(payload)
|
|
41
|
+
mapping = SpreeDoordash::DeliveryMapping.find_by(external_delivery_id: payload['external_delivery_id'])
|
|
42
|
+
return unless mapping
|
|
43
|
+
|
|
44
|
+
event_name = payload['event_name']
|
|
45
|
+
order = mapping.order
|
|
46
|
+
|
|
47
|
+
case event_name
|
|
48
|
+
when *SHIP_EVENTS
|
|
49
|
+
ship!(order)
|
|
50
|
+
when *CANCEL_EVENTS
|
|
51
|
+
cancel!(order)
|
|
52
|
+
when *LABEL_ONLY_EVENTS
|
|
53
|
+
# No Spree-side transition — last_status below is the only effect.
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
mapping.update!(
|
|
57
|
+
last_status: event_name || mapping.last_status,
|
|
58
|
+
tracking_url: payload['tracking_url'].presence || mapping.tracking_url,
|
|
59
|
+
dasher_name: payload['dasher_name'].presence || mapping.dasher_name,
|
|
60
|
+
dasher_phone: payload['dasher_dropoff_phone_number'].presence || mapping.dasher_phone
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def ship!(order)
|
|
67
|
+
order.shipments.each { |shipment| shipment.ship! if shipment.ready? }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def cancel!(order)
|
|
71
|
+
order.cancel! unless order.canceled?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# Requests a live DoorDash Drive delivery-fee quote for an order — the
|
|
3
|
+
# storefront-facing half of this extension (called from
|
|
4
|
+
# Spree::Calculator::Shipping::DoordashQuote during checkout, M3) and
|
|
5
|
+
# again at order.completed if the original quote expired (M4).
|
|
6
|
+
#
|
|
7
|
+
# `order.total` at whichever moment this is called is what's sent as
|
|
8
|
+
# order_value — Spree recalculates on every checkout step already, and
|
|
9
|
+
# DoorDash isn't in the money path (Spree's own payment method still
|
|
10
|
+
# charges the customer; DoorDash only uses this for delivery-fee/
|
|
11
|
+
# insurance math).
|
|
12
|
+
class Quote
|
|
13
|
+
Result = Struct.new(:fee_cents, :currency, :external_delivery_id, :expires_at, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
def self.call(...) = new.call(...)
|
|
16
|
+
|
|
17
|
+
def call(order)
|
|
18
|
+
location_mapping = location_mapping_for(order)
|
|
19
|
+
return nil unless location_mapping && order.ship_address
|
|
20
|
+
|
|
21
|
+
client = SpreeDoordash::Client.for_store(order.store || Spree::Store.default)
|
|
22
|
+
# A random suffix per attempt, not just order.number — confirmed live
|
|
23
|
+
# against a real Sandbox quote: DoorDash rejects a *second*
|
|
24
|
+
# /drive/v2/quotes call reusing the same external_delivery_id with a
|
|
25
|
+
# 409 duplicate_delivery_id, even though the first quote is still open
|
|
26
|
+
# (not yet accepted or expired). Checkout recalculates shipping rates
|
|
27
|
+
# on essentially every step, so calling this twice for the same order
|
|
28
|
+
# is the normal case, not an edge case — a stable per-order id made
|
|
29
|
+
# the DoorDash rate silently vanish (RequestError is rescued below,
|
|
30
|
+
# same as any other unserviceable-rate case) after the very first
|
|
31
|
+
# quote. QuoteMapping upserts on order, so only the most recent
|
|
32
|
+
# attempt's id is kept — the one that matters for accept (M4).
|
|
33
|
+
external_delivery_id = "spree-doordash-#{order.number}-#{SecureRandom.hex(4)}"
|
|
34
|
+
|
|
35
|
+
response = client.create_quote(build_payload(order, location_mapping, external_delivery_id))
|
|
36
|
+
mapping = persist_quote!(order, external_delivery_id, response)
|
|
37
|
+
|
|
38
|
+
Result.new(
|
|
39
|
+
fee_cents: mapping.quoted_fee_cents,
|
|
40
|
+
currency: mapping.currency,
|
|
41
|
+
external_delivery_id: mapping.external_delivery_id,
|
|
42
|
+
expires_at: mapping.quote_expires_at
|
|
43
|
+
)
|
|
44
|
+
rescue SpreeDoordash::Client::MissingCredentialsError, SpreeDoordash::RequestError => e
|
|
45
|
+
# Unserviceable address, no credential connected, DoorDash-side
|
|
46
|
+
# rejection — none of these should ever raise into checkout. The
|
|
47
|
+
# calculator (M3) treats a nil Result as "this rate isn't available",
|
|
48
|
+
# Spree's normal shape for "can't quote this," same as a carrier
|
|
49
|
+
# simply not covering an address.
|
|
50
|
+
Rails.logger.info("[SpreeDoordash] quote skipped for order #{order.number}: #{e.message}")
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def location_mapping_for(order)
|
|
57
|
+
# Queried directly rather than `order.shipments.first` — creating a
|
|
58
|
+
# shipment via `Spree::Shipment.create(order: order, ...)` (setting
|
|
59
|
+
# the FK directly, as spec factories and some checkout code paths do)
|
|
60
|
+
# doesn't invalidate an already-loaded `order` object's cached
|
|
61
|
+
# `shipments` association the way `order.shipments.create(...)`
|
|
62
|
+
# would. Querying fresh here means this is correct regardless of
|
|
63
|
+
# what the caller already touched on `order`.
|
|
64
|
+
stock_location = Spree::Shipment.where(order_id: order.id).first&.stock_location ||
|
|
65
|
+
Spree::StockLocation.find_by(default: true)
|
|
66
|
+
return nil unless stock_location
|
|
67
|
+
|
|
68
|
+
SpreeDoordash::LocationMapping.find_by(stock_location: stock_location)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_payload(order, location_mapping, external_delivery_id)
|
|
72
|
+
{
|
|
73
|
+
external_delivery_id: external_delivery_id,
|
|
74
|
+
pickup_address: format_address(location_mapping.stock_location),
|
|
75
|
+
pickup_business_name: location_mapping.stock_location.name,
|
|
76
|
+
pickup_phone_number: location_mapping.stock_location.phone.presence || '+10000000000',
|
|
77
|
+
pickup_instructions: location_mapping.stock_location.pickup_instructions,
|
|
78
|
+
dropoff_address: format_address(order.ship_address),
|
|
79
|
+
dropoff_phone_number: order.ship_address.phone.presence || '+10000000000',
|
|
80
|
+
order_value: (order.total * 100).to_i
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Spree::StockLocation and Spree::Address share the same field shape
|
|
85
|
+
# (address1/address2/city/state/zipcode/country) even though they're
|
|
86
|
+
# unrelated classes — DoorDash wants one flat string, not structured
|
|
87
|
+
# fields.
|
|
88
|
+
def format_address(record)
|
|
89
|
+
[
|
|
90
|
+
record.address1,
|
|
91
|
+
record.address2,
|
|
92
|
+
record.city,
|
|
93
|
+
record.state&.abbr || record.state_name,
|
|
94
|
+
record.zipcode,
|
|
95
|
+
record.country&.iso_name
|
|
96
|
+
].compact_blank.join(', ')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def persist_quote!(order, external_delivery_id, response)
|
|
100
|
+
mapping = SpreeDoordash::QuoteMapping.find_or_initialize_by(order: order)
|
|
101
|
+
mapping.update!(
|
|
102
|
+
external_delivery_id: external_delivery_id,
|
|
103
|
+
quoted_fee_cents: response['fee'],
|
|
104
|
+
currency: response['currency'] || 'USD',
|
|
105
|
+
# DoorDash's own documented quote validity window.
|
|
106
|
+
quote_expires_at: 5.minutes.from_now,
|
|
107
|
+
pickup_time_estimated: parse_time(response['pickup_time_estimated']),
|
|
108
|
+
dropoff_time_estimated: parse_time(response['dropoff_time_estimated']),
|
|
109
|
+
raw_response: response
|
|
110
|
+
)
|
|
111
|
+
mapping
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_time(value)
|
|
115
|
+
value.present? ? Time.iso8601(value) : nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# Verifies inbound DoorDash webhooks. Architecturally different from
|
|
3
|
+
# SpreeSquare::WebhookVerifier: DoorDash has no HMAC signing scheme for
|
|
4
|
+
# webhooks. Per DoorDash's own docs (Configure your webhook in the
|
|
5
|
+
# Portal): Basic Auth here means "enter the contents you'd like DoorDash
|
|
6
|
+
# to send in the HTTP Authorization header" — a static string DoorDash
|
|
7
|
+
# echoes back verbatim on every call, not a credential DoorDash itself
|
|
8
|
+
# authenticates. Comparison is still constant-time to avoid a timing
|
|
9
|
+
# oracle on that string.
|
|
10
|
+
class WebhookVerifier
|
|
11
|
+
def self.valid?(authorization_header:, expected_token:)
|
|
12
|
+
return false if authorization_header.blank? || expected_token.blank?
|
|
13
|
+
|
|
14
|
+
ActiveSupport::SecurityUtils.secure_compare(authorization_header, expected_token)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module SpreeDoordash
|
|
2
|
+
# Events & Subscribers is the preferred pattern for this kind of side
|
|
3
|
+
# effect (per this app's own CLAUDE.md conventions) — react to
|
|
4
|
+
# order.completed without touching Spree::Order itself. Mirrors
|
|
5
|
+
# SpreeSquare::OrderCompletedSubscriber's shape exactly; independent of
|
|
6
|
+
# it — spree_square's own order push (kitchen ticket) still fires for
|
|
7
|
+
# every order regardless of fulfillment method, DoorDash only decides who
|
|
8
|
+
# carries it out the door.
|
|
9
|
+
class OrderCompletedSubscriber < Spree::Subscriber
|
|
10
|
+
subscribes_to 'order.completed'
|
|
11
|
+
|
|
12
|
+
def handle(event)
|
|
13
|
+
order = Spree::Order.find_by_prefix_id(event.payload['id'])
|
|
14
|
+
return unless order
|
|
15
|
+
return unless doordash_delivery?(order)
|
|
16
|
+
|
|
17
|
+
SpreeDoordash::DeliveryDispatchJob.perform_later(order.id)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Only dispatch orders actually fulfilled via a "DoorDash Delivery"
|
|
23
|
+
# shipping method — pickup/other-carrier orders should never reach
|
|
24
|
+
# DoorDash at all.
|
|
25
|
+
def doordash_delivery?(order)
|
|
26
|
+
order.shipments.any? { |shipment| shipment.shipping_method&.calculator.is_a?(Spree::Calculator::Shipping::DoordashQuote) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<% content_for :page_title do %>
|
|
2
|
+
DoorDash Connection
|
|
3
|
+
<% end %>
|
|
4
|
+
|
|
5
|
+
<div class="card w-full max-w-2xl">
|
|
6
|
+
<div class="card-header flex items-center justify-between <%= 'bg-green-50' if @credential.persisted? %>">
|
|
7
|
+
<span class="font-medium">DoorDash Drive</span>
|
|
8
|
+
<% if @credential.persisted? %>
|
|
9
|
+
<span class="inline-flex items-center text-xs font-medium text-green-700">
|
|
10
|
+
<span class="w-1.5 h-1.5 rounded-full bg-green-500 mr-1"></span>
|
|
11
|
+
Configured
|
|
12
|
+
</span>
|
|
13
|
+
<% else %>
|
|
14
|
+
<span class="inline-flex items-center text-xs font-medium text-gray-500">
|
|
15
|
+
<span class="w-1.5 h-1.5 rounded-full bg-gray-400 mr-1"></span>
|
|
16
|
+
Not configured
|
|
17
|
+
</span>
|
|
18
|
+
<% end %>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<%= form_for @credential, url: admin_doordash_credential_path, method: :patch,
|
|
22
|
+
as: :spree_doordash_credential, html: { class: 'card-body space-y-4' } do |f| %>
|
|
23
|
+
<p class="text-sm text-gray-600">
|
|
24
|
+
Create an access key in the
|
|
25
|
+
<a href="https://developer.doordash.com" target="_blank" rel="noopener" class="text-blue-600 underline">DoorDash Developer Portal</a>
|
|
26
|
+
and paste its three values below. Stored encrypted — no OAuth redirect needed.
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<div>
|
|
30
|
+
<%= f.label :doordash_environment, 'Environment', class: 'label' %>
|
|
31
|
+
<%= f.select :doordash_environment, %w[sandbox production], {}, class: 'select' %>
|
|
32
|
+
</div>
|
|
33
|
+
<div>
|
|
34
|
+
<%= f.label :developer_id, 'Developer ID', class: 'label' %>
|
|
35
|
+
<%= f.text_field :developer_id, class: 'form-control' %>
|
|
36
|
+
</div>
|
|
37
|
+
<div>
|
|
38
|
+
<%= f.label :key_id, 'Key ID', class: 'label' %>
|
|
39
|
+
<%= f.text_field :key_id, class: 'form-control' %>
|
|
40
|
+
</div>
|
|
41
|
+
<div>
|
|
42
|
+
<%= f.label :signing_secret, 'Signing Secret', class: 'label' %>
|
|
43
|
+
<%= f.password_field :signing_secret, value: '', placeholder: (@credential.persisted? ? '•••••••• (leave blank to keep current)' : nil), class: 'form-control' %>
|
|
44
|
+
</div>
|
|
45
|
+
<div>
|
|
46
|
+
<%= f.label :webhook_basic_auth_token, 'Webhook Basic Auth value', class: 'label' %>
|
|
47
|
+
<%= f.password_field :webhook_basic_auth_token, value: '', placeholder: (@credential.webhook_basic_auth_token.present? ? '•••••••• (leave blank to keep current)' : 'e.g. Basic dGVzdDp0ZXN0'), class: 'form-control' %>
|
|
48
|
+
<p class="text-xs text-gray-500 mt-1">The exact Authorization header value configured for this store's webhook endpoint in DoorDash's dashboard.</p>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div class="card-footer -mx-4 -mb-4">
|
|
52
|
+
<%= f.submit 'Save', class: 'btn btn-primary' %>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ignored_warnings": [
|
|
3
|
+
{
|
|
4
|
+
"warning_type": "Cross-Site Request Forgery",
|
|
5
|
+
"warning_code": 86,
|
|
6
|
+
"fingerprint": "94410153d7554be55a85180f6c1c03417ecd7547075ef01e8c34b9ceb69785c6",
|
|
7
|
+
"check_name": "ForgerySetting",
|
|
8
|
+
"message": "`protect_from_forgery` should be configured with `with: :exception`",
|
|
9
|
+
"file": "app/controllers/spree_doordash/webhooks_controller.rb",
|
|
10
|
+
"line": 17,
|
|
11
|
+
"link": "https://brakemanscanner.org/docs/warning_types/cross-site_request_forgery/",
|
|
12
|
+
"code": "protect_from_forgery(:with => :null_session)",
|
|
13
|
+
"render_path": null,
|
|
14
|
+
"location": {
|
|
15
|
+
"type": "controller",
|
|
16
|
+
"controller": "SpreeDoordash::WebhooksController"
|
|
17
|
+
},
|
|
18
|
+
"user_input": null,
|
|
19
|
+
"confidence": "Medium",
|
|
20
|
+
"cwe_id": [
|
|
21
|
+
352
|
|
22
|
+
],
|
|
23
|
+
"note": "Intentional, not a false positive to fix: this is a webhook endpoint authenticated by DoorDash's Basic Auth header (SpreeDoordash::WebhookVerifier), not by a Rails session, so there's no session-based state for CSRF to protect in the first place. `:exception` (Brakeman's preferred default) would make every legitimate webhook POST raise ActionController::InvalidAuthenticityToken, since DoorDash never sends a Rails CSRF token — that would break the endpoint entirely, not harden it. `:null_session` is the correct, standard Rails pattern for an unauthenticated-by-session API/webhook endpoint. Same finding, same justification, as the identical pattern in the sibling spree_square gem's own WebhooksController."
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"updated": "2026-08-13 23:33:31 +0530",
|
|
27
|
+
"brakeman_version": "8.0.5"
|
|
28
|
+
}
|