belpost 0.1.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.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_service"
4
+ require_relative "models/parcel"
5
+ require_relative "models/api_response"
6
+
7
+ module Belpost
8
+ # Main client class for interacting with the BelPost API.
9
+ class Client
10
+ # Initializes a new instance of the Client.
11
+ #
12
+ # @raise [Belpost::Error] If JWT token is not configured.
13
+ def initialize(logger: Logger.new($stdout))
14
+ @config = Belpost.configuration
15
+ raise ConfigurationError, "JWT token is required" if @config.jwt_token.nil?
16
+
17
+ @api_service = ApiService.new(
18
+ base_url: @config.base_url,
19
+ jwt_token: @config.jwt_token,
20
+ timeout: @config.timeout,
21
+ logger: logger
22
+ )
23
+ end
24
+
25
+ # Creates a new postal parcel by sending a POST request to the API.
26
+ #
27
+ # @param parcel_data [Hash] The data for the postal parcel.
28
+ # @return [Hash] The parsed JSON response from the API.
29
+ # @raise [Belpost::InvalidRequestError] If the request data is invalid.
30
+ # @raise [Belpost::ApiError] If the API returns an error response.
31
+ def create_parcel(parcel_data)
32
+ validation_result = Validation::ParcelSchema.call(parcel_data)
33
+ unless validation_result.success?
34
+ raise ValidationError, "Invalid parcel data: #{validation_result.errors.to_h}"
35
+ end
36
+
37
+ parcel = Models::Parcel.new(parcel_data)
38
+ response = @api_service.post("/api/v1/business/postal-deliveries", parcel.to_h)
39
+ response.to_h
40
+ end
41
+
42
+ # Fetches the HS codes tree from the API.
43
+ #
44
+ # @return [Array<Hash>] The HS codes tree as an array of hashes.
45
+ # @raise [Belpost::ApiError] If the API returns an error response.
46
+ def fetch_hs_codes
47
+ response = @api_service.get("/api/v1/business/postal-deliveries/hs-codes/list")
48
+ response.to_h
49
+ end
50
+
51
+ # Fetches validation data for postal deliveries based on the country code.
52
+ #
53
+ # @param country_code [String] The country code (e.g. "BY", "RU-LEN").
54
+ # @return [Hash] The parsed JSON response containing validation data.
55
+ # @raise [Belpost::ApiError] If the API returns an error response.
56
+ def validate_postal_delivery(country_code)
57
+ country_code = country_code.upcase
58
+ response = @api_service.get("/api/v1/business/postal-deliveries/validation/#{country_code}")
59
+ response.to_h
60
+ end
61
+
62
+ # Allows you to get a list of countries to which postal items are sent.
63
+ #
64
+ # @return [Hash] The parsed JSON response containing available countries.
65
+ # @raise [Belpost::ApiError] If the API returns an error response.
66
+ def fetch_available_countries
67
+ response = @api_service.get("/api/v1/business/postal-deliveries/countries")
68
+ response.to_h
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belpost
4
+ # Class for managing the Belpochta API configuration.
5
+ # Allows you to set basic parameters such as API URL, JWT token, and request timeout.
6
+ class Configuration
7
+ attr_accessor :base_url, :jwt_token, :timeout
8
+
9
+ def initialize
10
+ @base_url = ENV.fetch("BELPOST_API_URL")
11
+ @jwt_token = ENV.fetch("BELPOST_JWT_TOKEN", nil)
12
+ @timeout = ENV.fetch("BELPOST_TIMEOUT", 10).to_i
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belpost
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class ApiError < Error
9
+ attr_reader :status_code, :response_body
10
+
11
+ def initialize(message, status_code: nil, response_body: nil)
12
+ @status_code = status_code
13
+ @response_body = response_body
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ class AuthenticationError < ApiError; end
19
+ class InvalidRequestError < ApiError; end
20
+ class RateLimitError < ApiError; end
21
+ class ServerError < ApiError; end
22
+ class NetworkError < Error; end
23
+ class TimeoutError < Error; end
24
+ class ValidationError < Error; end
25
+ class RequestError < Error; end
26
+ class ParseError < Error; end
27
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belpost
4
+ module Models
5
+ class ApiResponse
6
+ attr_reader :data, :status_code, :headers
7
+
8
+ def initialize(data:, status_code:, headers:)
9
+ @data = data
10
+ @status_code = status_code
11
+ @headers = headers
12
+ end
13
+
14
+ def success?
15
+ status_code == 200
16
+ end
17
+
18
+ def to_h
19
+ data
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belpost
4
+ module Models
5
+ class CustomsDeclaration
6
+ VALID_CATEGORIES = %w[gift documents sample returned_goods merchandise other].freeze
7
+
8
+ attr_reader :items, :price, :category, :explanation, :comments, :invoice, :licences, :certificates
9
+
10
+ def initialize(data = {})
11
+ @items = data[:items] || []
12
+ @price = data[:price] || {}
13
+ @category = data[:category]
14
+ @explanation = data[:explanation]
15
+ @comments = data[:comments]
16
+ @invoice = data[:invoice]
17
+ @licences = data[:licences] || []
18
+ @certificates = data[:certificates] || []
19
+ end
20
+
21
+ def add_item(item_data)
22
+ @items << item_data
23
+ end
24
+
25
+ def set_price(currency, value)
26
+ @price = { currency: currency, value: value }
27
+ end
28
+
29
+ def set_category(category)
30
+ unless VALID_CATEGORIES.include?(category)
31
+ raise ValidationError, "Invalid category. Must be one of: #{VALID_CATEGORIES.join(', ')}"
32
+ end
33
+
34
+ @category = category
35
+ end
36
+
37
+ def to_h
38
+ {
39
+ items: @items,
40
+ price: @price,
41
+ category: @category,
42
+ explanation: @explanation,
43
+ comments: @comments,
44
+ invoice: @invoice,
45
+ licences: @licences,
46
+ certificates: @certificates
47
+ }.compact
48
+ end
49
+
50
+ def valid?
51
+ return false if @category && !VALID_CATEGORIES.include?(@category)
52
+ return false if @category == "other" && @explanation.nil?
53
+
54
+ # Проверка наличия обязательных полей для коммерческих отправлений
55
+ if %w[merchandise sample returned_goods].include?(@category)
56
+ return false if @items.empty?
57
+ return false if @price.empty?
58
+ end
59
+
60
+ true
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belpost
4
+ module Models
5
+ class Parcel
6
+ attr_reader :data
7
+
8
+ def initialize(data)
9
+ @data = data
10
+ end
11
+
12
+ def to_h
13
+ data
14
+ end
15
+
16
+ def to_json(*_args)
17
+ data.to_json
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belpost
4
+ module Models
5
+ class ParcelBuilder
6
+ def initialize
7
+ @data = {
8
+ parcel: {
9
+ type: "package",
10
+ attachment_type: "products",
11
+ measures: {},
12
+ departure: {
13
+ country: "BY",
14
+ place: "post_office"
15
+ },
16
+ arrival: {
17
+ place: "post_office"
18
+ }
19
+ },
20
+ addons: {},
21
+ sender: {
22
+ info: {},
23
+ location: {}
24
+ },
25
+ recipient: {
26
+ info: {},
27
+ location: {}
28
+ }
29
+ }
30
+ end
31
+
32
+ # Основные параметры посылки
33
+ def with_type(type)
34
+ @data[:parcel][:type] = type
35
+ self
36
+ end
37
+
38
+ def with_attachment_type(attachment_type)
39
+ @data[:parcel][:attachment_type] = attachment_type
40
+ self
41
+ end
42
+
43
+ def with_weight(weight_in_grams)
44
+ @data[:parcel][:measures][:weight] = weight_in_grams
45
+ self
46
+ end
47
+
48
+ def with_dimensions(length, width, height)
49
+ @data[:parcel][:measures][:long] = length
50
+ @data[:parcel][:measures][:width] = width
51
+ @data[:parcel][:measures][:height] = height
52
+ self
53
+ end
54
+
55
+ def to_country(country_code)
56
+ @data[:parcel][:arrival][:country] = country_code
57
+ self
58
+ end
59
+
60
+ # Дополнительные сервисы
61
+ def with_declared_value(value, currency = "BYN")
62
+ @data[:addons][:declared_value] = {
63
+ currency: currency,
64
+ value: value.to_f
65
+ }
66
+ self
67
+ end
68
+
69
+ def with_cash_on_delivery(value, currency = "BYN")
70
+ @data[:addons][:cash_on_delivery] = {
71
+ currency: currency,
72
+ value: value.to_f
73
+ }
74
+ self
75
+ end
76
+
77
+ def add_service(service_name, value = true)
78
+ @data[:addons][service_name.to_sym] = value
79
+ self
80
+ end
81
+
82
+ # Отправитель
83
+ def from_legal_person(organization_name)
84
+ @data[:sender][:type] = "legal_person"
85
+ @data[:sender][:info][:organization_name] = organization_name
86
+ self
87
+ end
88
+
89
+ def from_sole_proprietor(organization_name)
90
+ @data[:sender][:type] = "sole_proprietor"
91
+ @data[:sender][:info][:organization_name] = organization_name
92
+ self
93
+ end
94
+
95
+ def with_sender_details(taxpayer_number: nil, bank: nil, iban: nil, bic: nil)
96
+ @data[:sender][:info][:taxpayer_number] = taxpayer_number if taxpayer_number
97
+ @data[:sender][:info][:bank] = bank if bank
98
+ @data[:sender][:info][:IBAN] = iban if iban
99
+ @data[:sender][:info][:BIC] = bic if bic
100
+ self
101
+ end
102
+
103
+ def with_sender_location(postal_code:, region:, district:, locality_type:, locality_name:, road_type:, road_name:, building:, housing: nil, apartment: nil)
104
+ @data[:sender][:location] = {
105
+ code: postal_code,
106
+ region: region,
107
+ district: district,
108
+ locality: {
109
+ type: locality_type,
110
+ name: locality_name
111
+ },
112
+ road: {
113
+ type: road_type,
114
+ name: road_name
115
+ },
116
+ building: building,
117
+ housing: housing,
118
+ apartment: apartment
119
+ }.compact
120
+ self
121
+ end
122
+
123
+ def with_sender_contact(email:, phone:)
124
+ @data[:sender][:email] = email
125
+ @data[:sender][:phone] = phone
126
+ self
127
+ end
128
+
129
+ # Получатель
130
+ def to_natural_person(first_name:, last_name:, second_name: nil)
131
+ @data[:recipient][:type] = "natural_person"
132
+ @data[:recipient][:info] = {
133
+ first_name: first_name,
134
+ last_name: last_name,
135
+ second_name: second_name
136
+ }.compact
137
+ self
138
+ end
139
+
140
+ def to_legal_person(organization_name)
141
+ @data[:recipient][:type] = "legal_person"
142
+ @data[:recipient][:info] = {
143
+ organization_name: organization_name
144
+ }
145
+ self
146
+ end
147
+
148
+ def with_recipient_location(postal_code:, region:, district:, locality_type:, locality_name:, road_type:, road_name:, building:, housing: nil, apartment: nil)
149
+ @data[:recipient][:location] = {
150
+ code: postal_code,
151
+ region: region,
152
+ district: district,
153
+ locality: {
154
+ type: locality_type,
155
+ name: locality_name
156
+ },
157
+ road: {
158
+ type: road_type,
159
+ name: road_name
160
+ },
161
+ building: building,
162
+ housing: housing,
163
+ apartment: apartment
164
+ }.compact
165
+ self
166
+ end
167
+
168
+ def with_foreign_recipient_location(postal_code:, locality:, address:)
169
+ @data[:recipient][:location] = {
170
+ code: postal_code,
171
+ locality: locality,
172
+ address: address
173
+ }
174
+ self
175
+ end
176
+
177
+ def with_recipient_contact(email: nil, phone:)
178
+ @data[:recipient][:email] = email
179
+ @data[:recipient][:phone] = phone
180
+ self
181
+ end
182
+
183
+ # Таможенная декларация
184
+ def with_customs_declaration(customs_declaration)
185
+ @data[:cp72] = customs_declaration.to_h
186
+ self
187
+ end
188
+
189
+ def build
190
+ validate!
191
+ @data
192
+ end
193
+
194
+ private
195
+
196
+ def validate!
197
+ # Проверка обязательных полей
198
+ raise ValidationError, "Weight is required" unless @data.dig(:parcel, :measures, :weight)
199
+ raise ValidationError, "Sender type is required" unless @data.dig(:sender, :type)
200
+ raise ValidationError, "Recipient type is required" unless @data.dig(:recipient, :type)
201
+
202
+ # Проверка логики
203
+ if @data.dig(:addons, :cash_on_delivery) && !@data.dig(:addons, :declared_value)
204
+ raise ValidationError, "Declared value is required when cash on delivery is set"
205
+ end
206
+
207
+ # Проверка международных отправлений
208
+ if @data.dig(:parcel, :arrival, :country) != "BY"
209
+ if @data[:cp72].nil? || @data[:cp72].empty?
210
+ raise ValidationError, "Customs declaration is required for international parcels"
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belpost
4
+ class Retry
5
+ MAX_RETRIES = 3
6
+ INITIAL_DELAY = 1
7
+
8
+ def self.with_retry(max_retries: MAX_RETRIES, initial_delay: INITIAL_DELAY)
9
+ retries = 0
10
+ delay = initial_delay
11
+
12
+ begin
13
+ yield
14
+ rescue NetworkError, TimeoutError, ServerError => e
15
+ retries += 1
16
+ if retries <= max_retries
17
+ sleep(delay)
18
+ delay *= 2
19
+ retry
20
+ else
21
+ raise e
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/schema"
4
+
5
+ module Belpost
6
+ # rubocop:disable Metrics/ModuleLength
7
+ module Validation
8
+ # Validation schema for parcel_data
9
+ # rubocop:disable Metrics/BlockLength
10
+ ParcelSchema = Dry::Schema.JSON do
11
+ required(:parcel).hash do
12
+ required(:type).filled(:string, included_in?: %w[package small_package small_valued_package ems])
13
+ required(:attachment_type).filled(:string, included_in?: %w[products documents])
14
+ required(:measures).hash do
15
+ required(:weight).filled(:integer, gt?: 0)
16
+ optional(:long).value(:integer, gt?: 0)
17
+ optional(:width).value(:integer, gt?: 0)
18
+ optional(:height).value(:integer, gt?: 0)
19
+ end
20
+ required(:departure).hash do
21
+ required(:country).filled(:string, eql?: "BY")
22
+ required(:place).filled(:string, included_in?: %w[post_office])
23
+ optional(:place_uuid).maybe(:string)
24
+ end
25
+ required(:arrival).hash do
26
+ required(:country).filled(:string)
27
+ required(:place).filled(:string, included_in?: %w[post_office])
28
+ optional(:place_uuid).maybe(:string)
29
+ end
30
+ optional(:s10code).maybe(:string)
31
+ end
32
+
33
+ optional(:addons).hash do
34
+ optional(:cash_on_delivery).maybe(:hash) do
35
+ required(:currency).filled(:string, eql?: "BYN")
36
+ required(:value).filled(:float, gt?: 0)
37
+ end
38
+ optional(:declared_value).maybe(:hash) do
39
+ required(:currency).filled(:string, eql?: "BYN")
40
+ required(:value).filled(:float, gt?: 0)
41
+ end
42
+ optional(:IOSS).maybe(:string)
43
+ optional(:registered_notification).maybe(:string)
44
+ optional(:simple_notification).maybe(:bool)
45
+ optional(:sms_notification).maybe(:bool)
46
+ optional(:email_notification).maybe(:bool)
47
+ optional(:priority_parcel).maybe(:bool)
48
+ optional(:attachment_inventory).maybe(:bool)
49
+ optional(:paid_shipping).maybe(:bool)
50
+ optional(:careful_fragile).maybe(:bool)
51
+ optional(:bulky).maybe(:bool)
52
+ optional(:ponderous).maybe(:bool)
53
+ optional(:payment_upon_receipt).maybe(:bool)
54
+ optional(:hand_over_personally).maybe(:bool)
55
+ optional(:return_of_documents).maybe(:bool)
56
+ optional(:open_upon_delivery).maybe(:bool)
57
+ optional(:delivery_to_work).maybe(:bool)
58
+ optional(:time_of_delivery).maybe(:hash) do
59
+ required(:type).filled(:string, included_in?: %w[level1 level2 level3 level4])
60
+ optional(:time_interval).hash do
61
+ required(:from).filled(:string)
62
+ required(:to).filled(:string)
63
+ end
64
+ end
65
+ end
66
+
67
+ required(:sender).hash do
68
+ required(:type).filled(:string, included_in?: %w[legal_person sole_proprietor])
69
+ required(:info).hash do
70
+ required(:organization_name).filled(:string)
71
+ optional(:taxpayer_number).maybe(:string)
72
+ optional(:bank).maybe(:string)
73
+ optional(:IBAN).maybe(:string)
74
+ optional(:BIC).maybe(:string)
75
+ end
76
+ required(:location).hash do
77
+ required(:code).filled(:string)
78
+ required(:region).filled(:string)
79
+ required(:district).filled(:string)
80
+ required(:locality).hash do
81
+ required(:type).filled(:string)
82
+ required(:name).filled(:string)
83
+ end
84
+ required(:road).hash do
85
+ required(:type).filled(:string)
86
+ required(:name).filled(:string)
87
+ end
88
+ required(:building).filled(:string)
89
+ optional(:housing).maybe(:string)
90
+ optional(:apartment).maybe(:string)
91
+ end
92
+ required(:email).filled(:string)
93
+ required(:phone).filled(:string)
94
+ end
95
+
96
+ required(:recipient).hash do
97
+ required(:type).filled(:string, included_in?: %w[natural_person legal_person sole_proprietor])
98
+ required(:info).hash do
99
+ optional(:organization_name).maybe(:string)
100
+ optional(:first_name).maybe(:string)
101
+ optional(:second_name).maybe(:string)
102
+ optional(:last_name).maybe(:string)
103
+ end
104
+ required(:location).hash do
105
+ optional(:code).maybe(:string)
106
+ optional(:region).maybe(:string)
107
+ optional(:district).maybe(:string)
108
+ optional(:locality).maybe(:hash) do
109
+ optional(:type).maybe(:string)
110
+ optional(:name).maybe(:string)
111
+ end
112
+ optional(:road).maybe(:hash) do
113
+ optional(:type).maybe(:string)
114
+ optional(:name).maybe(:string)
115
+ end
116
+ optional(:building).maybe(:string)
117
+ optional(:housing).maybe(:string)
118
+ optional(:apartment).maybe(:string)
119
+ optional(:address).maybe(:string)
120
+ end
121
+ optional(:email).maybe(:string)
122
+ required(:phone).filled(:string)
123
+ end
124
+
125
+ optional(:cp72).hash do
126
+ optional(:items).array(:hash) do
127
+ required(:name).filled(:string)
128
+ required(:local).filled(:string)
129
+ required(:unit).hash do
130
+ required(:local).filled(:string)
131
+ required(:en).filled(:string)
132
+ end
133
+ required(:count).filled(:integer, gt?: 0)
134
+ required(:weight).filled(:integer, gt?: 0)
135
+ required(:price).hash do
136
+ required(:currency).filled(:string)
137
+ required(:value).filled(:float, gt?: 0)
138
+ end
139
+ optional(:code).maybe(:string)
140
+ optional(:country).maybe(:string)
141
+ end
142
+ optional(:price).hash do
143
+ required(:currency).filled(:string)
144
+ required(:value).filled(:float, gt?: 0)
145
+ end
146
+ optional(:category).filled(:string, included_in?: %w[gift documents sample returned_goods merchandise other])
147
+ optional(:explanation).maybe(:string)
148
+ optional(:comments).maybe(:string)
149
+ optional(:invoice).maybe(:string)
150
+ optional(:licences).array(:string)
151
+ optional(:certificates).array(:string)
152
+ end
153
+ end
154
+ # rubocop:enable Metrics/BlockLength
155
+ end
156
+ # rubocop:enable Metrics/ModuleLength
157
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belpost
4
+ VERSION = "0.1.0"
5
+ end
data/lib/belpost.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dotenv/load"
4
+
5
+ require_relative "belpost/version"
6
+ require_relative "belpost/configuration"
7
+ require_relative "belpost/client"
8
+ require_relative "belpost/errors"
9
+ require_relative "belpost/retry"
10
+ require_relative "belpost/models/parcel"
11
+ require_relative "belpost/models/api_response"
12
+ require_relative "belpost/models/customs_declaration"
13
+ require_relative "belpost/models/parcel_builder"
14
+ require_relative "belpost/validations/parcel_schema"
15
+
16
+ # Module for working with Belpochta API.
17
+ # Provides an interface for configuring and interacting with the API.
18
+ module Belpost
19
+ class Error < StandardError; end
20
+
21
+ # Setting up the API configuration.
22
+ # @yieldparam config [Belpost::Configuration] configuration object.
23
+ def self.configure
24
+ yield configuration
25
+ end
26
+
27
+ # Returns the current configuration.
28
+ # @return [Belpost::Configuration]
29
+ def self.configuration
30
+ @configuration ||= Configuration.new
31
+ end
32
+
33
+ # Resets the configuration to default values.
34
+ def self.reset
35
+ @configuration = Configuration.new
36
+ end
37
+ end