payhub 1.0.1
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/LICENSE +21 -0
- data/README.md +102 -0
- data/lib/payhub/client.rb +174 -0
- data/lib/payhub/errors.rb +74 -0
- data/lib/payhub/next_action.rb +60 -0
- data/lib/payhub/types.rb +34 -0
- data/lib/payhub/version.rb +5 -0
- data/lib/payhub/webhook.rb +104 -0
- data/lib/payhub.rb +15 -0
- data/payhub.gemspec +29 -0
- metadata +101 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: df642ff56742eb5ad6bb7fc16383d398ae07fc9ade7c43a3d8d0cc66a9c40e4b
|
|
4
|
+
data.tar.gz: ba44c24e7fa075789e8d7ec1135be1ce725d006bebc9e135583d0d7a948a9eb8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1405e7530c8962ffcf8b617e304c418d8e7f03d538cf432250d98bf2c945490f441a93f6c0cbd114fdf1aefa04c7c9e409f48b663d4bda94273a88ed75804d0e
|
|
7
|
+
data.tar.gz: e8c98a274bd6e72ffa5992df693b6716c94a4a30f9ed9d11a430d90e84785cde7967667fd6d3eaa8aae5ab60453d63070a97142c1dd2bac9e805f0205d2fe96d
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Safwa Tech
|
|
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,102 @@
|
|
|
1
|
+
# PayHub Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official PayHub SDK for Ruby. Synchronous Net::HTTP transport, idempotent
|
|
4
|
+
retries, typed error hierarchy, webhook verifier, and a discriminated
|
|
5
|
+
`NextAction` you `case`-match on.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install payhub
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or in `Gemfile`:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "payhub", "~> 1.0"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart — Sadad OTP
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "payhub"
|
|
23
|
+
|
|
24
|
+
client = Payhub::Client.new(ENV.fetch("PAYHUB_API_KEY"))
|
|
25
|
+
|
|
26
|
+
payment = client.payments.create(
|
|
27
|
+
psp: "sadad",
|
|
28
|
+
merchant_order_ref: "ord-42",
|
|
29
|
+
amount_minor: 4500,
|
|
30
|
+
currency: "LYD",
|
|
31
|
+
customer: {msisdn: "218910000001", birth_year: 1990}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
case payment.next_action
|
|
35
|
+
in Payhub::NextAction::OtpRequired => otp
|
|
36
|
+
puts "Sadad sent OTP to #{otp.masked_destination}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
confirmed = client.payments.confirm_otp(payment.id, "111111")
|
|
40
|
+
puts confirmed.status # "succeeded"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Webhook verification (Rails / Sinatra / Rack)
|
|
44
|
+
|
|
45
|
+
The single most important rule: **verify the raw request body**, not a
|
|
46
|
+
parsed Hash. Re-serializing the JSON before HMAC will corrupt the signature.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# Rack / Sinatra
|
|
50
|
+
post "/webhooks/payhub" do
|
|
51
|
+
body = request.body.read
|
|
52
|
+
|
|
53
|
+
ev = Payhub::WebhookEvent.verify(
|
|
54
|
+
secret: ENV.fetch("PAYHUB_WEBHOOK_SECRET"),
|
|
55
|
+
body: body,
|
|
56
|
+
header: request.env["HTTP_HUB_SIGNATURE"]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# ev.type ∈ "payment.succeeded" | "payment.failed" | "payment.expired" | "payment.refunded"
|
|
60
|
+
status 200
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# Rails controller
|
|
66
|
+
class WebhooksController < ApplicationController
|
|
67
|
+
skip_before_action :verify_authenticity_token
|
|
68
|
+
|
|
69
|
+
def payhub
|
|
70
|
+
body = request.raw_post
|
|
71
|
+
ev = Payhub::WebhookEvent.verify(
|
|
72
|
+
ENV.fetch("PAYHUB_WEBHOOK_SECRET"),
|
|
73
|
+
body,
|
|
74
|
+
request.headers["Hub-Signature"]
|
|
75
|
+
)
|
|
76
|
+
head :ok
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Errors
|
|
82
|
+
|
|
83
|
+
| Class | When |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `Payhub::Errors::AuthenticationError` | 401 |
|
|
86
|
+
| `Payhub::Errors::PermissionError` | 403 |
|
|
87
|
+
| `Payhub::Errors::NotFoundError` | 404 |
|
|
88
|
+
| `Payhub::Errors::IdempotencyConflictError` | 409 |
|
|
89
|
+
| `Payhub::Errors::ValidationError` | 422 |
|
|
90
|
+
| `Payhub::Errors::RateLimitedError` | 429 (`#retry_after`) |
|
|
91
|
+
| `Payhub::Errors::GatewayError` | 5xx + `gateway.<psp>.*` |
|
|
92
|
+
| `Payhub::Errors::ServerError` | other 5xx |
|
|
93
|
+
| `Payhub::Errors::TimeoutError` | timeout |
|
|
94
|
+
| `Payhub::Errors::ConnectionError` | TCP / TLS / DNS failure |
|
|
95
|
+
| `Payhub::Errors::DecodeError` | malformed response |
|
|
96
|
+
| `Payhub::MalformedHeaderError` | webhook header missing `t=`/`v1=` |
|
|
97
|
+
| `Payhub::TimestampOutOfToleranceError` | webhook clock skew > 300 s |
|
|
98
|
+
| `Payhub::InvalidSignatureError` | webhook HMAC mismatch |
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT — see `LICENSE`.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "rbconfig"
|
|
8
|
+
|
|
9
|
+
require_relative "errors"
|
|
10
|
+
require_relative "types"
|
|
11
|
+
require_relative "version"
|
|
12
|
+
|
|
13
|
+
module Payhub
|
|
14
|
+
# Synchronous PayHub client. Thread-safe — share one instance per process;
|
|
15
|
+
# internally each request opens a fresh Net::HTTP connection (keep-alive
|
|
16
|
+
# is delegated to the OS-level connection pool of the HTTP server tier).
|
|
17
|
+
class Client
|
|
18
|
+
DEFAULT_BASE_URL = "https://app.payhub.ly"
|
|
19
|
+
DEFAULT_TIMEOUT = 30
|
|
20
|
+
DEFAULT_RETRIES = 2
|
|
21
|
+
|
|
22
|
+
attr_reader :payments, :health
|
|
23
|
+
|
|
24
|
+
def initialize(api_key, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
|
|
25
|
+
max_retries: DEFAULT_RETRIES, http_client: nil, user_agent_suffix: nil)
|
|
26
|
+
raise ArgumentError, "PayHub API key must start with 'phk_'" unless api_key.is_a?(String) && api_key.start_with?("phk_")
|
|
27
|
+
@api_key = api_key
|
|
28
|
+
@base_url = base_url.sub(%r{/+$}, "")
|
|
29
|
+
@timeout = timeout
|
|
30
|
+
@max_retries = max_retries
|
|
31
|
+
@http_client = http_client
|
|
32
|
+
@user_agent = build_user_agent(user_agent_suffix)
|
|
33
|
+
@payments = Payments.new(self)
|
|
34
|
+
@health = HealthResource.new(self)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Internal request helper used by resource classes.
|
|
38
|
+
def request(method, path, body: nil, idempotency_key: nil, retriable: true)
|
|
39
|
+
attempts = retriable ? [@max_retries + 1, 1].max : 1
|
|
40
|
+
last_err = nil
|
|
41
|
+
attempts.times do |attempt|
|
|
42
|
+
begin
|
|
43
|
+
status, headers, raw = perform_request(method, path, body, idempotency_key)
|
|
44
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
45
|
+
last_err = Errors::TimeoutError.new("payhub: timeout: #{e.message}")
|
|
46
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, EOFError, IOError => e
|
|
47
|
+
last_err = Errors::ConnectionError.new("payhub: connection: #{e.message}")
|
|
48
|
+
else
|
|
49
|
+
if (200..299).cover?(status)
|
|
50
|
+
return decode_2xx(raw)
|
|
51
|
+
end
|
|
52
|
+
err = build_api_error(status, raw, headers)
|
|
53
|
+
if retriable && (status >= 500 || status == 429) && attempt + 1 < attempts
|
|
54
|
+
wait = retry_after(headers) || backoff_seconds(attempt)
|
|
55
|
+
sleep(wait)
|
|
56
|
+
last_err = err
|
|
57
|
+
next
|
|
58
|
+
end
|
|
59
|
+
raise err
|
|
60
|
+
end
|
|
61
|
+
sleep(backoff_seconds(attempt)) if attempt + 1 < attempts
|
|
62
|
+
end
|
|
63
|
+
raise last_err if last_err
|
|
64
|
+
raise Errors::Error, "payhub: unreachable retry loop"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def perform_request(method, path, body, idempotency_key)
|
|
70
|
+
uri = URI.parse(@base_url + path)
|
|
71
|
+
req_class = case method
|
|
72
|
+
when :get then Net::HTTP::Get
|
|
73
|
+
when :post then Net::HTTP::Post
|
|
74
|
+
when :delete then Net::HTTP::Delete
|
|
75
|
+
else raise ArgumentError, "unsupported method: #{method}"
|
|
76
|
+
end
|
|
77
|
+
req = req_class.new(uri.request_uri)
|
|
78
|
+
req["Authorization"] = "Bearer #{@api_key}"
|
|
79
|
+
req["Accept"] = "application/json"
|
|
80
|
+
req["User-Agent"] = @user_agent
|
|
81
|
+
req["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
82
|
+
if body
|
|
83
|
+
req["Content-Type"] = "application/json"
|
|
84
|
+
req.body = JSON.generate(body)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
http = @http_client || begin
|
|
88
|
+
h = Net::HTTP.new(uri.host, uri.port)
|
|
89
|
+
h.use_ssl = (uri.scheme == "https")
|
|
90
|
+
h.open_timeout = @timeout
|
|
91
|
+
h.read_timeout = @timeout
|
|
92
|
+
h
|
|
93
|
+
end
|
|
94
|
+
resp = http.request(req)
|
|
95
|
+
[resp.code.to_i, resp.to_hash.transform_keys(&:downcase), resp.body || ""]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def decode_2xx(raw)
|
|
99
|
+
return nil if raw.nil? || raw.empty?
|
|
100
|
+
JSON.parse(raw)
|
|
101
|
+
rescue JSON::ParserError => e
|
|
102
|
+
raise Errors::DecodeError, "payhub: decode: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_api_error(status, raw, headers)
|
|
106
|
+
envelope = parse_envelope(raw, status)
|
|
107
|
+
Errors.from_envelope(envelope, status, retry_after: retry_after(headers))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parse_envelope(raw, status)
|
|
111
|
+
JSON.parse(raw)
|
|
112
|
+
rescue JSON::ParserError, TypeError
|
|
113
|
+
{"error" => {"code" => "hub.unknown", "message" => "HTTP #{status}"}}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def retry_after(headers)
|
|
117
|
+
v = headers && (headers["retry-after"] || headers["Retry-After"])
|
|
118
|
+
Array(v).first&.to_i
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def backoff_seconds(attempt)
|
|
122
|
+
base = 0.5 * (2**attempt)
|
|
123
|
+
base * (0.8 + rand * 0.4)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_user_agent(suffix)
|
|
127
|
+
base = "payhub-ruby/#{VERSION} (ruby #{RUBY_VERSION}; #{RbConfig::CONFIG["host_os"]})"
|
|
128
|
+
suffix ? "#{base} #{suffix}" : base
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
class Payments
|
|
132
|
+
def initialize(client)
|
|
133
|
+
@c = client
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def create(request, idempotency_key: nil)
|
|
137
|
+
key = idempotency_key || SecureRandom.uuid
|
|
138
|
+
raw = @c.request(:post, "/v1/payments", body: request, idempotency_key: key)
|
|
139
|
+
Payment.from_raw(raw)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def confirm_otp(payment_id, code, idempotency_key: nil)
|
|
143
|
+
key = idempotency_key || SecureRandom.uuid
|
|
144
|
+
raw = @c.request(:post, "/v1/payments/#{payment_id}/otp",
|
|
145
|
+
body: {code: code}, idempotency_key: key)
|
|
146
|
+
Payment.from_raw(raw)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def refund(payment_id, amount_minor: nil, reason: nil, idempotency_key: nil)
|
|
150
|
+
body = {}
|
|
151
|
+
body[:amount_minor] = amount_minor unless amount_minor.nil?
|
|
152
|
+
body[:reason] = reason unless reason.nil?
|
|
153
|
+
key = idempotency_key || SecureRandom.uuid
|
|
154
|
+
raw = @c.request(:post, "/v1/payments/#{payment_id}/refund",
|
|
155
|
+
body: body, idempotency_key: key)
|
|
156
|
+
Payment.from_raw(raw)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def retrieve(payment_id)
|
|
160
|
+
Payment.from_raw(@c.request(:get, "/v1/payments/#{payment_id}"))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
class HealthResource
|
|
165
|
+
def initialize(client)
|
|
166
|
+
@c = client
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def check
|
|
170
|
+
Health.from_raw(@c.request(:get, "/v1/health"))
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Typed exception hierarchy mirroring app/core/errors.py. Maps the server's
|
|
4
|
+
# {error: {code, message, details, request_id}} envelope plus HTTP status to
|
|
5
|
+
# a precise subclass so callers `rescue Payhub::Errors::AuthenticationError`
|
|
6
|
+
# instead of inspecting strings.
|
|
7
|
+
|
|
8
|
+
module Payhub
|
|
9
|
+
module Errors
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
class APIError < Error
|
|
13
|
+
attr_reader :code, :http_status, :details, :request_id
|
|
14
|
+
|
|
15
|
+
def initialize(message, code:, http_status:, details: nil, request_id: nil)
|
|
16
|
+
msg = request_id ? "#{message} [request_id=#{request_id}]" : message
|
|
17
|
+
super(msg)
|
|
18
|
+
@code = code
|
|
19
|
+
@http_status = http_status
|
|
20
|
+
@details = details || {}
|
|
21
|
+
@request_id = request_id
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class AuthenticationError < APIError; end
|
|
26
|
+
class PermissionError < APIError; end
|
|
27
|
+
class NotFoundError < APIError; end
|
|
28
|
+
class ValidationError < APIError; end
|
|
29
|
+
class IdempotencyConflictError < APIError; end
|
|
30
|
+
|
|
31
|
+
class RateLimitedError < APIError
|
|
32
|
+
attr_reader :retry_after
|
|
33
|
+
|
|
34
|
+
def initialize(message, retry_after: nil, **kwargs)
|
|
35
|
+
super(message, **kwargs)
|
|
36
|
+
@retry_after = retry_after
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class GatewayError < APIError; end
|
|
41
|
+
class ServerError < APIError; end
|
|
42
|
+
|
|
43
|
+
class TransportError < Error; end
|
|
44
|
+
class TimeoutError < TransportError; end
|
|
45
|
+
class ConnectionError < TransportError; end
|
|
46
|
+
class DecodeError < TransportError; end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
def from_envelope(envelope, http_status, retry_after: nil)
|
|
50
|
+
err = (envelope.is_a?(Hash) && envelope["error"].is_a?(Hash)) ? envelope["error"] : {}
|
|
51
|
+
code = err["code"] || "hub.unknown"
|
|
52
|
+
message = err["message"] || "HTTP #{http_status}"
|
|
53
|
+
details = err["details"] || {}
|
|
54
|
+
request_id = err["request_id"]
|
|
55
|
+
common = {code: code, http_status: http_status, details: details, request_id: request_id}
|
|
56
|
+
|
|
57
|
+
case http_status
|
|
58
|
+
when 401 then AuthenticationError.new(message, **common)
|
|
59
|
+
when 403 then PermissionError.new(message, **common)
|
|
60
|
+
when 404 then NotFoundError.new(message, **common)
|
|
61
|
+
when 409 then IdempotencyConflictError.new(message, **common)
|
|
62
|
+
when 422 then ValidationError.new(message, **common)
|
|
63
|
+
when 429 then RateLimitedError.new(message, retry_after: retry_after, **common)
|
|
64
|
+
else
|
|
65
|
+
if (500..599).cover?(http_status)
|
|
66
|
+
code.start_with?("gateway.") ? GatewayError.new(message, **common) : ServerError.new(message, **common)
|
|
67
|
+
else
|
|
68
|
+
APIError.new(message, **common)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Discriminated NextAction returned in payment.next_action.
|
|
4
|
+
#
|
|
5
|
+
# Ruby has no sealed-classes; instead, decode_next_action returns a frozen
|
|
6
|
+
# struct subclass keyed off the kind so callers can `case na in OtpRequired`.
|
|
7
|
+
|
|
8
|
+
module Payhub
|
|
9
|
+
module NextAction
|
|
10
|
+
OtpRequired = Struct.new(:psp_ref, :masked_destination, :expires_at, keyword_init: true) do
|
|
11
|
+
def kind = :otp_required
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
Redirect = Struct.new(:url, :method, :fields, :expires_at, keyword_init: true) do
|
|
15
|
+
def kind = :redirect
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
QR = Struct.new(:reference, :qr_payload, :expires_at, keyword_init: true) do
|
|
19
|
+
def kind = :qr
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Lightbox = Struct.new(:params, :script_url, keyword_init: true) do
|
|
23
|
+
def kind = :lightbox
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def decode(raw)
|
|
28
|
+
return nil if raw.nil?
|
|
29
|
+
raise ArgumentError, "next_action must be an object or nil" unless raw.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
case raw["type"]
|
|
32
|
+
when "otp_required"
|
|
33
|
+
OtpRequired.new(
|
|
34
|
+
psp_ref: raw["psp_ref"].to_s,
|
|
35
|
+
masked_destination: raw["masked_destination"].to_s,
|
|
36
|
+
expires_at: raw["expires_at"]
|
|
37
|
+
)
|
|
38
|
+
when "redirect"
|
|
39
|
+
Redirect.new(
|
|
40
|
+
url: raw["url"].to_s,
|
|
41
|
+
method: (raw["method"] || "GET").to_s.upcase,
|
|
42
|
+
fields: (raw["fields"] || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v.to_s },
|
|
43
|
+
expires_at: raw["expires_at"]
|
|
44
|
+
)
|
|
45
|
+
when "qr"
|
|
46
|
+
QR.new(
|
|
47
|
+
reference: raw["reference"].to_s,
|
|
48
|
+
qr_payload: raw["qr_payload"].to_s,
|
|
49
|
+
expires_at: raw["expires_at"]
|
|
50
|
+
)
|
|
51
|
+
when "lightbox"
|
|
52
|
+
params = (raw["params"] || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v.to_s }
|
|
53
|
+
Lightbox.new(params: params, script_url: params["lightbox_js_url"])
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError, "unknown next_action.type: #{raw["type"].inspect}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/payhub/types.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "next_action"
|
|
4
|
+
|
|
5
|
+
module Payhub
|
|
6
|
+
Payment = Struct.new(
|
|
7
|
+
:id, :status, :psp, :psp_ref, :next_action, :amount_minor,
|
|
8
|
+
:currency, :merchant_order_ref, :hosted_checkout_url,
|
|
9
|
+
keyword_init: true
|
|
10
|
+
) do
|
|
11
|
+
def self.from_raw(raw)
|
|
12
|
+
new(
|
|
13
|
+
id: raw["id"].to_s,
|
|
14
|
+
status: raw["status"].to_s,
|
|
15
|
+
psp: raw["psp"].to_s,
|
|
16
|
+
psp_ref: raw["psp_ref"],
|
|
17
|
+
next_action: Payhub::NextAction.decode(raw["next_action"]),
|
|
18
|
+
amount_minor: raw["amount_minor"].to_i,
|
|
19
|
+
currency: raw["currency"].to_s,
|
|
20
|
+
merchant_order_ref: raw["merchant_order_ref"].to_s,
|
|
21
|
+
hosted_checkout_url: raw["hosted_checkout_url"]
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Refund row currently mirrors Payment.
|
|
27
|
+
Refund = Payment
|
|
28
|
+
|
|
29
|
+
Health = Struct.new(:status, :psps, keyword_init: true) do
|
|
30
|
+
def self.from_raw(raw)
|
|
31
|
+
new(status: raw["status"].to_s, psps: Array(raw["psps"]))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
# Webhook signature verification.
|
|
7
|
+
#
|
|
8
|
+
# Algorithmic reference: app/core/signing.py. Header is
|
|
9
|
+
# Hub-Signature: t=<unix>,v1=<hmac_sha256_hex>
|
|
10
|
+
# Signed bytes: "#{t}.".b + raw_body. Default tolerance ±300 s.
|
|
11
|
+
#
|
|
12
|
+
# Every PayHub SDK ports the same algorithm; the canonical fixtures at
|
|
13
|
+
# sdks/shared/test-vectors/webhook-signing.json are the spec.
|
|
14
|
+
|
|
15
|
+
module Payhub
|
|
16
|
+
class WebhookSignatureError < StandardError; end
|
|
17
|
+
|
|
18
|
+
class MalformedHeaderError < WebhookSignatureError; end
|
|
19
|
+
|
|
20
|
+
class TimestampOutOfToleranceError < WebhookSignatureError
|
|
21
|
+
attr_reader :skew_seconds
|
|
22
|
+
|
|
23
|
+
def initialize(skew_seconds)
|
|
24
|
+
super("webhook timestamp out of tolerance: #{skew_seconds}s skew")
|
|
25
|
+
@skew_seconds = skew_seconds
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class InvalidSignatureError < WebhookSignatureError; end
|
|
30
|
+
|
|
31
|
+
WebhookEventPayload = Struct.new(
|
|
32
|
+
:id, :type, :payment_id, :prev_status, :new_status, :source, :payload, :created_at,
|
|
33
|
+
keyword_init: true
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
module WebhookEvent
|
|
37
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# Verify a webhook delivery and return the decoded event.
|
|
41
|
+
# Raises Payhub::MalformedHeaderError, TimestampOutOfToleranceError, or InvalidSignatureError.
|
|
42
|
+
def verify(secret, body, header, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil)
|
|
43
|
+
secret_b = secret.is_a?(String) ? secret.b : secret.to_s.b
|
|
44
|
+
body_b = body.is_a?(String) ? body.b : body.to_s.b
|
|
45
|
+
|
|
46
|
+
t, v1 = parse_header(header)
|
|
47
|
+
wall_now = now || Time.now.to_i
|
|
48
|
+
skew = (wall_now - t).abs
|
|
49
|
+
raise TimestampOutOfToleranceError.new(skew) if skew > tolerance_seconds
|
|
50
|
+
|
|
51
|
+
signed = "#{t}.".b + body_b
|
|
52
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret_b, signed)
|
|
53
|
+
raise InvalidSignatureError, "Hub-Signature v1 does not match" unless secure_compare(expected, v1)
|
|
54
|
+
|
|
55
|
+
decode_payload(body_b)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def parse_header(header)
|
|
61
|
+
parts = {}
|
|
62
|
+
header.to_s.split(",").each do |seg|
|
|
63
|
+
k, _, v = seg.partition("=")
|
|
64
|
+
parts[k.strip] = v.strip unless v.empty?
|
|
65
|
+
end
|
|
66
|
+
unless parts.key?("t") && parts.key?("v1")
|
|
67
|
+
raise MalformedHeaderError, "Hub-Signature missing t or v1: #{header.inspect}"
|
|
68
|
+
end
|
|
69
|
+
begin
|
|
70
|
+
t = Integer(parts["t"], 10)
|
|
71
|
+
rescue ArgumentError, TypeError
|
|
72
|
+
raise MalformedHeaderError, "Hub-Signature t is not an integer: #{parts["t"].inspect}"
|
|
73
|
+
end
|
|
74
|
+
[t, parts["v1"]]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def secure_compare(a, b)
|
|
78
|
+
return false unless a.bytesize == b.bytesize
|
|
79
|
+
OpenSSL.fixed_length_secure_compare(a.b, b.b)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def decode_payload(body_bytes)
|
|
83
|
+
return WebhookEventPayload.new if body_bytes.empty?
|
|
84
|
+
begin
|
|
85
|
+
raw = JSON.parse(body_bytes)
|
|
86
|
+
rescue JSON::ParserError => e
|
|
87
|
+
raise InvalidSignatureError, "webhook body is not JSON: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
raise InvalidSignatureError, "webhook body is not a JSON object" unless raw.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
WebhookEventPayload.new(
|
|
92
|
+
id: raw["id"].to_s,
|
|
93
|
+
type: raw["type"].to_s,
|
|
94
|
+
payment_id: raw["payment_id"].to_s,
|
|
95
|
+
prev_status: raw["prev_status"],
|
|
96
|
+
new_status: raw["new_status"].to_s,
|
|
97
|
+
source: raw["source"].to_s,
|
|
98
|
+
payload: raw["payload"] || {},
|
|
99
|
+
created_at: raw["created_at"].to_s
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/payhub.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "payhub/version"
|
|
4
|
+
require_relative "payhub/errors"
|
|
5
|
+
require_relative "payhub/next_action"
|
|
6
|
+
require_relative "payhub/types"
|
|
7
|
+
require_relative "payhub/webhook"
|
|
8
|
+
require_relative "payhub/client"
|
|
9
|
+
|
|
10
|
+
# Top-level shortcut so `Payhub.new("phk_…")` works.
|
|
11
|
+
module Payhub
|
|
12
|
+
def self.new(*args, **kwargs)
|
|
13
|
+
Client.new(*args, **kwargs)
|
|
14
|
+
end
|
|
15
|
+
end
|
data/payhub.gemspec
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/payhub/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "payhub"
|
|
7
|
+
spec.version = Payhub::VERSION
|
|
8
|
+
spec.authors = ["SafwaTech"]
|
|
9
|
+
spec.email = ["sdk@payhub.ly"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Official PayHub SDK for Ruby."
|
|
12
|
+
spec.description = "Idempotent client + webhook verifier for the PayHub v1 payment hub. Targets Libyan PSPs (Sadad, Moamalat, Mobicash, T-Lync, Adfali)."
|
|
13
|
+
spec.homepage = "https://payhub.ly"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
16
|
+
|
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/safwatech/payhub-ruby"
|
|
19
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/safwatech/payhub-ruby/issues"
|
|
20
|
+
spec.metadata["changelog_uri"] = "https://github.com/safwatech/payhub-ruby/blob/main/CHANGELOG.md"
|
|
21
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
22
|
+
|
|
23
|
+
spec.files = Dir.glob("{lib,LICENSE,README.md}/**/*") + ["LICENSE", "README.md", "payhub.gemspec"]
|
|
24
|
+
spec.require_paths = ["lib"]
|
|
25
|
+
|
|
26
|
+
spec.add_development_dependency "rspec", "~> 3.13"
|
|
27
|
+
spec.add_development_dependency "webmock", "~> 3.23"
|
|
28
|
+
spec.add_development_dependency "standard", "~> 1.40"
|
|
29
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: payhub
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- SafwaTech
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-09 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rspec
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.13'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.13'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: webmock
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.23'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.23'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: standard
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.40'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.40'
|
|
55
|
+
description: Idempotent client + webhook verifier for the PayHub v1 payment hub. Targets
|
|
56
|
+
Libyan PSPs (Sadad, Moamalat, Mobicash, T-Lync, Adfali).
|
|
57
|
+
email:
|
|
58
|
+
- sdk@payhub.ly
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- LICENSE
|
|
64
|
+
- README.md
|
|
65
|
+
- lib/payhub.rb
|
|
66
|
+
- lib/payhub/client.rb
|
|
67
|
+
- lib/payhub/errors.rb
|
|
68
|
+
- lib/payhub/next_action.rb
|
|
69
|
+
- lib/payhub/types.rb
|
|
70
|
+
- lib/payhub/version.rb
|
|
71
|
+
- lib/payhub/webhook.rb
|
|
72
|
+
- payhub.gemspec
|
|
73
|
+
homepage: https://payhub.ly
|
|
74
|
+
licenses:
|
|
75
|
+
- MIT
|
|
76
|
+
metadata:
|
|
77
|
+
homepage_uri: https://payhub.ly
|
|
78
|
+
source_code_uri: https://github.com/safwatech/payhub-ruby
|
|
79
|
+
bug_tracker_uri: https://github.com/safwatech/payhub-ruby/issues
|
|
80
|
+
changelog_uri: https://github.com/safwatech/payhub-ruby/blob/main/CHANGELOG.md
|
|
81
|
+
rubygems_mfa_required: 'true'
|
|
82
|
+
post_install_message:
|
|
83
|
+
rdoc_options: []
|
|
84
|
+
require_paths:
|
|
85
|
+
- lib
|
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: 3.1.0
|
|
91
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
requirements: []
|
|
97
|
+
rubygems_version: 3.5.22
|
|
98
|
+
signing_key:
|
|
99
|
+
specification_version: 4
|
|
100
|
+
summary: Official PayHub SDK for Ruby.
|
|
101
|
+
test_files: []
|