Class: Shipping::Curri
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 ascargo-vanbut is open-bed, and
truck-with-pipe-rackalready 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
-
#find_rates(logger = nil) ⇒ Hash
Quotes one Curri delivery for the current shipment.
-
#label(_return_label = false, logger = nil) ⇒ Hash
Books the delivery.
-
#void(identifier) ⇒ Hash
Cancels the Curri delivery.
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).
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 ( = disqualification_reason) # Clean failure (not an exception): parallel rate-shopping and www # flows just skip Curri. logger.debug { "Curri disqualified: #{}" } 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 = result[:message] end end response = {} response[:success] = rate_estimates.any? response[:message] = "Curri: #{}" if .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.
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.
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 |