Class: Shipping::Curri

Inherits:
Base
  • Object
show all
Defined in:
app/services/shipping/curri.rb

Overview

Carrier adapter for Curri (same-day / hot-shot final-mile courier — a
driver with a car/truck/flatbed picks up at the warehouse and delivers
directly). Wraps CurriClient into the standard carrier
interface: #find_rates → deliveryQuote, #label → bookDelivery,
#void → cancelDelivery.

Shape notes (why this adapter looks like Freightquote, not a label
carrier): Curri issues NO labels, manifests, or barcodes. Booking returns
a delivery id ("del_…") that we store as delivery.freight_order_number
(it is the void identifier — WyShipping.void_delivery already prefers
freight_order_number), a trackingId we store as each shipment's
tracking number, and a live trackingUrl. There is no label PDF to
render (Delivery::GenerateLabels skips document generation when the
labels array carries no :image), and booking is synchronous — no async
worker.

CRM-only: a quote REQUIRES full street addresses on both ends, so www
zip-estimate flows fail cleanly (see the guard in #find_rates).
US-only per Curri's coverage.

Constant Summary collapse

SERVICE_CODE =
'CURRI'
COST_DISCREPANCY_THRESHOLD =

Re-rate drift warning thresholds at label time, mirroring
Shipping::SpeedeeDelivery — we always BOOK on the fresh quote (Curri
quote ids may be stale by label time), these only decide when the drift
from the operator-selected price is loud enough to report.

100.0
COST_DISCREPANCY_THRESHOLD_RATIO =
0.25
VEHICLE_CAPACITIES =

Vehicle-class capacities as [method, max_lb, max_length_in], ordered
smallest → largest: #pick_delivery_method takes the first row that
fits, so the ORDER is the cost control. Fees scale steeply with class
(sandbox 2026-07-24, 40-mile Lake Zurich → Chicago run: car $76.76 →
sprinter-van $148.65 → box-truck $326.07 → flatbed $500.44).

Weights are Curri's published per-vehicle maxima
(help.curri.com/en/articles/4530867-vehicle-types-and-capacities,
re-read 2026-08-05). Curri publishes NO dimensions, so the length caps
are ours — deliberately short, since overflowing a class means the
driver refuses the load at our dock.
ponytail: estimated length caps; upgrade path is the per-class quote
sweep in doc/tasks/202608050930_CURRI_LTL_PHASE_2.md.

truck (bare pickup) is deliberately absent, not forgotten: it caps at
the same 1,500 lb as cargo-van but is open-bed, and
truck-with-pipe-rack already covers the long loads a pickup is wanted
for. Add it back only if the sweep shows it undercutting the van.

[
  ['car', 200, 48],
  ['suv', 800, 72],
  ['cargo-van', 1_500, 108],
  ['truck-with-pipe-rack', 1_500, Float::INFINITY],
  ['sprinter-van', 4_000, 144],
  ['box-truck', 10_000, 192],
  ['flatbed', Float::INFINITY, Float::INFINITY] # catch-all: always fits
].freeze
SERVICE_STATES =

Regional footprint, seeded from Spee-Dee's upper-Midwest list
(SpeedeeService::SERVICE_STATES). DELIBERATELY DUPLICATED, not
referenced: the two carriers share a starting footprint today but have
nothing structurally in common — Spee-Dee's is its published parcel
service area, Curri's is our own call about where a same-day courier is
worth quoting. Either list must be able to move without dragging the
other with it.

Curri itself has no coverage limit — it's a nationwide driver
marketplace, and its API exposes no serviceability query and no maximum
distance (docs.curri.com, verified 2026-08-05). The fee simply climbs
with distance, so this gate is ours to define, not theirs.
ponytail: coarse state check, same shape as Spee-Dee's; a radius from
the origin is the upgrade path once quote sampling pins the mileage
where Curri stops being competitive.

%w[IL IN IA KS MI MN MO ND NE SD WI].to_set.freeze
DEFAULT_PICKUP_LEAD_TIME =

Fallback pickup lead time when the delivery carries no ship date —
far enough out that Curri's scheduling accepts it, same-day enough to
honor the service's point.

2.hours

Instance Attribute Summary

Attributes inherited from Base

#address, #address2, #address3, #address_residential, #attention_name, #billing_account, #billing_country, #billing_zip, #ci_comments, #city, #close_report_only, #cod_amount, #cod_collection_type, #company, #country, #currency_code, #data, #debug, #declared_value, #delivery_instructions, #delivery_total_value, #description, #discount_price, #dropoff_type, #email, #eta, #export_reason, #freight_class, #freightquote_authorization_url, #freightquote_client_id, #freightquote_client_secret, #freightquote_customer_code, #freightquote_events_url, #freightquote_rating_url, #freightquote_shipping_url, #freightquote_voiding_url, #handling_instructions, #has_loading_dock, #image_type, #importer_of_record, #include_first_class_mail_options, #insured_value, #is_construction_site, #is_trade_show, #label_type, #last_request_payload, #last_request_quote_id, #last_response_payload, #limited_access, #line_items, #master_tracking_number, #measure_height, #measure_length, #measure_units, #measure_width, #media_mail, #multiple_piece_shipping, #negotiated_rates, #package, #package_count, #package_sequence_number, #package_total, #packages, #packaging_type, #paperless, #pay_type, #phone, #pickup_datetime, #pickup_instructions, #plain_response, #price, #rate_data, #reference_number_1, #reference_number_2, #reference_number_3, #reference_number_code_1, #reference_number_code_2, #required, #requires_appointment, #requires_inside_delivery, #requires_liftgate, #response, #response_headers, #response_status, #return_to_address, #return_to_address2, #return_to_address3, #return_to_address_residential, #return_to_attention_name, #return_to_city, #return_to_company, #return_to_country, #return_to_email, #return_to_has_loading_dock, #return_to_is_construction_site, #return_to_is_trade_show, #return_to_limited_access, #return_to_name, #return_to_phone, #return_to_requires_appointment, #return_to_requires_inside_delivery, #return_to_requires_liftgate, #return_to_state, #return_to_zip, #rl_carriers_api_key, #rl_carriers_shipping_url, #saturday_delivery, #sender_address, #sender_address2, #sender_address3, #sender_address_residential, #sender_attention_name, #sender_city, #sender_company, #sender_country, #sender_email, #sender_has_loading_dock, #sender_is_construction_site, #sender_is_trade_show, #sender_limited_access, #sender_name, #sender_phone, #sender_requires_appointment, #sender_requires_inside_delivery, #sender_requires_liftgate, #sender_state, #sender_tax_identification_number, #sender_zip, #service_code, #service_type, #services, #ship_date, #shipengine_api_key, #shipengine_canadapost_account_id, #shipengine_canadapost_parent_account_number, #shipengine_canpar_account_id, #shipengine_dhl_express_account_id, #shipengine_fed_ex_account_id, #shipengine_fed_ex_ca_account_id, #shipengine_purolator_account_id, #shipengine_ups_account_id, #shipengine_ups_ca_account_id, #shipengine_usps_account_id, #shipper_address, #shipper_address2, #shipper_address3, #shipper_address_residential, #shipper_attention_name, #shipper_city, #shipper_company, #shipper_country, #shipper_email, #shipper_has_loading_dock, #shipper_is_construction_site, #shipper_is_trade_show, #shipper_limited_access, #shipper_name, #shipper_phone, #shipper_requires_appointment, #shipper_requires_inside_delivery, #shipper_requires_liftgate, #shipper_state, #shipper_zip, #signature_confirmation, #skip_png_download, #skip_rate_test, #special_instructions, #state, #tax_identification_number, #time_in_transit, #total_shipment_weight, #transaction_type, #weight, #weight_units, #zip

Instance Method Summary collapse

Methods inherited from Base

#fedex, #initialize, #purolator, state_from_zip, #ups, #ups_freight

Constructor Details

This class inherits a constructor from Shipping::Base

Instance Method Details

#find_rates(logger = nil) ⇒ Hash

Quotes one Curri delivery for the current shipment. Never raises for a
shipment Curri can't carry — an out-of-region or incomplete-address
delivery comes back as a failed response so parallel rate-shopping just
skips Curri (see #disqualification_reason).

Parameters:

  • logger (Logger, nil) (defaults to: nil)

    defaults to Rails.logger

Returns:

  • (Hash)

    :success, :rates (zero or one estimate), :message
    on failure, plus the :request/:xml audit strings every adapter
    returns



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'app/services/shipping/curri.rb', line 92

def find_rates(logger = nil)
  logger ||= Rails.logger
  @country ||= 'US'
  @sender_country ||= 'US'
  logger.debug 'Shipping Curri find_rates'

  rate_estimates = []
  nil

  if (failure_message = disqualification_reason)
    # Clean failure (not an exception): parallel rate-shopping and www
    # flows just skip Curri.
    logger.debug { "Curri disqualified: #{failure_message}" }
  else
    result = client.quote(pickup: pickup_hash, dropoff: dropoff_hash, packages: package_hashes,
                          delivery_method: pick_delivery_method, priority: CurriClient::PRIORITY_SCHEDULED)
    if result[:status] == :ok
      rate_estimates << build_estimate(result)
    else
      failure_message = result[:message]
    end
  end

  response = {}
  response[:success] = rate_estimates.any?
  response[:message] = "Curri: #{failure_message}" if failure_message.present?
  response[:request] = { pickup: pickup_hash, dropoff: dropoff_hash, packages: package_hashes }.to_s
  response[:xml] = rate_estimates.first&.dig(:rate_data).to_s
  response[:rates] = rate_estimates

  # allows for things like response.success (same convention as every
  # other adapter in this tree) — via HashMethodAccess
  response.extend(HashMethodAccess)

  response
end

#label(_return_label = false, logger = nil) ⇒ Hash

Books the delivery. Always re-quotes first — the CRM-time quote id may
be stale by label time, and booking on a fresh quote pays the current
price — then warns (without blocking) when that price drifted from the
operator-selected rate beyond the Spee-Dee-style thresholds.

Parameters:

  • _return_label (Boolean) (defaults to: false)

    unused — Curri issues no labels at all,
    so there is no return-label variant to switch on

  • logger (Logger, nil) (defaults to: nil)

    defaults to Rails.logger

Returns:

  • (Hash)

    :labels (one entry per package, all carrying the same
    trackingId), :shipment_identification_number (trackingId →
    delivery.master_tracking_number), :freight_order_number (the
    Curri delivery id "del_…" — the void/webhook correlation key),
    :total_charges, :tracking_url, :rate_data.

Raises:

  • (ShippingError)

    when the delivery is disqualified, the re-quote
    fails, or booking fails



144
145
146
147
148
149
150
151
152
153
154
155
156
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
186
187
188
# File 'app/services/shipping/curri.rb', line 144

def label(_return_label = false, logger = nil)
  logger ||= Rails.logger
  @required = %i[address city state zip sender_address sender_city sender_state sender_zip packages]
  check_required

  raise ShippingError, "Curri cannot ship this delivery: #{disqualification_reason}" if disqualification_reason

  quoted_rate = ActiveSupport::HashWithIndifferentAccess.new(@rate_data || {})
  fresh = client.quote(pickup: pickup_hash, dropoff: dropoff_hash, packages: package_hashes,
                       delivery_method: quoted_rate[:delivery_method].presence || pick_delivery_method,
                       priority: quoted_rate[:priority].presence || CurriClient::PRIORITY_SCHEDULED)
  raise ShippingError, "Curri re-quote failed: #{fresh[:message]}" unless fresh[:status] == :ok

  warn_on_price_drift(quoted_rate[:total_price].to_f, fresh[:fee])

  booking = client.book_delivery(
    quote_id: fresh[:quote_id],
    pickup: pickup_hash,
    dropoff: dropoff_hash,
    packages: package_hashes,
    scheduled_at: pickup_scheduled_at,
    po_number: @reference_number_3.presence,
    order_number: @reference_number_1.presence,
    pickup_note: @special_instructions.presence
  )
  raise ShippingError, "Curri booking failed: #{booking[:message]}" unless booking[:status] == :ok

  logger.debug { "Curri booked #{booking[:delivery_id]} tracking #{booking[:tracking_id]}" }

  # One label entry per package so Delivery::GenerateLabels marks every
  # awaiting-label shipment label_generated — a Curri delivery has ONE
  # tracking id covering all packages (no :image ⇒ no PDF generation).
  labels = Array.new(@packages.size) { { tracking_number: booking[:tracking_id] } }
  {
    labels: labels.freeze,
    shipment_identification_number: booking[:tracking_id],
    freight_order_number: booking[:delivery_id],
    total_charges: booking[:price] || fresh[:fee],
    tracking_url: booking[:tracking_url],
    rate_data: quoted_rate.merge(
      quote_id: fresh[:quote_id], total_price: booking[:price] || fresh[:fee],
      curri_delivery_id: booking[:delivery_id], tracking_url: booking[:tracking_url]
    )
  }
end

#void(identifier) ⇒ Hash

Cancels the Curri delivery. identifier arrives from
WyShipping.void_delivery as delivery.freight_order_number — the Curri
delivery id recorded by #label.

The reason: we send is WRITE-ONLY: Curri discards it and reports its
own category back on the delivery (cancellationReason came back as
"User Requested" on a sandbox cancel, 2026-08-06; their docs also list
"Driver Issue"). So the string below never reaches a ShipmentEvent
description — WebhookProcessors::CurriProcessor#cancellation_suffix
renders Curri's category, not ours. Don't encode CRM context here
expecting to read it back.

Parameters:

  • identifier (String)

    the Curri delivery id ("del_…")

Returns:

  • (Hash)

    :void_request_xml / :void_response_xml audit strings

Raises:

  • (ShippingError)

    when the identifier is blank or Curri refuses the
    cancellation (e.g. DELIVERY_CANNOT_BE_CANCELED on a terminal delivery)



206
207
208
209
210
211
212
213
# File 'app/services/shipping/curri.rb', line 206

def void(identifier)
  raise ShippingError, 'Curri void requires the Curri delivery id (freight_order_number)' if identifier.blank?

  result = client.cancel_delivery(delivery_id: identifier, reason: 'Voided from Heatwave CRM')
  raise ShippingError, "Curri void failed: #{result[:message]}" unless result[:status] == :ok

  { void_request_xml: { cancelDelivery: identifier }.to_json, void_response_xml: result.except(:raw).to_json }
end