wirepayment 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b5791532cdc9970945e66a9d1743b4074db2ca11fa118b6d2a2b073618940df9
4
+ data.tar.gz: 4e14b8cf1875b6f3f091743a4da11260815751981840f341e073ff5d622c3036
5
+ SHA512:
6
+ metadata.gz: 2688c7e8b3ae09fe8149a73c13314bb9ade7ae5b476512d737182b66dac5d9853e5f83ddba6dbbb14ee3b20fe8fc5a715d5c1a77cdcd1c44991abab94fe05909
7
+ data.tar.gz: a156cbe508a41e8a0e89811d1cc86a88519099d20e5d0daacfdfa7f13a53e3a1dbfe727b75c1f8b88e72908d5f0e418d6edb923ece717883dc310441825804fa
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2026-06-07
9
+
10
+ ### Added
11
+ - Initial release of the `wirepayment` gem (`Wire` namespace).
12
+ - `Wire::Client` with Bearer auth, JSON encoding, configurable `base_url`,
13
+ `timeout`, `max_retries`, and `backoff`.
14
+ - Automatic `Idempotency-Key` on POST requests, reused across retries.
15
+ - Exponential backoff with jitter on 429, 5xx, and network errors; honors
16
+ `Retry-After`.
17
+ - Resources: payment intents (create, retrieve, confirm, cancel, list),
18
+ charges (retrieve, list), events (retrieve, list), webhook endpoints
19
+ (create, retrieve, update, delete, list).
20
+ - Cursor auto-pagination via lazy `Enumerator` following `has_more`.
21
+ - Typed `Wire::WireError` decoded from the API error envelope; distinct
22
+ `Wire::ConnectionError` / `Wire::TimeoutError` for transport failures.
23
+ - `Wire::Webhook.verify` with HMAC-SHA256 signature verification, constant-time
24
+ comparison, timestamp tolerance, and fail-closed behavior.
25
+
26
+ [1.0.0]: https://github.com/buildry-wire/wire-ruby/releases/tag/v1.0.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Buildry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # wirepayment
2
+
3
+ Official Ruby SDK for the [Wire](https://wire.mn) payment API — a unified
4
+ gateway over Mongolian payment operators. Server-side, Ruby 3.0+, built on the
5
+ standard library with no third-party runtime dependencies.
6
+
7
+ Full documentation: [docs.wire.mn](https://docs.wire.mn)
8
+
9
+ ## Install
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "wirepayment"
14
+ ```
15
+
16
+ ```bash
17
+ bundle install
18
+ # or
19
+ gem install wirepayment
20
+ ```
21
+
22
+ ```ruby
23
+ require "wirepayment"
24
+ ```
25
+
26
+ ## Quickstart
27
+
28
+ ```ruby
29
+ client = Wire::Client.new("sk_live_...")
30
+
31
+ # Amounts are in minor units (e.g. 50000 = 500.00 MNT).
32
+ pi = client.payment_intents.create(
33
+ amount: 50_000,
34
+ currency: "MNT",
35
+ allowed_operators: ["sandbox"] # the operator ids enabled on your account
36
+ )
37
+ puts pi["id"], pi["status"]
38
+
39
+ # Confirm it.
40
+ confirmed = client.payment_intents.confirm(pi["id"], return_url: "https://example.com/return")
41
+ puts confirmed["status"]
42
+ ```
43
+
44
+ ## Configuration
45
+
46
+ ```ruby
47
+ client = Wire::Client.new(
48
+ "sk_live_...",
49
+ base_url: "https://api.wire.mn", # default
50
+ timeout: 30, # seconds, default
51
+ max_retries: 2, # default; retries 429/5xx/network with backoff
52
+ backoff: 0.5 # base seconds for exponential backoff w/ jitter
53
+ )
54
+ ```
55
+
56
+ Every POST automatically sends an `Idempotency-Key` (generated if you don't
57
+ supply one), and the same key is reused across retries. Pass your own with
58
+ `idempotency_key:`.
59
+
60
+ ## Auto-pagination
61
+
62
+ `list` returns a lazy `Enumerator` that follows `has_more` across pages:
63
+
64
+ ```ruby
65
+ client.charges.list(limit: 50).each do |charge|
66
+ puts charge["id"]
67
+ end
68
+
69
+ # Or collect everything:
70
+ events = client.events.list.to_a
71
+ ```
72
+
73
+ ## Webhook verification
74
+
75
+ Verify against the **raw** request body, before any JSON parsing:
76
+
77
+ ```ruby
78
+ require "wirepayment"
79
+
80
+ # In a Rack/Rails controller:
81
+ payload = request.body.read
82
+ sig_header = request.headers[Wire::Webhook::SIGNATURE_HEADER] # "WirePayment-Signature"
83
+
84
+ begin
85
+ event = Wire::Webhook.verify(payload, sig_header, ENV["WIRE_WEBHOOK_SECRET"])
86
+ puts event["type"]
87
+ rescue Wire::SignatureVerificationError
88
+ head :bad_request
89
+ end
90
+ ```
91
+
92
+ ## Error handling
93
+
94
+ ```ruby
95
+ begin
96
+ client.payment_intents.retrieve("pi_missing")
97
+ rescue Wire::WireError => e
98
+ e.type # e.g. "invalid_request_error"
99
+ e.code # e.g. "resource_missing"
100
+ e.param
101
+ e.request_id # always preserved when present
102
+ e.doc_url
103
+ e.operator_decline_code
104
+ e.status_code # HTTP status
105
+ rescue Wire::ConnectionError => e
106
+ # network failure or timeout (Wire::TimeoutError is a subclass)
107
+ end
108
+ ```
109
+
110
+ The SDK never logs your API key and never includes it in error messages.
111
+
112
+ ## License
113
+
114
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ require_relative "errors"
9
+ require_relative "resources/payment_intents"
10
+ require_relative "resources/charges"
11
+ require_relative "resources/events"
12
+ require_relative "resources/webhook_endpoints"
13
+
14
+ module Wire
15
+ DEFAULT_BASE_URL = "https://api.wire.mn"
16
+
17
+ # Client is the Wire API client. Construct it with an API key (sk_live_...).
18
+ #
19
+ # client = Wire::Client.new("sk_live_...")
20
+ # pi = client.payment_intents.create(amount: 50_000, currency: "MNT")
21
+ class Client
22
+ attr_reader :payment_intents, :charges, :events, :webhook_endpoints
23
+
24
+ # @param api_key [String] secret API key (sk_live_...).
25
+ # @param base_url [String] API base URL.
26
+ # @param timeout [Numeric] per-request timeout in seconds.
27
+ # @param max_retries [Integer] retry attempts for 429/5xx/network errors.
28
+ # @param backoff [Numeric] base backoff in seconds (exponential w/ jitter).
29
+ # @param http_adapter an optional object responding to
30
+ # #call(method, uri, headers, body, timeout) -> Wire::Response. Used to
31
+ # inject a stub transport in tests; defaults to Net::HTTP.
32
+ def initialize(api_key, base_url: DEFAULT_BASE_URL, timeout: 30,
33
+ max_retries: 2, backoff: 0.5, http_adapter: nil)
34
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
35
+
36
+ @api_key = api_key
37
+ @base_url = base_url.sub(%r{/+\z}, "")
38
+ @timeout = timeout
39
+ @max_retries = max_retries
40
+ @backoff = backoff
41
+ @http_adapter = http_adapter || NetHTTPAdapter.new
42
+
43
+ @payment_intents = Resources::PaymentIntents.new(self)
44
+ @charges = Resources::Charges.new(self)
45
+ @events = Resources::Events.new(self)
46
+ @webhook_endpoints = Resources::WebhookEndpoints.new(self)
47
+ end
48
+
49
+ # request performs an HTTP request with auth, idempotency, retries, and
50
+ # error decoding, returning the parsed JSON body as a Hash.
51
+ #
52
+ # @param method [String] HTTP method ("GET", "POST", "DELETE").
53
+ # @param path [String] request path (e.g. "/v1/payment_intents").
54
+ # @param body [Hash, nil] request body, JSON-encoded when present.
55
+ # @param query [Hash, nil] query parameters.
56
+ # @param idempotency_key [String, nil] reused across retries; a random key
57
+ # is generated for POST when absent.
58
+ def request(method, path, body: nil, query: nil, idempotency_key: nil)
59
+ uri = build_uri(path, query)
60
+
61
+ headers = {
62
+ "Authorization" => "Bearer #{@api_key}",
63
+ "Accept" => "application/json"
64
+ }
65
+ body_str = nil
66
+ unless body.nil?
67
+ body_str = JSON.generate(body)
68
+ headers["Content-Type"] = "application/json"
69
+ end
70
+ if method == "POST"
71
+ headers["Idempotency-Key"] = idempotency_key || self.class.new_idempotency_key
72
+ end
73
+
74
+ attempt = 0
75
+ loop do
76
+ begin
77
+ resp = @http_adapter.call(method, uri, headers, body_str, @timeout)
78
+ rescue TimeoutError, ConnectionError => e
79
+ if attempt < @max_retries
80
+ sleep_for(backoff_delay(attempt))
81
+ attempt += 1
82
+ next
83
+ end
84
+ raise e
85
+ end
86
+
87
+ if (resp.status == 429 || resp.status >= 500) && attempt < @max_retries
88
+ delay = retry_after(resp.headers) || backoff_delay(attempt)
89
+ sleep_for(delay)
90
+ attempt += 1
91
+ next
92
+ end
93
+
94
+ return handle_response(resp)
95
+ end
96
+ end
97
+
98
+ def self.new_idempotency_key
99
+ "idk_#{SecureRandom.hex(16)}"
100
+ end
101
+
102
+ private
103
+
104
+ def build_uri(path, query)
105
+ uri = URI.parse(@base_url + path)
106
+ if query && !query.empty?
107
+ pairs = query.reject { |_k, v| v.nil? || v == "" || v == 0 }
108
+ uri.query = URI.encode_www_form(pairs) unless pairs.empty?
109
+ end
110
+ uri
111
+ end
112
+
113
+ def handle_response(resp)
114
+ body = resp.body.to_s
115
+ if resp.status >= 200 && resp.status < 300
116
+ return body.empty? ? {} : JSON.parse(body)
117
+ end
118
+ raise Wire.parse_error(resp.status, body)
119
+ end
120
+
121
+ # Exponential backoff with full jitter.
122
+ def backoff_delay(attempt)
123
+ base = @backoff * (2**attempt)
124
+ rand * base
125
+ end
126
+
127
+ def retry_after(headers)
128
+ raw = headers["retry-after"] || headers["Retry-After"]
129
+ return nil if raw.nil? || raw.to_s.empty?
130
+
131
+ n = Integer(raw, exception: false)
132
+ n&.positive? ? n.to_f : nil
133
+ end
134
+
135
+ def sleep_for(seconds)
136
+ sleep(seconds) if seconds&.positive?
137
+ end
138
+ end
139
+
140
+ # Response is the transport-agnostic result an http_adapter must return.
141
+ Response = Struct.new(:status, :headers, :body, keyword_init: true)
142
+
143
+ # NetHTTPAdapter is the default transport, built on stdlib Net::HTTP.
144
+ class NetHTTPAdapter
145
+ def call(method, uri, headers, body, timeout)
146
+ http = Net::HTTP.new(uri.host, uri.port)
147
+ http.use_ssl = (uri.scheme == "https")
148
+ http.open_timeout = timeout
149
+ http.read_timeout = timeout
150
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
151
+
152
+ req = build_request(method, uri, headers, body)
153
+
154
+ begin
155
+ res = http.request(req)
156
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
157
+ raise Wire::TimeoutError, "request timed out: #{e.message}"
158
+ rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
159
+ raise Wire::ConnectionError, "request failed: #{e.message}"
160
+ end
161
+
162
+ Wire::Response.new(
163
+ status: res.code.to_i,
164
+ headers: normalize_headers(res),
165
+ body: res.body
166
+ )
167
+ end
168
+
169
+ private
170
+
171
+ def build_request(method, uri, headers, body)
172
+ klass = case method
173
+ when "GET" then Net::HTTP::Get
174
+ when "POST" then Net::HTTP::Post
175
+ when "DELETE" then Net::HTTP::Delete
176
+ when "PUT" then Net::HTTP::Put
177
+ else raise ArgumentError, "unsupported method: #{method}"
178
+ end
179
+ req = klass.new(uri.request_uri)
180
+ headers.each { |k, v| req[k] = v }
181
+ req.body = body if body
182
+ req
183
+ end
184
+
185
+ def normalize_headers(res)
186
+ out = {}
187
+ res.each_header { |k, v| out[k.downcase] = v }
188
+ out
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Wire
6
+ # Base class for every error raised by this library.
7
+ class Error < StandardError; end
8
+
9
+ # WireError is a typed error decoded from the API error envelope
10
+ # ({ "error": { ... } }). It is raised for any non-2xx API response.
11
+ class WireError < Error
12
+ attr_reader :type, :code, :param, :request_id, :doc_url,
13
+ :operator_decline_code, :status_code
14
+
15
+ def initialize(message, type: "api_error", code: nil, param: nil,
16
+ request_id: nil, doc_url: nil, operator_decline_code: nil,
17
+ status_code: nil)
18
+ super(message)
19
+ @type = type
20
+ @code = code
21
+ @param = param
22
+ @request_id = request_id
23
+ @doc_url = doc_url
24
+ @operator_decline_code = operator_decline_code
25
+ @status_code = status_code
26
+ end
27
+
28
+ def to_s
29
+ "#{super} (type=#{type}, code=#{code}, status=#{status_code}, " \
30
+ "request_id=#{request_id})"
31
+ end
32
+ end
33
+
34
+ # Raised for connection failures and request timeouts. Distinct from
35
+ # WireError so callers can treat transport problems separately.
36
+ class ConnectionError < Error; end
37
+
38
+ # Raised when a request exceeds the configured timeout.
39
+ class TimeoutError < ConnectionError; end
40
+
41
+ # Decode the Wire error envelope; fall back to a generic error.
42
+ #
43
+ # The api_key is never read from or echoed into the error.
44
+ def self.parse_error(status, body)
45
+ begin
46
+ env = JSON.parse(body)
47
+ err = env.is_a?(Hash) ? env["error"] : nil
48
+ if err.is_a?(Hash)
49
+ return WireError.new(
50
+ err["message"] || "request failed",
51
+ type: err["type"] || "api_error",
52
+ code: err["code"],
53
+ param: err["param"],
54
+ request_id: err["request_id"],
55
+ doc_url: err["doc_url"],
56
+ operator_decline_code: err["operator_decline_code"],
57
+ status_code: status
58
+ )
59
+ end
60
+ rescue JSON::ParserError
61
+ # fall through to generic error
62
+ end
63
+
64
+ WireError.new(
65
+ "unexpected response (status #{status})",
66
+ type: "api_error",
67
+ status_code: status
68
+ )
69
+ end
70
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wire
4
+ module Resources
5
+ # Base provides shared helpers for resource classes, notably the
6
+ # cursor auto-pagination enumerator.
7
+ class Base
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ private
13
+
14
+ # paginate returns an Enumerator that yields every item across pages,
15
+ # following has_more via starting_after. It is lazy: pages are fetched
16
+ # only as the caller iterates.
17
+ #
18
+ # client.charges.list(limit: 50).each { |ch| ... }
19
+ # client.charges.list.to_a
20
+ def paginate(path, params)
21
+ params ||= {}
22
+ limit = params[:limit] || params["limit"]
23
+ after = params[:starting_after] || params["starting_after"] || ""
24
+
25
+ Enumerator.new do |yielder|
26
+ loop do
27
+ page = @client.request(
28
+ "GET", path,
29
+ query: { "limit" => limit, "starting_after" => (after unless after.to_s.empty?) }
30
+ )
31
+ data = page["data"] || []
32
+ data.each do |item|
33
+ after = item["id"]
34
+ yielder << item
35
+ end
36
+ break if !page["has_more"] || data.empty?
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Wire
6
+ module Resources
7
+ # Charges: retrieve, list.
8
+ class Charges < Base
9
+ def retrieve(id)
10
+ @client.request("GET", "/v1/charges/#{URI.encode_www_form_component(id.to_s)}")
11
+ end
12
+
13
+ # Returns an Enumerator that auto-paginates across all pages.
14
+ def list(params = {})
15
+ paginate("/v1/charges", params)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Wire
6
+ module Resources
7
+ # Events: retrieve, list.
8
+ class Events < Base
9
+ def retrieve(id)
10
+ @client.request("GET", "/v1/events/#{URI.encode_www_form_component(id.to_s)}")
11
+ end
12
+
13
+ # Returns an Enumerator that auto-paginates across all pages.
14
+ def list(params = {})
15
+ paginate("/v1/events", params)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Wire
6
+ module Resources
7
+ # PaymentIntents: create, retrieve, confirm, cancel, list.
8
+ class PaymentIntents < Base
9
+ # @param params [Hash] amount:, currency:, automatic_operator:,
10
+ # allowed_operators:, metadata:. Pass idempotency_key: to override.
11
+ def create(params = {})
12
+ params = params.dup
13
+ key = params.delete(:idempotency_key) || params.delete("idempotency_key")
14
+ @client.request("POST", "/v1/payment_intents", body: params, idempotency_key: key)
15
+ end
16
+
17
+ def retrieve(id)
18
+ @client.request("GET", "/v1/payment_intents/#{escape(id)}")
19
+ end
20
+
21
+ def confirm(id, params = {})
22
+ params = params.dup
23
+ key = params.delete(:idempotency_key) || params.delete("idempotency_key")
24
+ @client.request(
25
+ "POST", "/v1/payment_intents/#{escape(id)}/confirm",
26
+ body: params, idempotency_key: key
27
+ )
28
+ end
29
+
30
+ def cancel(id, idempotency_key: nil)
31
+ @client.request(
32
+ "POST", "/v1/payment_intents/#{escape(id)}/cancel",
33
+ body: {}, idempotency_key: idempotency_key
34
+ )
35
+ end
36
+
37
+ # Returns an Enumerator that auto-paginates across all pages.
38
+ def list(params = {})
39
+ paginate("/v1/payment_intents", params)
40
+ end
41
+
42
+ private
43
+
44
+ def escape(id)
45
+ URI.encode_www_form_component(id.to_s)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Wire
6
+ module Resources
7
+ # WebhookEndpoints: create, retrieve, update, delete, list.
8
+ class WebhookEndpoints < Base
9
+ # @param params [Hash] url:, enabled_events:. Pass idempotency_key: to override.
10
+ def create(params = {})
11
+ params = params.dup
12
+ key = params.delete(:idempotency_key) || params.delete("idempotency_key")
13
+ @client.request("POST", "/v1/webhook_endpoints", body: params, idempotency_key: key)
14
+ end
15
+
16
+ def retrieve(id)
17
+ @client.request("GET", "/v1/webhook_endpoints/#{escape(id)}")
18
+ end
19
+
20
+ # @param params [Hash] url:, enabled_events:, status:.
21
+ def update(id, params = {})
22
+ params = params.dup
23
+ key = params.delete(:idempotency_key) || params.delete("idempotency_key")
24
+ @client.request(
25
+ "POST", "/v1/webhook_endpoints/#{escape(id)}",
26
+ body: params, idempotency_key: key
27
+ )
28
+ end
29
+
30
+ def delete(id)
31
+ @client.request("DELETE", "/v1/webhook_endpoints/#{escape(id)}")
32
+ end
33
+
34
+ # Returns an Enumerator that auto-paginates across all pages.
35
+ def list(params = {})
36
+ paginate("/v1/webhook_endpoints", params)
37
+ end
38
+
39
+ private
40
+
41
+ def escape(id)
42
+ URI.encode_www_form_component(id.to_s)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wire
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ require_relative "errors"
7
+
8
+ module Wire
9
+ # Raised when a webhook signature does not verify (fail closed).
10
+ class SignatureVerificationError < Error; end
11
+
12
+ # Webhook verifies inbound webhook signatures.
13
+ #
14
+ # Header format: "WirePayment-Signature: t=<unix>,v1=<hex>" where
15
+ # hex = HMAC-SHA256(secret, "<t>.<rawBody>")
16
+ #
17
+ # Verification runs on the RAW request body, before any JSON parsing.
18
+ module Webhook
19
+ SIGNATURE_HEADER = "WirePayment-Signature"
20
+ DEFAULT_TOLERANCE_SECONDS = 300
21
+
22
+ module_function
23
+
24
+ # Verify a webhook signature and return the parsed event (Hash).
25
+ #
26
+ # @param payload [String] raw, unparsed request body.
27
+ # @param header [String] value of the WirePayment-Signature header.
28
+ # @param secret [String] endpoint signing secret (whsec_...).
29
+ # @param tolerance [Integer] max allowed clock skew in seconds.
30
+ # @raise [SignatureVerificationError] on any parse failure, timestamp
31
+ # outside tolerance, or signature mismatch.
32
+ def verify(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS)
33
+ verify_at(payload, header, secret, tolerance: tolerance, now: Time.now.to_i)
34
+ end
35
+
36
+ # verify_at is the testable core taking an explicit `now` (unix seconds).
37
+ def verify_at(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS, now:)
38
+ t, v1 = parse_header(header)
39
+ raise SignatureVerificationError, "malformed signature header" if t.nil? || v1.nil? || v1.empty?
40
+
41
+ if (now - t).abs > tolerance
42
+ raise SignatureVerificationError, "timestamp outside tolerance"
43
+ end
44
+
45
+ body = payload.to_s
46
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{body}")
47
+
48
+ unless secure_compare(expected, v1)
49
+ raise SignatureVerificationError, "signature mismatch"
50
+ end
51
+
52
+ JSON.parse(body)
53
+ end
54
+
55
+ # parse_header extracts t (Integer) and v1 (hex String) from the header.
56
+ # Returns [nil, nil] when t is missing or not an integer.
57
+ def parse_header(header)
58
+ t = nil
59
+ v1 = nil
60
+ header.to_s.split(",").each do |part|
61
+ k, v = part.strip.split("=", 2)
62
+ case k
63
+ when "t"
64
+ parsed = Integer(v, exception: false)
65
+ return [nil, nil] if parsed.nil?
66
+
67
+ t = parsed
68
+ when "v1"
69
+ v1 = v
70
+ end
71
+ end
72
+ [t, v1]
73
+ end
74
+
75
+ # Constant-time comparison. Prefers OpenSSL's fixed-length compare,
76
+ # falling back to a Rack-style XOR comparison on older rubies.
77
+ def secure_compare(a, b)
78
+ a = a.to_s
79
+ b = b.to_s
80
+ if OpenSSL.respond_to?(:fixed_length_secure_compare)
81
+ return false unless a.bytesize == b.bytesize
82
+
83
+ OpenSSL.fixed_length_secure_compare(a, b)
84
+ else
85
+ return false unless a.bytesize == b.bytesize
86
+
87
+ l = a.unpack("C*")
88
+ res = 0
89
+ b.each_byte { |byte| res |= byte ^ l.shift }
90
+ res.zero?
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wire/version"
4
+ require_relative "wire/errors"
5
+ require_relative "wire/webhook"
6
+ require_relative "wire/client"
7
+
8
+ # Wire is the top-level namespace for the Wire payment API SDK.
9
+ #
10
+ # client = Wire::Client.new("sk_live_...")
11
+ # pi = client.payment_intents.create(amount: 50_000, currency: "MNT")
12
+ module Wire
13
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wirepayment
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Buildry
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-07 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'Server-side Ruby client for the Wire unified payment gateway: payment
14
+ intents, charges, events, and webhook endpoints across Mongolian payment operators,
15
+ with automatic retries, idempotency, cursor auto-pagination, and webhook signature
16
+ verification.'
17
+ email:
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - LICENSE
24
+ - README.md
25
+ - lib/wire/client.rb
26
+ - lib/wire/errors.rb
27
+ - lib/wire/resources/base.rb
28
+ - lib/wire/resources/charges.rb
29
+ - lib/wire/resources/events.rb
30
+ - lib/wire/resources/payment_intents.rb
31
+ - lib/wire/resources/webhook_endpoints.rb
32
+ - lib/wire/version.rb
33
+ - lib/wire/webhook.rb
34
+ - lib/wirepayment.rb
35
+ homepage: https://github.com/buildry-wire/wire-ruby
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ homepage_uri: https://github.com/buildry-wire/wire-ruby
40
+ source_code_uri: https://github.com/buildry-wire/wire-ruby
41
+ documentation_uri: https://docs.wire.mn
42
+ changelog_uri: https://github.com/buildry-wire/wire-ruby/blob/main/CHANGELOG.md
43
+ bug_tracker_uri: https://github.com/buildry-wire/wire-ruby/issues
44
+ rubygems_mfa_required: 'true'
45
+ post_install_message:
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 3.5.22
61
+ signing_key:
62
+ specification_version: 4
63
+ summary: Official Ruby SDK for the Wire payment API.
64
+ test_files: []