From ace05133fb06d93016082f94119a84d19e68f2da Mon Sep 17 00:00:00 2001 From: mustafayuce33 Date: Fri, 17 Jul 2026 16:35:19 +0200 Subject: [PATCH] Add UPS Paperless Document API support - Connection#paperless_document_upload uploads a document (e.g. commercial invoice) to the Paperless Document API and returns the Forms History DocumentID; get_response now accepts extra headers (ShipperNumber). - PaperlessDocumentBuilder builds the UploadRequest (base64 file, doc type). - PaperlessDocumentParser extracts the returned DocumentID. - InternationalInvoiceBuilder supports FormType override and a UserCreatedForm.DocumentID (attach an uploaded invoice), and only emits FreightCharges/Discount when provided. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/ups.rb | 2 + .../builders/international_invoice_builder.rb | 21 ++++--- .../builders/paperless_document_builder.rb | 61 +++++++++++++++++++ lib/ups/connection.rb | 25 +++++++- lib/ups/parsers/paperless_document_parser.rb | 19 ++++++ .../international_invoice_builder_spec.rb | 55 +++++++++++++++++ .../paperless_document_builder_spec.rb | 26 ++++++++ .../parsers/paperless_document_parser_spec.rb | 28 +++++++++ 8 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 lib/ups/builders/paperless_document_builder.rb create mode 100644 lib/ups/parsers/paperless_document_parser.rb create mode 100644 spec/ups/builders/international_invoice_builder_spec.rb create mode 100644 spec/ups/builders/paperless_document_builder_spec.rb create mode 100644 spec/ups/parsers/paperless_document_parser_spec.rb diff --git a/lib/ups.rb b/lib/ups.rb index 14c6e74..ccec5f9 100644 --- a/lib/ups.rb +++ b/lib/ups.rb @@ -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 @@ -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 diff --git a/lib/ups/builders/international_invoice_builder.rb b/lib/ups/builders/international_invoice_builder.rb index 6bcc0db..e01e7d6 100644 --- a/lib/ups/builders/international_invoice_builder.rb +++ b/lib/ups/builders/international_invoice_builder.rb @@ -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 @@ -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 diff --git a/lib/ups/builders/paperless_document_builder.rb b/lib/ups/builders/paperless_document_builder.rb new file mode 100644 index 0000000..8c96fc0 --- /dev/null +++ b/lib/ups/builders/paperless_document_builder.rb @@ -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 diff --git a/lib/ups/connection.rb b/lib/ups/connection.rb index 2191778..a7b489e 100644 --- a/lib/ups/connection.rb +++ b/lib/ups/connection.rb @@ -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 @@ -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 @@ -194,7 +215,7 @@ 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 = { @@ -202,7 +223,7 @@ def get_response(path, body = {}, method = 'post') '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( diff --git a/lib/ups/parsers/paperless_document_parser.rb b/lib/ups/parsers/paperless_document_parser.rb new file mode 100644 index 0000000..78f55ef --- /dev/null +++ b/lib/ups/parsers/paperless_document_parser.rb @@ -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 diff --git a/spec/ups/builders/international_invoice_builder_spec.rb b/spec/ups/builders/international_invoice_builder_spec.rb new file mode 100644 index 0000000..09c16e2 --- /dev/null +++ b/spec/ups/builders/international_invoice_builder_spec.rb @@ -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 diff --git a/spec/ups/builders/paperless_document_builder_spec.rb b/spec/ups/builders/paperless_document_builder_spec.rb new file mode 100644 index 0000000..bb61607 --- /dev/null +++ b/spec/ups/builders/paperless_document_builder_spec.rb @@ -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 diff --git a/spec/ups/parsers/paperless_document_parser_spec.rb b/spec/ups/parsers/paperless_document_parser_spec.rb new file mode 100644 index 0000000..872bbc2 --- /dev/null +++ b/spec/ups/parsers/paperless_document_parser_spec.rb @@ -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