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 +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE +21 -0
- data/README.md +114 -0
- data/lib/wire/client.rb +191 -0
- data/lib/wire/errors.rb +70 -0
- data/lib/wire/resources/base.rb +42 -0
- data/lib/wire/resources/charges.rb +19 -0
- data/lib/wire/resources/events.rb +19 -0
- data/lib/wire/resources/payment_intents.rb +49 -0
- data/lib/wire/resources/webhook_endpoints.rb +46 -0
- data/lib/wire/version.rb +5 -0
- data/lib/wire/webhook.rb +94 -0
- data/lib/wirepayment.rb +13 -0
- metadata +64 -0
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).
|
data/lib/wire/client.rb
ADDED
|
@@ -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
|
data/lib/wire/errors.rb
ADDED
|
@@ -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
|
data/lib/wire/version.rb
ADDED
data/lib/wire/webhook.rb
ADDED
|
@@ -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
|
data/lib/wirepayment.rb
ADDED
|
@@ -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: []
|