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.
- checksums.yaml +7 -0
- data/.cursor/mcp.json +21 -0
- data/.cursor/rules/cursor_rules.mdc +53 -0
- data/.cursor/rules/dev_workflow.mdc +215 -0
- data/.cursor/rules/self_improve.mdc +73 -0
- data/.cursor/rules/taskmaster.mdc +353 -0
- data/.env.example +14 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/.windsurfrules +474 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README-task-master.md +645 -0
- data/README.md +166 -0
- data/Rakefile +10 -0
- data/lib/an_post_return/client.rb +82 -0
- data/lib/an_post_return/configuration.rb +47 -0
- data/lib/an_post_return/errors.rb +21 -0
- data/lib/an_post_return/objects/base.rb +70 -0
- data/lib/an_post_return/objects/return_label.rb +11 -0
- data/lib/an_post_return/resources/return_label_resource.rb +123 -0
- data/lib/an_post_return/sftp/client.rb +132 -0
- data/lib/an_post_return/sftp/errors.rb +12 -0
- data/lib/an_post_return/sftp/tracking_parser.rb +116 -0
- data/lib/an_post_return/tracker.rb +97 -0
- data/lib/an_post_return/version.rb +5 -0
- data/lib/an_post_return.rb +45 -0
- data/scripts/example_prd.txt +47 -0
- data/scripts/prd.txt +147 -0
- data/scripts/sftp_test.rb +185 -0
- data/scripts/task-complexity-report.json +131 -0
- data/sig/anpost_api.rbs +4 -0
- metadata +302 -0
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,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,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
|