yookassarb 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,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Entities
5
+ # Payment entity with status helpers and confirmation URL access.
6
+ #
7
+ # All attributes from the API response (id, status, amount, description, etc.)
8
+ # are accessible as methods via the inherited {Base} dynamic dispatch.
9
+ #
10
+ # @see https://yookassa.ru/developers/api#payment_object Payment object reference
11
+ class Payment < Base
12
+ # Returns the confirmation URL for redirect-based payment flows.
13
+ #
14
+ # @return [String, nil] the URL to redirect the user to, or +nil+ if not available
15
+ def confirmation_url
16
+ return nil unless attributes["confirmation"]
17
+
18
+ attributes.dig("confirmation", "confirmation_url")
19
+ end
20
+
21
+ # @return [Boolean] +true+ if the payment completed successfully
22
+ def succeeded?
23
+ status == "succeeded"
24
+ end
25
+
26
+ # @return [Boolean] +true+ if the payment is awaiting user action
27
+ def pending?
28
+ status == "pending"
29
+ end
30
+
31
+ # @return [Boolean] +true+ if the payment was canceled
32
+ def canceled?
33
+ status == "canceled"
34
+ end
35
+
36
+ # @return [Boolean] +true+ if the payment is authorized and awaiting capture
37
+ def waiting_for_capture?
38
+ status == "waiting_for_capture"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Entities
5
+ # Payout entity with status helpers.
6
+ #
7
+ # @see https://yookassa.ru/developers/api#payout_object Payout object reference
8
+ class Payout < Base
9
+ # @return [Boolean] +true+ if the payout completed successfully
10
+ def succeeded?
11
+ status == "succeeded"
12
+ end
13
+
14
+ # @return [Boolean] +true+ if the payout was canceled
15
+ def canceled?
16
+ status == "canceled"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Entities
5
+ # Receipt entity with status helpers.
6
+ #
7
+ # @see https://yookassa.ru/developers/api#receipt_object Receipt object reference
8
+ class Receipt < Base
9
+ # @return [Boolean] +true+ if the receipt was delivered successfully
10
+ def succeeded?
11
+ status == "succeeded"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Entities
5
+ # Refund entity with status helpers.
6
+ #
7
+ # @see https://yookassa.ru/developers/api#refund_object Refund object reference
8
+ class Refund < Base
9
+ # @return [Boolean] +true+ if the refund completed successfully
10
+ def succeeded?
11
+ status == "succeeded"
12
+ end
13
+
14
+ # @return [Boolean] +true+ if the refund was canceled
15
+ def canceled?
16
+ status == "canceled"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Entities
5
+ # Webhook subscription entity representing a registered webhook endpoint.
6
+ #
7
+ # @see https://yookassa.ru/developers/api#webhook_object Webhook object reference
8
+ class WebhookObj < Base
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ # Base error class for all YooKassa gem errors.
5
+ class Error < StandardError; end
6
+
7
+ # Error returned by the YooKassa API with structured details.
8
+ #
9
+ # @example Handling an API error
10
+ # begin
11
+ # Yookassa::Payment.find("nonexistent")
12
+ # rescue Yookassa::ApiError => e
13
+ # puts e.code # => "not_found"
14
+ # puts e.http_code # => 404
15
+ # puts e.description # => "Payment not found"
16
+ # end
17
+ class ApiError < Error
18
+ # @return [String, nil] YooKassa error code (e.g. "invalid_request", "not_found")
19
+ attr_reader :code
20
+
21
+ # @return [String, nil] human-readable error description
22
+ attr_reader :description
23
+
24
+ # @return [String, nil] name of the invalid parameter, if applicable
25
+ attr_reader :parameter
26
+
27
+ # @return [Hash] raw response details (:http_code, :body, :headers)
28
+ attr_reader :response
29
+
30
+ # @param code [String, nil] YooKassa error code
31
+ # @param description [String, nil] error description
32
+ # @param parameter [String, nil] invalid parameter name
33
+ # @param response [Hash] raw HTTP response info
34
+ def initialize(code: nil, description: nil, parameter: nil, response: {})
35
+ @code = code
36
+ @description = description
37
+ @parameter = parameter
38
+ @response = response
39
+ super(description || "API error (HTTP #{http_code})")
40
+ end
41
+
42
+ # @return [Integer, nil] HTTP status code
43
+ def http_code
44
+ @response[:http_code]
45
+ end
46
+
47
+ # @return [String, nil] raw response body
48
+ def response_body
49
+ @response[:body]
50
+ end
51
+
52
+ # @return [Hash, nil] response headers
53
+ def response_headers
54
+ @response[:headers]
55
+ end
56
+ end
57
+
58
+ # Raised when API returns 400 Bad Request.
59
+ class BadRequestError < ApiError; end
60
+
61
+ # Raised when API returns 401 Unauthorized.
62
+ class UnauthorizedError < ApiError; end
63
+
64
+ # Raised when API returns 403 Forbidden.
65
+ class ForbiddenError < ApiError; end
66
+
67
+ # Raised when API returns 404 Not Found.
68
+ class NotFoundError < ApiError; end
69
+
70
+ # Raised when API returns 429 Too Many Requests.
71
+ class TooManyRequestsError < ApiError; end
72
+
73
+ # Raised when API returns 500 Internal Server Error.
74
+ class InternalServerError < ApiError; end
75
+
76
+ # Raised when network connection fails.
77
+ class ConnectionError < Error; end
78
+
79
+ # Raised when request times out.
80
+ class TimeoutError < ConnectionError; end
81
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Yookassa
6
+ module Middleware
7
+ # Faraday middleware that maps HTTP error responses to typed exceptions.
8
+ #
9
+ # Inspects the response status and raises the appropriate {ApiError} subclass.
10
+ # The error body is parsed to extract YooKassa error code, description,
11
+ # and parameter name when available.
12
+ #
13
+ # @see Yookassa::ApiError
14
+ class ErrorHandler < Faraday::Middleware
15
+ # Maps HTTP status codes to error classes.
16
+ ERROR_MAP = {
17
+ 400 => Yookassa::BadRequestError,
18
+ 401 => Yookassa::UnauthorizedError,
19
+ 403 => Yookassa::ForbiddenError,
20
+ 404 => Yookassa::NotFoundError,
21
+ 429 => Yookassa::TooManyRequestsError,
22
+ 500 => Yookassa::InternalServerError
23
+ }.freeze
24
+
25
+ def on_complete(env)
26
+ return if env.success?
27
+
28
+ status = env.status
29
+ body = env.body
30
+ error_class = ERROR_MAP[status] || Yookassa::ApiError
31
+ error_data = parse_error_body(body)
32
+
33
+ raise error_class.new(
34
+ code: error_data[:code],
35
+ description: error_data[:description],
36
+ parameter: error_data[:parameter],
37
+ response: {
38
+ http_code: status,
39
+ body: body,
40
+ headers: env.response_headers
41
+ }
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def parse_error_body(body)
48
+ return {} if body.to_s.empty?
49
+
50
+ parsed = JSON.parse(body)
51
+ {
52
+ code: parsed["code"],
53
+ description: parsed["description"],
54
+ parameter: parsed["parameter"]
55
+ }
56
+ rescue JSON::ParserError
57
+ { description: body }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Yookassa
6
+ module Middleware
7
+ # Faraday middleware that ensures POST and DELETE requests include an idempotency key.
8
+ #
9
+ # If the +Idempotence-Key+ header is not already set, a UUID v4 is generated
10
+ # automatically. This prevents duplicate operations when requests are retried.
11
+ class Idempotency < Faraday::Middleware
12
+ # YooKassa idempotency header name.
13
+ IDEMPOTENCY_HEADER = "Idempotence-Key"
14
+
15
+ # @param app [#call] the next middleware in the Faraday stack
16
+ # @param idempotency_key [String, nil] optional fixed key (mainly for testing)
17
+ def initialize(app, idempotency_key: nil)
18
+ super(app)
19
+ @idempotency_key = idempotency_key
20
+ end
21
+
22
+ def on_request(env)
23
+ return unless %i[post delete].include?(env.method)
24
+
25
+ headers = env.request_headers
26
+ return if headers[IDEMPOTENCY_HEADER]
27
+
28
+ headers[IDEMPOTENCY_HEADER] = @idempotency_key || SecureRandom.uuid
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Middleware
5
+ # Faraday middleware that retries requests on transient failures.
6
+ #
7
+ # Retries on HTTP 202 (object not ready yet), 500 (server error),
8
+ # and Faraday connection/timeout exceptions. Uses linear backoff
9
+ # with configurable delay.
10
+ class Retry < Faraday::Middleware
11
+ # HTTP status codes that trigger a retry.
12
+ RETRYABLE_STATUSES = [202, 500].freeze
13
+
14
+ # @param app [#call] the next middleware in the Faraday stack
15
+ # @param max_retries [Integer] maximum number of retry attempts (default: 3)
16
+ # @param retry_delay [Float] base delay in seconds, multiplied by attempt number (default: 1.8)
17
+ def initialize(app, max_retries: 3, retry_delay: 1.8)
18
+ super(app)
19
+ @max_retries = max_retries
20
+ @retry_delay = retry_delay
21
+ end
22
+
23
+ def call(env)
24
+ attempt = 0
25
+
26
+ loop do
27
+ response = @app.call(env)
28
+ return response unless retryable_status?(response, attempt)
29
+
30
+ attempt = prepare_retry(attempt, env)
31
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
32
+ raise e if attempt >= @max_retries
33
+
34
+ attempt = prepare_retry(attempt, env)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def retryable_status?(response, attempt)
41
+ RETRYABLE_STATUSES.include?(response.status) && attempt < @max_retries
42
+ end
43
+
44
+ def prepare_retry(attempt, env)
45
+ attempt += 1
46
+ wait(attempt)
47
+ env.body = env.request_body
48
+ attempt
49
+ end
50
+
51
+ def wait(attempt)
52
+ sleep(@retry_delay * attempt)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Yookassa
7
+ module Resources
8
+ # Base resource class with HTTP request handling and Faraday connection setup.
9
+ #
10
+ # Subclasses declare +resource_path+ and +entity_class+ to inherit default
11
+ # +create+, +find+, and +list+ behaviour. Resources that need custom logic
12
+ # (e.g. {Payment}) override those methods directly.
13
+ #
14
+ # @abstract Subclass and declare +resource_path+ / +entity_class+
15
+ class Base
16
+ # @return [Client] the API client this resource belongs to
17
+ attr_reader :client
18
+
19
+ # @param client [Client] the API client instance
20
+ def initialize(client)
21
+ @client = client
22
+ end
23
+
24
+ # Declares or retrieves the API path for this resource.
25
+ #
26
+ # @param path [String, nil] the API path (e.g. "payments") when setting
27
+ # @return [String, nil] the configured path
28
+ def self.resource_path(path = nil)
29
+ if path
30
+ @resource_path = path
31
+ else
32
+ @resource_path
33
+ end
34
+ end
35
+
36
+ # Declares or retrieves the entity class for wrapping API responses.
37
+ #
38
+ # @param klass [Class, nil] the entity class when setting
39
+ # @return [Class, nil] the configured entity class
40
+ def self.entity_class(klass = nil)
41
+ if klass
42
+ @entity_class = klass
43
+ else
44
+ @entity_class
45
+ end
46
+ end
47
+
48
+ # Creates a new resource via POST.
49
+ #
50
+ # @param params [Hash] resource attributes
51
+ # @param idempotency_key [String, nil] optional idempotency key
52
+ # @return [Entities::Base] the created entity
53
+ # @raise [ApiError] on API failure
54
+ def create(params, idempotency_key: nil)
55
+ data = request(:post, resource_path, body: params, idempotency_key: idempotency_key)
56
+ entity_class.new(data)
57
+ end
58
+
59
+ # Retrieves a single resource by ID via GET.
60
+ #
61
+ # @param resource_id [String] the resource identifier
62
+ # @return [Entities::Base] the found entity
63
+ # @raise [NotFoundError] if resource does not exist
64
+ def find(resource_id)
65
+ data = request(:get, "#{resource_path}/#{resource_id}")
66
+ entity_class.new(data)
67
+ end
68
+
69
+ # Lists resources matching the given filters via GET.
70
+ #
71
+ # @param filters [Hash] query parameters (limit, cursor, status, etc.)
72
+ # @return [Entities::Collection] paginated collection of entities
73
+ def list(**filters)
74
+ build_collection(resource_path, entity_class: entity_class, query: filters)
75
+ end
76
+
77
+ private
78
+
79
+ def resource_path
80
+ self.class.resource_path
81
+ end
82
+
83
+ def entity_class
84
+ self.class.entity_class
85
+ end
86
+
87
+ def connection
88
+ @connection ||= build_connection
89
+ end
90
+
91
+ def request(method, path, body: nil, query: nil, idempotency_key: nil)
92
+ response = connection.public_send(method) do |req|
93
+ req.url path
94
+ req.params = query if query
95
+ req.body = JSON.generate(body) if body
96
+ req.headers["Idempotence-Key"] = idempotency_key if idempotency_key && %i[post delete].include?(method)
97
+ end
98
+
99
+ parse_response(response)
100
+ end
101
+
102
+ def parse_response(response)
103
+ body = response.body
104
+ return nil if body.to_s.empty?
105
+
106
+ JSON.parse(body)
107
+ end
108
+
109
+ def build_connection
110
+ Faraday.new(url: "https://api.yookassa.ru/v3") do |conn|
111
+ configure_headers(conn)
112
+ configure_auth(conn)
113
+ configure_middleware(conn)
114
+ conn.adapter Faraday.default_adapter
115
+ end
116
+ end
117
+
118
+ def configure_headers(conn)
119
+ conn.request :json
120
+ headers = conn.headers
121
+ headers["Content-Type"] = "application/json"
122
+ headers["User-Agent"] = "yookassarb/#{Yookassa::VERSION}"
123
+ end
124
+
125
+ def configure_auth(conn)
126
+ creds = client.config.credentials
127
+ auth_token = creds[:auth_token]
128
+ if auth_token
129
+ conn.request :authorization, "Bearer", auth_token
130
+ else
131
+ conn.request :authorization, :basic, creds[:shop_id].to_s, creds[:api_key].to_s
132
+ end
133
+ end
134
+
135
+ def configure_middleware(conn)
136
+ config = client.config
137
+ conn.use Yookassa::Middleware::Idempotency
138
+ conn.use Yookassa::Middleware::Retry,
139
+ max_retries: config.max_retries,
140
+ retry_delay: config.retry_delay
141
+ conn.use Yookassa::Middleware::ErrorHandler
142
+ conn.options.timeout = config.timeout
143
+ end
144
+
145
+ def build_collection(path, entity_class:, query: {})
146
+ data = request(:get, path, query: query)
147
+ Entities::Collection.new(
148
+ items: data["items"] || [],
149
+ next_cursor: data["next_cursor"],
150
+ entity_class: entity_class
151
+ )
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Resources
5
+ # REST resource for the +/v3/deals+ endpoint.
6
+ #
7
+ # Inherits +create+, +find+, and +list+ from {Base}.
8
+ #
9
+ # @see https://yookassa.ru/developers/api#create_deal API reference
10
+ class Deal < Base
11
+ resource_path "deals"
12
+ entity_class Entities::Deal
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Resources
5
+ # REST resource for the +/v3/invoices+ endpoint.
6
+ #
7
+ # @see https://yookassa.ru/developers/api YooKassa API reference
8
+ class Invoice < Base
9
+ # Creates a new invoice.
10
+ #
11
+ # @param params [Hash] invoice parameters
12
+ # @param idempotency_key [String, nil] optional idempotency key
13
+ # @return [Entities::Base]
14
+ # @raise [ApiError] on API failure
15
+ def create(params, idempotency_key: nil)
16
+ data = request(:post, "invoices", body: params, idempotency_key: idempotency_key)
17
+ Entities::Base.new(data)
18
+ end
19
+
20
+ # Retrieves an invoice by ID.
21
+ #
22
+ # @param invoice_id [String] the invoice identifier
23
+ # @return [Entities::Base]
24
+ # @raise [NotFoundError] if invoice does not exist
25
+ def find(invoice_id)
26
+ data = request(:get, "invoices/#{invoice_id}")
27
+ Entities::Base.new(data)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Resources
5
+ # REST resource for the +/v3/payments+ endpoint.
6
+ #
7
+ # Supports creating, finding, capturing, canceling, and listing payments.
8
+ #
9
+ # @see https://yookassa.ru/developers/api#create_payment API reference
10
+ class Payment < Base
11
+ # Creates a new payment.
12
+ #
13
+ # @param params [Hash] payment parameters (amount, confirmation, description, etc.)
14
+ # @param idempotency_key [String, nil] optional idempotency key
15
+ # @return [Entities::Payment]
16
+ # @raise [ApiError] on API failure
17
+ def create(params, idempotency_key: nil)
18
+ data = request(:post, "payments", body: params, idempotency_key: idempotency_key)
19
+ Entities::Payment.new(data)
20
+ end
21
+
22
+ # Retrieves a payment by ID.
23
+ #
24
+ # @param payment_id [String] the payment identifier
25
+ # @return [Entities::Payment]
26
+ # @raise [NotFoundError] if payment does not exist
27
+ def find(payment_id)
28
+ data = request(:get, "payments/#{payment_id}")
29
+ Entities::Payment.new(data)
30
+ end
31
+
32
+ # Captures an authorized payment (for two-stage payments).
33
+ #
34
+ # @param payment_id [String] the payment identifier
35
+ # @param params [Hash] optional capture parameters (amount for partial capture, etc.)
36
+ # @param idempotency_key [String, nil] optional idempotency key
37
+ # @return [Entities::Payment]
38
+ # @raise [ApiError] on API failure
39
+ def capture(payment_id, params = {}, idempotency_key: nil)
40
+ data = request(:post, "payments/#{payment_id}/capture", body: params, idempotency_key: idempotency_key)
41
+ Entities::Payment.new(data)
42
+ end
43
+
44
+ # Cancels a payment that has not yet been captured.
45
+ #
46
+ # @param payment_id [String] the payment identifier
47
+ # @param idempotency_key [String, nil] optional idempotency key
48
+ # @return [Entities::Payment]
49
+ # @raise [ApiError] on API failure
50
+ def cancel(payment_id, idempotency_key: nil)
51
+ data = request(:post, "payments/#{payment_id}/cancel", idempotency_key: idempotency_key)
52
+ Entities::Payment.new(data)
53
+ end
54
+
55
+ # Lists payments matching the given filters.
56
+ #
57
+ # @param filters [Hash] query filters (status, created_at, limit, cursor, etc.)
58
+ # @return [Entities::Collection<Entities::Payment>]
59
+ def list(**filters)
60
+ build_collection("payments", entity_class: Entities::Payment, query: filters)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Resources
5
+ # REST resource for the +/v3/payouts+ endpoint.
6
+ #
7
+ # Inherits +create+, +find+, and +list+ from {Base}.
8
+ #
9
+ # @see https://yookassa.ru/developers/api#create_payout API reference
10
+ class Payout < Base
11
+ resource_path "payouts"
12
+ entity_class Entities::Payout
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Resources
5
+ # REST resource for the +/v3/receipts+ endpoint.
6
+ #
7
+ # Inherits +create+, +find+, and +list+ from {Base}.
8
+ #
9
+ # @see https://yookassa.ru/developers/api#create_receipt API reference
10
+ class Receipt < Base
11
+ resource_path "receipts"
12
+ entity_class Entities::Receipt
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Resources
5
+ # REST resource for the +/v3/refunds+ endpoint.
6
+ #
7
+ # Inherits +create+, +find+, and +list+ from {Base}.
8
+ #
9
+ # @see https://yookassa.ru/developers/api#create_refund API reference
10
+ class Refund < Base
11
+ resource_path "refunds"
12
+ entity_class Entities::Refund
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yookassa
4
+ module Resources
5
+ # REST resource for the +/v3/me+ endpoint (shop settings).
6
+ #
7
+ # @see https://yookassa.ru/developers/api#me_object API reference
8
+ class Settings < Base
9
+ # Retrieves the current shop settings and account info.
10
+ #
11
+ # @return [Entities::Base] shop settings entity
12
+ # @raise [ApiError] on API failure
13
+ def retrieve
14
+ data = request(:get, "me")
15
+ Entities::Base.new(data)
16
+ end
17
+ end
18
+ end
19
+ end