Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/ups.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ module Parsers
autoload :ShipParser, 'ups/parsers/ship_parser'
autoload :TrackParser, 'ups/parsers/track_parser'
autoload :LabelParser, 'ups/parsers/label_parser'
autoload :PaperlessDocumentParser, 'ups/parsers/paperless_document_parser'
end

module Builders
Expand All @@ -42,5 +43,6 @@ module Builders
autoload :OrganisationBuilder, 'ups/builders/organisation_builder'
autoload :ShipperBuilder, 'ups/builders/shipper_builder'
autoload :LabelRecoveryRequestBuilder, 'ups/builders/label_recovery_request_builder'
autoload :PaperlessDocumentBuilder, 'ups/builders/paperless_document_builder'
end
end
21 changes: 11 additions & 10 deletions lib/ups/builders/international_invoice_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ def initialize(name, opts = {})
end

def form_type
element_with_value('FormType', '01')
element_with_value('FormType', opts[:form_type] || '01')
end

def user_created_form
element_with_value('UserCreatedForm', 'DocumentID' => Array(opts[:document_id]))
end

def invoice_number
Expand Down Expand Up @@ -67,16 +71,13 @@ def as_json
international_form[name].merge!(form_type,
invoice_date,
reason_for_export,
currency_code,
freight_charge,
discount)
currency_code)

if opts[:invoice_number]
international_form[name].merge!(invoice_number)
end
if opts[:terms_of_shipment]
international_form[name].merge!(terms_of_shipment)
end
international_form[name].merge!(freight_charge) if opts[:freight_charge]
international_form[name].merge!(discount) if opts[:discount]
international_form[name].merge!(invoice_number) if opts[:invoice_number]
international_form[name].merge!(terms_of_shipment) if opts[:terms_of_shipment]
international_form[name].merge!(user_created_form) if opts[:document_id]

international_form[name]['Product'] = product_details
international_form
Expand Down
61 changes: 61 additions & 0 deletions lib/ups/builders/paperless_document_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

require 'base64'

module UPS
module Builders
# The {PaperlessDocumentBuilder} class builds the UploadRequest JSON body
# for the UPS Paperless Document API (uploading e.g. a commercial invoice
# PDF to Forms History).
#
# @author Thinka
class PaperlessDocumentBuilder
# UserCreatedFormDocumentType: 002 = Commercial Invoice
COMMERCIAL_INVOICE = '002'

attr_reader :shipper_number

def initialize
@documents = []
end

# Sets the UPS account number (also sent as the ShipperNumber header).
#
# @param [String] shipper_number UPS account number
# @return [void]
def add_shipper_number(shipper_number)
@shipper_number = shipper_number
end

# Adds a document to be uploaded.
#
# @param [String] file The raw file bytes (will be base64 encoded)
# @param [String] file_name The file name
# @param [String] file_format The file extension (default 'pdf')
# @param [String] document_type The UPS document type code (default '002')
# @return [self]
def add_file(file:, file_name:, file_format: 'pdf', document_type: COMMERCIAL_INVOICE)
@documents << {
'UserCreatedFormFileName' => file_name,
'UserCreatedFormFile' => Base64.strict_encode64(file),
'UserCreatedFormFileFormat' => file_format,
'UserCreatedFormDocumentType' => document_type
}
self
end

# Returns a JSON representation of the upload request.
#
# @return [Hash]
def as_json
{
'UploadRequest' => {
'Request' => {},
'ShipperNumber' => shipper_number,
'UserCreatedForm' => @documents
}
}
end
end
end
end
25 changes: 23 additions & 2 deletions lib/ups/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ class Connection
SHIP_VERSION = 'v2403'
TRACK_VERSION = 'v1'
LABEL_VERSION = 'v1'
PAPERLESS_VERSION = 'v2'

RATE_PATH = "/api/rating/#{RATE_VERSION}/Shop"
SHIP_PATH = "/api/shipments/#{SHIP_VERSION}/ship"
TRACK_PATH = "/api/track/#{TRACK_VERSION}/details"
LABEL_PATH = "/api/labels/#{LABEL_VERSION}/recovery"
PAPERLESS_UPLOAD_PATH = "/api/paperlessdocuments/#{PAPERLESS_VERSION}/upload"

DEFAULT_PARAMS = {
test_mode: false
Expand Down Expand Up @@ -121,6 +123,25 @@ def label(label_builder = nil)
UPS::Parsers::LabelParser.new(response.body)
end

# Uploads a document (e.g. a commercial invoice) to the UPS Paperless
# Document API and returns a parser exposing the created DocumentID.
#
# A pre-configured {Builders::PaperlessDocumentBuilder} object can be passed
# as the first option or a block yielded to configure a new one.
#
# @param [Builders::PaperlessDocumentBuilder] builder A pre-configured builder
# @yield [builder] A PaperlessDocumentBuilder for configuring the upload
def paperless_document_upload(builder = nil)
if builder.nil? && block_given?
builder = UPS::Builders::PaperlessDocumentBuilder.new
yield builder
end

response = get_response(PAPERLESS_UPLOAD_PATH, builder.as_json, 'post',
'ShipperNumber' => builder.shipper_number)
UPS::Parsers::PaperlessDocumentParser.new(response.body)
end

# Authorizes the connection with the UPS API
#
# @param [String] account_number Account number to use for the request
Expand Down Expand Up @@ -194,15 +215,15 @@ def build_url(path)
# @param [Optional, Hash] body The body to send with the request
# @param [Optional, String] method The method type to use for the request
# @return [Excon::Response] The response from the request
def get_response(path, body = {}, method = 'post')
def get_response(path, body = {}, method = 'post', extra_headers = {})
access_token = get_access_token

headers = {
'Content-Type' => 'application/json',
'Authorization' => "Bearer #{access_token}",
'transId' => SecureRandom.hex(16), # Unique identifier for this request
'transactionSrc' => 'testing' # Identifies client/source app that is calling, UPS has default of 'testing' (sometimes)
}
}.merge(extra_headers)

if method.downcase == 'get'
Excon.get(
Expand Down
19 changes: 19 additions & 0 deletions lib/ups/parsers/paperless_document_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

module UPS
module Parsers
# Parses the response of a UPS Paperless Document API upload and exposes
# the returned Forms History DocumentID.
class PaperlessDocumentParser < BaseParser
def success?
!document_id.nil?
end

# @return [String, nil] the 26-character Forms History DocumentID
def document_id
id = parsed_response.dig(:UploadResponse, :FormsHistoryDocumentID, :DocumentID)
id.is_a?(Array) ? id.first : id
end
end
end
end
55 changes: 55 additions & 0 deletions spec/ups/builders/international_invoice_builder_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
require "spec_helper"

describe UPS::Builders::InternationalInvoiceBuilder do
let(:opts) do
{
form_type: "07",
invoice_number: "202405-123",
invoice_date: "20260717",
reason_for_export: "SALE",
currency_code: "EUR",
document_id: "1Z2345678901234567890ABCDE",
products: [
{
description: "Thinka for KNX",
number: 1,
value: "789.00",
dimensions_unit: "PCS",
commodity_code: "84714100",
origin_country_code: "NL"
}
]
}
end

subject { UPS::Builders::InternationalInvoiceBuilder.new("InternationalForms", opts).as_json["InternationalForms"] }

it "uses the given form type" do
expect(subject["FormType"]).must_equal "07"
end

it "attaches the uploaded document id" do
expect(subject["UserCreatedForm"]).must_equal({ "DocumentID" => ["1Z2345678901234567890ABCDE"] })
end

it "includes the product commodity code" do
expect(subject["Product"].first["CommodityCode"]).must_equal "84714100"
end

it "omits freight and discount when not provided" do
expect(subject.key?("FreightCharges")).must_equal false
expect(subject.key?("Discount")).must_equal false
end

describe "without a form type or document id" do
before do
opts.delete(:form_type)
opts.delete(:document_id)
end

it "defaults to an invoice (01) with no user created form" do
expect(subject["FormType"]).must_equal "01"
expect(subject.key?("UserCreatedForm")).must_equal false
end
end
end
26 changes: 26 additions & 0 deletions spec/ups/builders/paperless_document_builder_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
require "spec_helper"
require "base64"

describe UPS::Builders::PaperlessDocumentBuilder do
subject do
UPS::Builders::PaperlessDocumentBuilder.new.tap do |builder|
builder.add_shipper_number("8Y7F40")
builder.add_file(file: "PDF-BYTES", file_name: "invoice-1.pdf")
end
end

let(:upload) { subject.as_json["UploadRequest"] }

it "sets the shipper number" do
expect(subject.shipper_number).must_equal "8Y7F40"
expect(upload["ShipperNumber"]).must_equal "8Y7F40"
end

it "base64 encodes the file as a commercial invoice" do
form = upload["UserCreatedForm"].first
expect(form["UserCreatedFormFileName"]).must_equal "invoice-1.pdf"
expect(form["UserCreatedFormFileFormat"]).must_equal "pdf"
expect(form["UserCreatedFormDocumentType"]).must_equal "002"
expect(Base64.strict_decode64(form["UserCreatedFormFile"])).must_equal "PDF-BYTES"
end
end
28 changes: 28 additions & 0 deletions spec/ups/parsers/paperless_document_parser_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
require "spec_helper"

describe UPS::Parsers::PaperlessDocumentParser do
subject { UPS::Parsers::PaperlessDocumentParser.new(response) }

describe "with a DocumentID" do
let(:response) do
{ UploadResponse: { FormsHistoryDocumentID: { DocumentID: ["1Z2345678901234567890ABCDE"] } } }.to_json
end

it "extracts the DocumentID" do
expect(subject.document_id).must_equal "1Z2345678901234567890ABCDE"
end

it "is successful" do
expect(subject.success?).must_equal true
end
end

describe "without a DocumentID" do
let(:response) { { UploadResponse: {} }.to_json }

it "is not successful and has no DocumentID" do
expect(subject.success?).must_equal false
expect(subject.document_id).must_be_nil
end
end
end
Loading