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 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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payhub
4
+ VERSION = "1.0.1"
5
+ 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: []