Class: Shipping::CurriClient
- Inherits:
-
BaseService
- Object
- BaseService
- Shipping::CurriClient
- 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 authbase64(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
errorsarray (and
machine-readableextensions.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);distanceis
meters,duration/estimatedTravelTimeseconds. 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 REQUIRESscheduledAt(ISO8601) —
BOOKING_FAILED with "Please provide a scheduledAt time" otherwise.
Constant Summary collapse
- REQUEST_TIMEOUT =
15- PRIORITY_SCHEDULED =
Priorities (quote
feeComparisonreturns 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 (thedeliveryMethodsquery
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
Instance Method Summary collapse
-
#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.
-
#cancel_delivery(delivery_id:, reason:) ⇒ Hash
Cancels a delivery.
-
#get_delivery(delivery_id:) ⇒ Hash
Fetches a delivery's full current state (status, driver, GPS, images, cancellationReason).
-
#quote(pickup:, dropoff:, packages:, delivery_method:, priority: PRIORITY_SCHEDULED) ⇒ Hash
Requests a delivery quote.
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.
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? = { poNumber: po_number, orderNumber: order_number, pickupNote: pickup_note }.compact data[:deliveryMeta] = if .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).
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.
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.
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 |