module ActiveShipping class CanadaPost < Carrier # NOTE! # A Merchant CPC Id must be assigned to you by Canada Post # CPC_DEMO_XML is just a public domain account for testing class CanadaPostRateResponse < RateResponse attr_reader :boxes, :postal_outlets def initialize(success, message, params = {}, options = {}) @boxes = options[:boxes] @postal_outlets = options[:postal_outlets] super end end cattr_reader :name, :name_french @@name = "Canada Post" @@name_french = "Postes Canada" Box = Struct.new(:name, :weight, :expediter_weight, :length, :width, :height, :packedItems) PackedItem = Struct.new(:quantity, :description) PostalOutlet = Struct.new(:sequence_no, :distance, :name, :business_name, :postal_address, :business_hours) URL = "http://sellonline.canadapost.ca:30000" DTD_NAME = 'eparcel' DTD_URI = "http://sellonline.canadapost.ca/DevelopersResources/protocolV3/eParcel.dtd" RESPONSE_CODES = { '1' => "All calculation was done", '2' => "Default shipping rates are returned due to a problem during the processing of the request.", '-2' => "Missing argument when calling module", '-5' => "No Item to ship", '-6' => "Illegal Item weight", '-7' => "Illegal item dimension", '-12' => "Can't open IM config file", '-13' => "Can't create log files", '-15' => "Invalid config file format", '-102' => "Invalid socket connection", '-106' => "Can't connect to server", '-1000' => "Unknow request type sent by client", '-1002' => "MAS Timed out", '-1004' => "Socket communication break", '-1005' => "Did not receive required data on socket.", '-2000' => "Unable to estabish socket connection with RSSS", '-2001' => "Merchant Id not found on server", '-2002' => "One or more parameter was not sent by the IM to the MAS", '-2003' => "Did not receive required data on socket.", '-2004' => "The request contains to many items to process it.", '-2005' => "The request received on socket is larger than the maximum allowed.", '-3000' => "Origin Postal Code is illegal", '-3001' => "Destination Postal Code/State Name/ Country is illegal", '-3002' => "Parcel too large to be shipped with CPC", '-3003' => "Parcel too small to be shipped with CPC", '-3004' => "Parcel too heavy to be shipped with CPC", '-3005' => "Internal error code returned by the rating DLL", '-3006' => "The pick up time format is invalid or not defined.", '-4000' => "Volumetric internal error", '-4001' => "Volumetric time out calculation error.", '-4002' => "No bins provided to the volumetric engine.", '-4003' => "No items provided to the volumetric engine.", '-4004' => "Item is too large to be packed", '-4005' => "Number of item more than maximum allowed", '-5000' => "XML Parsing error", '-5001' => "XML Tag not found", '-5002' => "Node Value Number format error", '-5003' => "Node value is empty", '-5004' => "Unable to create/parse XML Document", '-6000' => "Unable to open the database", '-6001' => "Unable to read from the database", '-6002' => "Unable to write to the database", '-50000' => "Internal problem - Please contact Sell Online Help Desk" } NON_ISO_COUNTRY_NAMES = { 'Russian Federation' => 'Russia' } def requirements [:login] end def find_rates(origin, destination, line_items = [], options = {}) rate_request = build_rate_request(origin, destination, line_items, options) commit(rate_request, origin, destination, options) end def maximum_weight Measured::Weight.new(30, :kg) end def maximum_address_field_length # https://www.canadapost.ca/cpo/mc/business/productsservices/developers/services/shippingmanifest/createshipment.jsf 44 end def self.default_location { country: 'CA', province: 'ON', city: 'Ottawa', address1: '61A York St', postal_code: 'K1N5T2' } end protected def commit(request, origin, destination, options = {}) parse_rate_response(ssl_post(URL, request), origin, destination, options) end private def generate_xml(&block) builder = Nokogiri::XML::Builder.new do |xml| xml.doc.create_internal_subset(DTD_NAME, nil, DTD_URI) yield(xml) end builder.to_xml end def build_rate_request(origin, destination, line_items = [], options = {}) line_items = [line_items] unless line_items.is_a?(Array) origin = origin.is_a?(Location) ? origin : Location.new(origin) destination = destination.is_a?(Location) ? destination : Location.new(destination) generate_xml do |xml| xml.eparcel do xml.language(@options[:french] ? 'fr' : 'en') xml.ratesAndServicesRequest do xml.merchantCPCID(@options[:login]) xml.fromPostalCode(origin.postal_code) xml.turnAroundTime(options[:turn_around_time]) if options[:turn_around_time] xml.itemsPrice(dollar_amount(line_items.map(&:value).compact.sum)) build_line_items(xml, line_items) xml.city(destination.city) xml.provOrState(destination.province) xml.country(handle_non_iso_country_names(destination.country)) xml.postalCode(destination.postal_code) end end end end def parse_rate_response(response, origin, destination, options = {}) xml = Nokogiri.XML(response) success = response_success?(xml) message = response_message(xml) rate_estimates = [] boxes = [] if success xml.xpath('eparcel/ratesAndServicesResponse/product').each do |product| service_name = (@options[:french] ? @@name_french : @@name) + " " + product.at('name').text service_code = product['id'] rate_estimates << RateEstimate.new(origin, destination, @@name, service_name, service_code: service_code, total_price: product.at('rate').text, currency: 'CAD', shipping_date: product.at('shippingDate').text, delivery_range: [product.at('deliveryDate').text] * 2 ) end boxes = xml.xpath('eparcel/ratesAndServicesResponse/packing/box').map do |box| b = Box.new b.packedItems = [] b.name = box.at('name').text b.weight = box.at('weight').text.to_f b.expediter_weight = box.at('expediterWeight').text.to_f b.length = box.at('length').text.to_f b.width = box.at('width').text.to_f b.height = box.at('height').text.to_f b.packedItems = box.xpath('packedItem').map do |item| p = PackedItem.new p.quantity = item.at('quantity').text.to_i p.description = item.at('description').text p end b end postal_outlets = xml.xpath('eparcel/ratesAndServicesResponse/nearestPostalOutlet').map do |outlet| postal_outlet = PostalOutlet.new postal_outlet.sequence_no = outlet.at('postalOutletSequenceNo').text postal_outlet.distance = outlet.at('distance').text postal_outlet.name = outlet.at('outletName').text postal_outlet.business_name = outlet.at('businessName').text postal_outlet.postal_address = Location.new( address1: outlet.at('postalAddress/addressLine').text, postal_code: outlet.at('postalAddress/postal_code').text, city: outlet.at('postalAddress/municipality').text, province: outlet.at('postalAddress/province').text, country: 'Canada', phone_number: outlet.at('phoneNumber').text ) postal_outlet.business_hours = outlet.elements.collect('businessHours') do |hour| { day_of_week: hour.at('dayOfWeek').text, time: hour.at('time').text } end postal_outlet end end CanadaPostRateResponse.new(success, message, Hash.from_xml(response), rates: rate_estimates, xml: response, boxes: boxes, postal_outlets: postal_outlets) end def response_success?(xml) return false unless xml.at('eparcel/error').nil? value = xml.at('eparcel/ratesAndServicesResponse/statusCode').text value == '1' || value == '2' end def response_message(xml) if response_success?(xml) xml.at('eparcel/ratesAndServicesResponse/statusMessage').text else xml.at('eparcel/error/statusMessage').text end end # # # # # # # # def build_line_items(xml, line_items) xml.lineItems do line_items.each do |line_item| xml.item do xml.quantity(1) xml.weight(line_item.kilograms) xml.length(line_item.cm(:length).to_s) xml.width(line_item.cm(:width).to_s) xml.height(line_item.cm(:height).to_s) xml.description(line_item.options[:description] || ' ') xml.readyToShip(line_item.options[:ready_to_ship] || nil) # By setting the 'readyToShip' tag to true, Sell Online will not pack this item in the boxes defined in the merchant profile. end end end end def dollar_amount(cents) "%0.2f" % (cents / 100.0) end def handle_non_iso_country_names(country) NON_ISO_COUNTRY_NAMES[country.to_s] || country end end end