Class: Shipping::CurriClient

Inherits:
BaseService show all
Defined in:
app/services/shipping/curri_client.rb

Overview

Faraday-backed client for Curri's GraphQL API (same-day / hot-shot final-mile
courier — a driver with a car/truck/flatbed picks up at our warehouse and
delivers directly; no labels, no manifests, no barcodes).

Phase 1 of the Curri integration — see
doc/tasks/202607241449_CURRI_API_INTEGRATION.md. Modeled on
Shipping::SpeedeeDispatchScienceClient: same config no-op guard, same
structured { status: :ok/:error } returns, same retry split (retrying
connection for idempotent reads, NO retry for the non-idempotent booking —
a retry after Curri has accepted a bookDelivery dispatches a duplicate real
driver).

API facts, ALL verified live against the sandbox on 2026-07-24 (introspection
is disabled on api.curri.com, so every type name below was confirmed by
executing real operations — Apollo's validation errors name the expected type
on mismatch):

  • Single endpoint POST https://api.curri.com/graphql, JSON body
    { query:, variables: }, HTTP Basic auth base64(user_id:api_key).
    Sandbox = the sandbox API key on the SAME endpoint; sandbox deliveries
    simulate a real lifecycle (auto-assigned driver, statuses advance).
  • GraphQL failures come back as HTTP 200 with an errors array (and
    machine-readable extensions.code, e.g. DELIVERY_METHOD_INVALID,
    BOOKING_FAILED, DELIVERY_CANNOT_BE_CANCELED) — a 200 is NOT success.
  • Money is INTEGER CENTS (quote fee: 12746 == $127.46); distance is
    meters, duration/estimatedTravelTime seconds. The delivery/book
    responses return these numerics as STRINGS ("12746"); the quote returns
    integers. This client normalizes money to Float dollars at the boundary —
    everything downstream (rate estimates, ShippingCost) speaks dollars.
  • Argument types: deliveryQuote(origin:/destination: AddressInput!,
    manifestItems: [ManifestItemInput!], deliveryMethod:/priority: String);
    bookDelivery(data: BookDeliveryInput!); delivery(id: IDCustomScalar);
    cancelDelivery(id: String, reason: String).
  • Booking a scheduled-priority quote REQUIRES scheduledAt (ISO8601) —
    BOOKING_FAILED with "Please provide a scheduledAt time" otherwise.

Constant Summary collapse

REQUEST_TIMEOUT =
15
PRIORITY_SCHEDULED =

Priorities (quote feeComparison returns a fee for each).

'scheduled'
PRIORITY_RUSH =
'rush'
PRIORITY_SAMEDAY =
'sameday'
DELIVERY_METHODS =

Vehicle classes accepted by deliveryQuote/bookDelivery, discovered
empirically against the sandbox 2026-07-24 (the deliveryMethods query
exists but is UNAUTHORIZED for our API role; invalid values fail with
DELIVERY_METHOD_INVALID). Ordered cheapest → priciest for a 40-mile
Lake Zurich → Chicago run: car $76.76, suv $94.43, truck (pickup) $127.46,
cargo-van $136.35, truck-with-pipe-rack $144.25, sprinter-van $148.65,
box-truck $326.07, flatbed $500.44.

%w[car suv truck cargo-van truck-with-pipe-rack sprinter-van box-truck flatbed].freeze
QUOTE_QUERY =
<<~GRAPHQL
  query DeliveryQuote($origin: AddressInput!, $destination: AddressInput!, $deliveryMethod: String, $manifestItems: [ManifestItemInput!], $priority: String) {
    deliveryQuote(origin: $origin, destination: $destination, deliveryMethod: $deliveryMethod, manifestItems: $manifestItems, priority: $priority) {
      id fee distance duration deliveryMethod priority
      feeComparison { rush sameday scheduled }
    }
  }
GRAPHQL
BOOK_MUTATION =
<<~GRAPHQL
  mutation BookDelivery($data: BookDeliveryInput!) {
    bookDelivery(data: $data) {
      id price deliveryMethod trackingUrl trackingId createdAt scheduledAt
      deliveryStatus { name code }
    }
  }
GRAPHQL
DELIVERY_QUERY =
<<~GRAPHQL
  query Delivery($id: IDCustomScalar) {
    delivery(id: $id) {
      id createdAt distance price estimatedTravelTime deliveryMethod deliveredAt
      trackingUrl cancellationReason
      deliveryStatus { name code }
      deliveryMeta { poNumber orderNumber pickupNote dropoffNote }
      origin { name addressLine1 city state postalCode latitude longitude }
      destination { name addressLine1 city state postalCode latitude longitude }
      driver { firstName lastName phoneNumber lastKnownLocation { latitude longitude } }
      images
    }
  }
GRAPHQL
CANCEL_MUTATION =
<<~GRAPHQL
  mutation CancelDelivery($id: String, $reason: String) {
    cancelDelivery(id: $id, reason: $reason) { id }
  }
GRAPHQL

Instance Attribute Summary

Attributes inherited from BaseService

#options

Instance Method Summary collapse

Methods inherited from BaseService

#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #process, #tagged_logger

Constructor Details

This class inherits a constructor from BaseService

Instance Method Details

#book_delivery(quote_id:, pickup:, dropoff:, packages:, scheduled_at: nil, po_number: nil, order_number: nil, pickup_note: nil) ⇒ Hash

Books a delivery from a prior #quote. NON-IDEMPOTENT — runs on the
no-retry connection: a retry after Curri has accepted the booking
dispatches a duplicate REAL driver.

Curri accepts the full origin/destination/manifest alongside the quote id
(the docs' own example does the same); passing them keeps the booking
self-describing and pins the contacts/PO metadata the quote never carried.

Parameters:

  • quote_id (String)

    deliveryQuote id ("quote_…")

  • pickup (Hash)

    same shape as #quote

  • dropoff (Hash)

    same shape as #quote

  • packages (Array<Hash>)

    same shape as #quote

  • scheduled_at (Time, nil) (defaults to: nil)

    REQUIRED for scheduled-priority quotes

  • po_number (String, nil) (defaults to: nil)

    our PO reference (deliveryMeta.poNumber)

  • order_number (String, nil) (defaults to: nil)

    our order reference

  • pickup_note (String, nil) (defaults to: nil)

    note shown to the driver at pickup

Returns:

  • (Hash)

    +{ status: :ok, delivery_id:, price:, tracking_id:,
    tracking_url:, delivery_status:, scheduled_at:, raw: }+ (+price+ in
    Float dollars, +delivery_status+ the status code e.g. "scheduled") or
    +{ status: :error, message:, raw: }+.



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'app/services/shipping/curri_client.rb', line 157

def book_delivery(quote_id:, pickup:, dropoff:, packages:, scheduled_at: nil, po_number: nil, order_number: nil, pickup_note: nil)
  data = {
    deliveryQuoteId: quote_id,
    origin: address_hash(pickup).merge(name: pickup[:company].presence || pickup[:name]),
    destination: address_hash(dropoff).merge(name: dropoff[:company].presence || dropoff[:name]),
    pickupContact: contact_hash(pickup),
    dropoffContact: contact_hash(dropoff),
    manifestItems: packages.map { |package| manifest_item_hash(package) }
  }
  data[:scheduledAt] = scheduled_at.iso8601 if scheduled_at.present?
  meta = { poNumber: po_number, orderNumber: order_number, pickupNote: pickup_note }.compact
  data[:deliveryMeta] = meta if meta.any?

  execute(BOOK_MUTATION, { data: data }, retry_allowed: false) do |response_data|
    delivery = response_data['bookDelivery']
    return { status: :error, message: 'Unexpected Curri booking response', raw: response_data } unless delivery.is_a?(Hash) && delivery['id'].present?

    {
      status: :ok,
      delivery_id: delivery['id'],
      price: cents_to_dollars(delivery['price']),
      tracking_id: delivery['trackingId'],
      tracking_url: delivery['trackingUrl'],
      delivery_status: delivery.dig('deliveryStatus', 'code'),
      scheduled_at: delivery['scheduledAt'],
      raw: delivery
    }
  end
end

#cancel_delivery(delivery_id:, reason:) ⇒ Hash

Cancels a delivery. Safe on the retrying connection: cancelling an
already-cancelled (or otherwise terminal) delivery fails cleanly with
DELIVERY_CANNOT_BE_CANCELED — no duplicate side effects (verified live).

Parameters:

  • delivery_id (String)

    the delivery id ("del_…")

  • reason (String)

    shown to Curri ops; surfaces as cancellationReason

Returns:

  • (Hash)

    +{ status: :ok, delivery_id: }+ or
    +{ status: :error, message:, raw: }+.



212
213
214
215
216
217
218
219
# File 'app/services/shipping/curri_client.rb', line 212

def cancel_delivery(delivery_id:, reason:)
  execute(CANCEL_MUTATION, { id: delivery_id, reason: reason }) do |data|
    cancelled_id = data.dig('cancelDelivery', 'id')
    return { status: :error, message: 'Unexpected Curri cancel response', raw: data } if cancelled_id.blank?

    { status: :ok, delivery_id: cancelled_id }
  end
end

#get_delivery(delivery_id:) ⇒ Hash

Fetches a delivery's full current state (status, driver, GPS, images,
cancellationReason). The webhook payload carries the same data — this is
the pull-side backstop and the manual-triage tool.

Parameters:

  • delivery_id (String)

    the delivery id ("del_…") from #book_delivery

Returns:

  • (Hash)

    +{ status: :ok, delivery:, status_code: }+ (+delivery+ is
    the raw GraphQL hash — numerics arrive as strings on this endpoint) or
    +{ status: :error, message:, raw: }+.



195
196
197
198
199
200
201
202
# File 'app/services/shipping/curri_client.rb', line 195

def get_delivery(delivery_id:)
  execute(DELIVERY_QUERY, { id: delivery_id }) do |data|
    delivery = data['delivery']
    return { status: :error, message: 'Unexpected Curri delivery response', raw: data } unless delivery.is_a?(Hash) && delivery['id'].present?

    { status: :ok, delivery: delivery, status_code: delivery.dig('deliveryStatus', 'code') }
  end
end

#quote(pickup:, dropoff:, packages:, delivery_method:, priority: PRIORITY_SCHEDULED) ⇒ Hash

Requests a delivery quote.

Parameters:

  • pickup (Hash)

    :address_line1, :address_line2, :company, :city,
    :state, :zip, :name, :phone (same shape as the Spee-Dee client)

  • dropoff (Hash)

    same shape as +pickup+

  • packages (Array<Hash>)

    each: :weight (lb), :length, :width,
    :height (in), optional :description, :quantity (default 1)

  • delivery_method (String)
  • priority (String) (defaults to: PRIORITY_SCHEDULED)

    scheduled (default) / rush / sameday

Returns:

  • (Hash)

    +{ status: :ok, quote_id:, fee:, fee_comparison:,
    distance:, duration:, delivery_method:, priority:, raw: }+ — +fee+ and
    the +fee_comparison+ values in Float DOLLARS (converted from Curri's
    cents), +distance+ meters, +duration+ seconds — or
    +{ status: :error, message:, raw: }+.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'app/services/shipping/curri_client.rb', line 111

def quote(pickup:, dropoff:, packages:, delivery_method:, priority: PRIORITY_SCHEDULED)
  variables = {
    origin: address_hash(pickup),
    destination: address_hash(dropoff),
    deliveryMethod: delivery_method,
    manifestItems: packages.map { |package| manifest_item_hash(package) },
    priority: priority
  }
  execute(QUOTE_QUERY, variables) do |data|
    quote = data['deliveryQuote']
    return { status: :error, message: 'Unexpected Curri quote response', raw: data } unless quote.is_a?(Hash) && quote['fee'].present?

    {
      status: :ok,
      quote_id: quote['id'],
      fee: cents_to_dollars(quote['fee']),
      fee_comparison: (quote['feeComparison'] || {}).transform_values { |cents| cents_to_dollars(cents) },
      distance: quote['distance'].to_i,
      duration: quote['duration'].to_i,
      delivery_method: quote['deliveryMethod'],
      priority: quote['priority'],
      raw: quote
    }
  end
end