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
+ 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,5 @@
1
+ <% content_for :page_title do %>
2
+ DoorDash Deliveries
3
+ <% end %>
4
+
5
+ <%= render_table @collection, :doordash_delivery_mappings %>
@@ -0,0 +1,5 @@
1
+ <% content_for :page_title do %>
2
+ DoorDash Webhook Events
3
+ <% end %>
4
+
5
+ <%= render_table @collection, :doordash_webhook_events %>
@@ -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
+ }