an_post_return 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # AnPostReturn
2
+
3
+ A Ruby gem for integrating with An Post's return label generation and tracking service. This gem provides a simple interface for creating return labels for domestic, EU, and non-EU returns, and tracking through An Post's SFTP service.
4
+
5
+ ## Features
6
+
7
+ - Generate return labels for domestic, EU, and non-EU returns
8
+ - SFTP integration for secure file transfer
9
+ - Simple configuration options
10
+ - Proxy option for An Post IP whitelisting purpose
11
+ - Robust error handling
12
+ - Comprehensive tracking data parsing
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ ```ruby
19
+ gem 'an_post_return'
20
+ ```
21
+
22
+ And then execute:
23
+
24
+ ```bash
25
+ bundle install
26
+ ```
27
+
28
+ Or install it yourself as:
29
+
30
+ ```bash
31
+ gem install an_post_return
32
+ ```
33
+
34
+ ## Configuration
35
+
36
+ Configure the gem with your An Post credentials:
37
+
38
+ ```ruby
39
+ AnPostReturn.configure do |config|
40
+ # API Configuration (required)
41
+ config.subscription_key = 'your_subscription_key' # The Ocp-Apim-Subscription-Key for authentication
42
+
43
+ # SFTP Configuration (required)
44
+ config.sftp_config = {
45
+ host: 'your_sftp_host',
46
+ username: 'your_username',
47
+ password: 'your_password',
48
+ remote_path: '/path/to/remote/files' # The remote directory where An Post places tracking files
49
+ }
50
+
51
+ # Optional proxy configuration (for An Post IP whitelisting)
52
+ config.proxy_config = {
53
+ host: 'proxy-host',
54
+ port: 'proxy-port',
55
+ user: 'proxy-user', # Optional proxy authentication
56
+ password: 'proxy-password' # Optional proxy authentication
57
+ }
58
+
59
+ # Environment setting
60
+ config.test = false # Set to true for sandbox environment
61
+ end
62
+ ```
63
+
64
+ ## Usage
65
+
66
+ ### Creating a Return Label
67
+
68
+ ```ruby
69
+ # Initialize a new return label request
70
+ client = AnPostReturn::Client.new
71
+ response = client.return_labels.create(
72
+ output_response_type: "Label",
73
+ sender: {
74
+ first_name: "Jane",
75
+ last_name: "Smith",
76
+ contact_number: "0871234567",
77
+ email_address: "test@email.com"
78
+ },
79
+ sender_address: {
80
+ address_line1: "Exo Building",
81
+ address_line2: "North Wall Quay",
82
+ city: "Dublin 1",
83
+ eircode: "D01 W5Y2",
84
+ county: "Dublin",
85
+ country: "Ireland",
86
+ countrycode: "IE"
87
+ },
88
+ retailer_account_no: "your_account_number",
89
+ retailer_return_reason: "Does not fit",
90
+ retailer_order_number: "987654321"
91
+ )
92
+
93
+ # Access the response data
94
+ puts response["trackingNumber"] # The An Post tracking number for this shipment
95
+ puts response["labelData"] # The label data (usually a PDF bitstream)
96
+ ```
97
+
98
+ ### Tracking Shipments
99
+
100
+ The tracking system works with An Post's SFTP service, where tracking files are named in the format:
101
+ `CDT99999999SSSSS.txt` where:
102
+
103
+ - CDT is the prefix (Customer Data Tracking)
104
+ - 99999999 is your An Post Customer Account Number
105
+ - SSSSS is a sequence number (starts at 1, increments by 1 for each file)
106
+ - .txt is the file extension
107
+
108
+ ```ruby
109
+ tracker = AnPostReturn::Tracker.new
110
+
111
+ # Track by account number (gets all files)
112
+ # This will retrieve all tracking files for the given account number
113
+ tracker.track_with_account_number("3795540") do |file, data|
114
+ puts "Processing file: #{file}"
115
+ puts "Tracking data: #{data}"
116
+ end
117
+
118
+ # Track by account number (get last N files)
119
+ # Useful when you only want recent tracking updates
120
+ tracker.track_with_account_number("3795540", last: 2) do |file, data|
121
+ puts "Processing file: #{file}"
122
+ puts "Tracking data: #{data}"
123
+ end
124
+
125
+ # Track from a specific file onwards, by incrementing file name by 1 until no file is found
126
+ # Useful for resuming tracking from a known point
127
+ tracker.track_from("cdt0370132115864.txt") do |file, data|
128
+ puts "Processing file: #{file}"
129
+ puts "Tracking data: #{data}"
130
+ end
131
+ ```
132
+
133
+ The tracking data structure contains:
134
+
135
+ - `:header` - File header information including account details
136
+ - `:data` - Array of tracking events for multiple shipments
137
+ - `:footer` - File footer information including totals
138
+
139
+ Possible tracking statuses:
140
+
141
+ - "SORTED" - Item has been sorted at An Post facility
142
+ - "ATTEMPTED DELIVERY" - Delivery was attempted but not completed
143
+ - "DELIVERED" - Item has been successfully delivered
144
+ - "ITEM ON HAND" - Item is being held at An Post facility
145
+ - "PRE-ADVICE" - Item is expected but not yet received
146
+ - "OUT FOR DELIVERY" - Item is on the delivery vehicle
147
+ - "ITEM ACCEPTED" - Item has been received by An Post
148
+ - "CUSTOMER INSTRUCTION REC" - Customer has provided special instructions
149
+
150
+ ## Development
151
+
152
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
153
+
154
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
155
+
156
+ ## Contributing
157
+
158
+ Bug reports and pull requests are welcome on GitHub at https://github.com/PostCo/an_post_return. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/PostCo/an_post_return/blob/main/CODE_OF_CONDUCT.md).
159
+
160
+ ## License
161
+
162
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
163
+
164
+ ## Code of Conduct
165
+
166
+ Everyone interacting in the AnPostReturn project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/PostCo/an_post_return/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[spec standard]
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require_relative "configuration"
6
+ require_relative "errors"
7
+
8
+ module AnPostReturn
9
+ class Client
10
+ attr_reader :config
11
+
12
+ def initialize
13
+ @config = AnPostReturn.configuration
14
+ end
15
+
16
+ def return_labels
17
+ @return_labels ||= Resources::ReturnLabelResource.new(self)
18
+ end
19
+
20
+ def connection
21
+ @connection ||=
22
+ Faraday.new(url: config.api_base_url) do |faraday|
23
+ faraday.proxy = config.proxy_uri if config.proxy_configured?
24
+
25
+ faraday.request :json
26
+ faraday.response :json
27
+ faraday.adapter Faraday.default_adapter
28
+ faraday.headers = default_headers
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def default_headers
35
+ {
36
+ "Content-Type" => "application/json",
37
+ "Accept" => "application/json",
38
+ "Ocp-Apim-Subscription-Key" => config.subscription_key,
39
+ }
40
+ end
41
+
42
+ def handle_response(response)
43
+ case response.status
44
+ when 200..299
45
+ if response.body.is_a?(Hash) && response.body["success"] == false
46
+ raise APIError.new(
47
+ "API request failed with status #{response.status}: #{parse_error_message(response)}",
48
+ response: response,
49
+ )
50
+ else
51
+ response.body
52
+ end
53
+ when 400
54
+ raise ValidationError.new(parse_error_message(response), response: response)
55
+ when 401
56
+ raise ConfigurationError.new("Invalid subscription key", response: response)
57
+ when 404
58
+ raise APIError.new("Resource not found", response: response)
59
+ when 407
60
+ raise ConfigurationError.new("Proxy authentication required", response: response)
61
+ else
62
+ raise APIError.new(
63
+ "API request failed with status #{response.status}: #{parse_error_message(response)}",
64
+ response: response,
65
+ )
66
+ end
67
+ end
68
+
69
+ def parse_error_message(response)
70
+ if response.body.is_a?(Hash) && response.body["errors"]
71
+ errors = response.body["errors"]
72
+ if errors.is_a?(Array)
73
+ return errors.map { |error| error["message"] }.join(", ")
74
+ else
75
+ return errors
76
+ end
77
+ end
78
+
79
+ "Unknown error"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnPostReturn
4
+ class Configuration
5
+ attr_accessor :test
6
+ attr_accessor :proxy_config
7
+ attr_accessor :sftp_config
8
+ attr_accessor :subscription_key
9
+
10
+ def initialize
11
+ @test = false
12
+ @proxy_config = nil
13
+ @sftp_config = nil
14
+ @subscription_key = nil
15
+ end
16
+
17
+ def api_base_url
18
+ if test
19
+ "https://apim-anpost-mailslabels-nonprod.dev-anpost.com/returnsapi-q/v2"
20
+ else
21
+ "https://apim-anpost-mailslabels.anpost.com/returnsapi/v2"
22
+ end
23
+ end
24
+
25
+ def proxy_configured?
26
+ !proxy_config.nil?
27
+ end
28
+
29
+ def proxy_uri
30
+ return nil unless proxy_configured?
31
+
32
+ uri = "http://#{proxy_config[:host]}:#{proxy_config[:port]}"
33
+ uri =
34
+ "http://#{proxy_config[:user]}:#{proxy_config[:password]}@#{proxy_config[:host]}:#{proxy_config[:port]}" if proxy_config[
35
+ :user
36
+ ] && proxy_config[:password]
37
+ URI.parse(uri)
38
+ end
39
+
40
+ def sftp_configured?
41
+ return false if sftp_config.nil?
42
+
43
+ required_keys = %i[host username password remote_path]
44
+ required_keys.all? { |key| sftp_config.key?(key) && !sftp_config[key].nil? }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,21 @@
1
+ module AnPostReturn
2
+ class ParserError < StandardError
3
+ end
4
+ class Error < StandardError
5
+ attr_reader :response
6
+
7
+ def initialize(message, response: nil)
8
+ @response = response
9
+ super(message)
10
+ end
11
+ end
12
+
13
+ class ConfigurationError < Error
14
+ end
15
+
16
+ class APIError < Error
17
+ end
18
+
19
+ class ValidationError < Error
20
+ end
21
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/string"
5
+ require "active_support/hash_with_indifferent_access"
6
+ require "ostruct"
7
+
8
+ module AnPostReturn
9
+ class Base < OpenStruct
10
+ attr_reader :original_response
11
+
12
+ def initialize(attributes)
13
+ @original_response = attributes
14
+ super to_ostruct(attributes)
15
+ end
16
+
17
+ def to_ostruct(obj)
18
+ if obj.is_a?(Hash)
19
+ OpenStruct.new(obj.map { |key, val| [key.to_s.underscore, to_ostruct(val)] }.to_h)
20
+ elsif obj.is_a?(Array)
21
+ obj.map { |o| to_ostruct(o) }
22
+ else # Assumed to be a primitive value
23
+ obj
24
+ end
25
+ end
26
+
27
+ # Return the original response with camelCase keys preserved
28
+ def response
29
+ @original_response
30
+ end
31
+
32
+ # Convert back to hash without table key, including nested structures
33
+ def to_hash
34
+ ostruct_to_hash(self)
35
+ end
36
+
37
+ # Override comparison to handle hash comparison
38
+ def ==(other)
39
+ case other
40
+ when Hash
41
+ to_hash == other
42
+ else
43
+ super
44
+ end
45
+ end
46
+
47
+ # Override eql? to be consistent with ==
48
+ def eql?(other)
49
+ self == other
50
+ end
51
+
52
+ private
53
+
54
+ def ostruct_to_hash(object)
55
+ case object
56
+ when OpenStruct
57
+ hash = object.to_h.reject { |k, _| k == :table }
58
+ # Convert to HashWithIndifferentAccess and process values recursively
59
+ ActiveSupport::HashWithIndifferentAccess.new(hash).transform_values { |value| ostruct_to_hash(value) }
60
+ when Array
61
+ object.map { |item| ostruct_to_hash(item) }
62
+ when Hash
63
+ # Convert to HashWithIndifferentAccess and process values recursively
64
+ ActiveSupport::HashWithIndifferentAccess.new(object).transform_values { |value| ostruct_to_hash(value) }
65
+ else
66
+ object
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module AnPostReturn
6
+ class ReturnLabel < Base
7
+ def success?
8
+ success
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,123 @@
1
+ require_relative "../objects/return_label"
2
+ module AnPostReturn
3
+ module Resources
4
+ class ReturnLabelResource
5
+ attr_reader :client
6
+
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Create a return label
12
+ # @param params [Hash] The parameters for creating a return label
13
+ # @option params [String] :output_response_type Type of response ('Label')
14
+ # @option params [Hash] :sender Sender information
15
+ # @option sender [String] :first_name Sender's first name
16
+ # @option sender [String] :last_name Sender's last name
17
+ # @option sender [String] :contact_number Sender's contact number
18
+ # @option sender [String] :email_address Sender's email address
19
+ # @option params [Hash] :sender_address Sender's address details
20
+ # @option sender_address [String] :address_line1 First line of address
21
+ # @option sender_address [String] :address_line2 Second line of address (optional)
22
+ # @option sender_address [String] :city City
23
+ # @option sender_address [String] :eircode Eircode
24
+ # @option sender_address [String] :county County
25
+ # @option sender_address [String] :country Country name
26
+ # @option sender_address [String] :countrycode Country code (ISO 3166-1 alpha-2)
27
+ # @option params [String] :retailer_account_no Your An Post account number
28
+ # @option params [String] :retailer_return_reason Reason for return
29
+ # @option params [String] :retailer_order_number Order number
30
+ # @option params [Array<Hash>] :international_security_declaration_items Required for EU returns
31
+ # @option international_security_declaration_items [String] :item_description Description of item
32
+ # @option params [Hash] :customs_information Required for non-EU returns
33
+ # @option customs_information [String] :customs_region_code Customs region code
34
+ # @option customs_information [Integer] :customs_category_id Category ID
35
+ # @option customs_information [Float] :weight Weight in kg
36
+ # @option customs_information [Float] :value_amount Value amount
37
+ # @option customs_information [Float] :postage_fee_paid Postage fee paid
38
+ # @option customs_information [Float] :insured_value Insured value
39
+ # @option customs_information [Array<Hash>] :customs_content_items Content items for customs
40
+ # @option customs_content_items [Integer] :list_order Order in list
41
+ # @option customs_content_items [Integer] :number_of_units Number of units
42
+ # @option customs_content_items [String] :description Item description
43
+ # @option customs_content_items [String] :hs_tarriff HS tariff code
44
+ # @option customs_content_items [Float] :value_amount Item value
45
+ # @option customs_content_items [Float] :weight Item weight
46
+ # @option customs_content_items [String] :country_of_origin Country of origin code
47
+ # @return [AReturnLabel] The created return label data
48
+ #
49
+ # @example Create a domestic return label
50
+ # client.return_labels.create({
51
+ # output_response_type: "Label",
52
+ # sender: {
53
+ # first_name: "Jane",
54
+ # last_name: "Smith",
55
+ # contact_number: "0871234567",
56
+ # email_address: "test@email.com"
57
+ # },
58
+ # sender_address: {
59
+ # address_line1: "Exo Building",
60
+ # address_line2: "North Wall Quay",
61
+ # city: "Dublin 1",
62
+ # eircode: "D01 W5Y2",
63
+ # county: "Dublin",
64
+ # country: "Ireland",
65
+ # countrycode: "IE"
66
+ # },
67
+ # retailer_account_no: "your_account_number",
68
+ # retailer_return_reason: "Does not fit",
69
+ # retailer_order_number: "987654321"
70
+ # })
71
+ #
72
+ # @example Create an EU return label
73
+ # client.return_labels.create({
74
+ # output_response_type: "Label",
75
+ # sender: { ... },
76
+ # sender_address: { ... },
77
+ # international_security_declaration_items: [
78
+ # { item_description: "book" }
79
+ # ],
80
+ # retailer_account_no: "your_account_number",
81
+ # retailer_return_reason: "Does not fit",
82
+ # retailer_order_number: "123456789"
83
+ # })
84
+ #
85
+ # @example Create a non-EU return label
86
+ # client.return_labels.create({
87
+ # customs_information: {
88
+ # customs_region_code: "1",
89
+ # customs_category_id: 2,
90
+ # weight: 1.5,
91
+ # value_amount: 55,
92
+ # postage_fee_paid: 1,
93
+ # insured_value: 55,
94
+ # customs_content_items: [
95
+ # {
96
+ # list_order: 1,
97
+ # number_of_units: 1,
98
+ # description: "shoes",
99
+ # hs_tarriff: "6404191000",
100
+ # value_amount: 55,
101
+ # weight: 1.5,
102
+ # country_of_origin: "IE"
103
+ # }
104
+ # ]
105
+ # },
106
+ # output_response_type: "Label",
107
+ # sender: { ... },
108
+ # sender_address: { ... },
109
+ # retailer_account_no: "your_account_number",
110
+ # retailer_return_reason: "Does not fit",
111
+ # retailer_order_number: "321654987"
112
+ # })
113
+ def create(params)
114
+ raise ArgumentError, "Missing required parameters" if params.nil?
115
+ raise ArgumentError, "Subscription key not configured" if client.config.subscription_key.nil?
116
+
117
+ response = client.connection.post("returnsLabel") { |req| req.body = params }
118
+ response_data = client.send(:handle_response, response)
119
+ ReturnLabel.new(response_data)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,132 @@
1
+ require "net/sftp"
2
+ require "net/ssh/proxy/http"
3
+ require "tempfile"
4
+ require "csv"
5
+ require "x25519"
6
+ require_relative "errors"
7
+
8
+ module AnPostReturn
9
+ module SFTP
10
+ class Client
11
+ # SFTP connection configuration
12
+ attr_reader :host, :username, :password, :proxy_config
13
+
14
+ # Initialize a new SFTP client
15
+ #
16
+ # @param host [String] SFTP server hostname
17
+ # @param username [String] SFTP username
18
+ # @param password [String] SFTP password
19
+ # @param proxy_config [Hash, nil] Optional HTTP proxy configuration
20
+ # @option proxy_config [String] :host Proxy host
21
+ # @option proxy_config [Integer] :port Proxy port
22
+ # @option proxy_config [String, nil] :username Optional proxy username
23
+ # @option proxy_config [String, nil] :password Optional proxy password
24
+ def initialize(host, username, password, proxy_config = nil)
25
+ @host = host
26
+ @username = username
27
+ @password = password
28
+ @proxy_config = proxy_config
29
+ @connected = false
30
+ end
31
+
32
+ # Establish SFTP connection
33
+ #
34
+ # @return [Boolean] true if connection successful, false otherwise
35
+ # @raise [AnPostReturn::SFTP::ConnectionError] if connection fails
36
+ def connect
37
+ sftp_client.connect!
38
+ @connected = true
39
+ true
40
+ rescue Net::SSH::Exception => e
41
+ raise ConnectionError, "Failed to connect to #{host}: #{e.message}"
42
+ end
43
+
44
+ # Close SFTP connection
45
+ #
46
+ # @return [void]
47
+ def disconnect
48
+ return unless @connected
49
+
50
+ sftp_client.close_channel
51
+ ssh_session.close
52
+ @connected = false
53
+ end
54
+
55
+ # Download and read a file from SFTP server
56
+ #
57
+ # @param remote_path [String] Path to file on SFTP server
58
+ # @yield [Tempfile] Temporary file containing downloaded content
59
+ # @return [Tempfile, Object] If block given, returns block result; otherwise returns Tempfile
60
+ # @raise [AnPostReturn::SFTP::FileError] if file download fails
61
+ def read_file(remote_path)
62
+ ensure_connected
63
+ temp_file = Tempfile.new(["sftp", File.extname(remote_path)])
64
+
65
+ begin
66
+ sftp_client.download!(remote_path, temp_file.path)
67
+ block_given? ? yield(temp_file) : temp_file
68
+ rescue Net::SFTP::StatusException => e
69
+ if e.message.include?("no such file")
70
+ raise FileNotFoundError
71
+ else
72
+ raise FileError, "Failed to download #{remote_path}: #{e.message}"
73
+ end
74
+ ensure
75
+ unless block_given?
76
+ temp_file.close
77
+ temp_file.unlink
78
+ end
79
+ end
80
+ end
81
+
82
+ # List files in remote directory
83
+ #
84
+ # @param remote_path [String] Remote directory path
85
+ # @param glob_pattern [String, nil] Optional glob pattern for filtering files
86
+ # @return [Array<Net::SFTP::Protocol::V01::Name>] Array of file entries
87
+ # @raise [AnPostReturn::SFTP::FileError] if listing files fails
88
+ def list_files(remote_path, glob_pattern = nil)
89
+ ensure_connected
90
+ entries = []
91
+
92
+ begin
93
+ if glob_pattern
94
+ sftp_client.dir.glob(remote_path, glob_pattern) { |entry| entries << entry }
95
+ else
96
+ sftp_client.dir.foreach(remote_path) { |entry| entries << entry }
97
+ end
98
+ entries
99
+ rescue Net::SFTP::StatusException => e
100
+ raise FileError, "Failed to list files in #{remote_path}: #{e.message}"
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def sftp_client
107
+ @sftp_client ||= Net::SFTP::Session.new(ssh_session)
108
+ end
109
+
110
+ def ssh_session
111
+ return @ssh_session if @ssh_session
112
+
113
+ ssh_options = { password: password, auth_methods: ["password"] }
114
+
115
+ if proxy_config
116
+ ssh_options[:proxy] = Net::SSH::Proxy::HTTP.new(
117
+ proxy_config[:host],
118
+ proxy_config[:port],
119
+ user: proxy_config[:username],
120
+ password: proxy_config[:password],
121
+ )
122
+ end
123
+
124
+ @ssh_session = Net::SSH.start(host, username, ssh_options)
125
+ end
126
+
127
+ def ensure_connected
128
+ raise ConnectionError, "Not connected to SFTP server" unless @connected
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,12 @@
1
+ module AnPostReturn
2
+ module SFTP
3
+ class Error < StandardError
4
+ end
5
+ class ConnectionError < Error
6
+ end
7
+ class FileError < Error
8
+ end
9
+ class FileNotFoundError < FileError
10
+ end
11
+ end
12
+ end