cashfree_pg 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module CashfreePg
7
+ # Verifies a Cashfree webhook, then parses it.
8
+ #
9
+ # The signature is Base64(HMAC-SHA256(timestamp + raw body, client secret)).
10
+ # Pass the raw body string. A parsed Hash will not match.
11
+ # `tolerance` is a timestamp window in seconds. It is off unless you set it,
12
+ # because Cashfree retries reuse the original timestamp.
13
+ class Webhook
14
+ # A verified event. Unknown `type` values are returned as sent.
15
+ #
16
+ # @!attribute [r] type
17
+ # @return [String, nil]
18
+ # @!attribute [r] time
19
+ # @return [String, nil] Cashfree `event_time`
20
+ # @!attribute [r] data
21
+ # @return [Hash, nil]
22
+ # @!attribute [r] version
23
+ # @return [String, nil] `x-webhook-version`
24
+ Event = Data.define(:type, :time, :data, :version)
25
+
26
+ # @param raw_body [String] exact request body, not a parsed Hash
27
+ # @param headers [Hash] plain Hash, Rack env, or Rails headers
28
+ # @param secret [String] Cashfree client secret
29
+ # @param tolerance [Numeric, nil] seconds; nil skips the timestamp check
30
+ # @return [Event]
31
+ # @raise [SignatureVerificationError]
32
+ # @example
33
+ # CashfreePg::Webhook.construct_event(
34
+ # raw_body: request.raw_post,
35
+ # headers: request.headers,
36
+ # secret: ENV.fetch("CASHFREE_CLIENT_SECRET")
37
+ # )
38
+ def self.construct_event(raw_body:, headers:, secret:, tolerance: nil)
39
+ new(raw_body, headers, secret, tolerance).construct
40
+ end
41
+ private_class_method :new
42
+
43
+ # @api private
44
+ def initialize(raw_body, headers, secret, tolerance)
45
+ @raw_body = raw_body
46
+ @headers = headers
47
+ @secret = secret
48
+ @tolerance = tolerance
49
+ end
50
+
51
+ # @api private
52
+ def construct
53
+ check_input!
54
+ signature = present_header("x-webhook-signature")
55
+ timestamp = present_header("x-webhook-timestamp")
56
+ raise SignatureVerificationError, "webhook signature does not match" unless valid?(timestamp, signature)
57
+
58
+ check_window!(timestamp)
59
+ build_event(header("x-webhook-version"))
60
+ end
61
+
62
+ private
63
+
64
+ # A blank secret would let anyone sign with an empty key.
65
+ def check_input!
66
+ raise SignatureVerificationError, "webhook secret is missing" if @secret.to_s.strip.empty?
67
+ raise SignatureVerificationError, "webhook body must be a string" unless @raw_body.is_a?(String)
68
+ raise SignatureVerificationError, "webhook body is empty" if @raw_body.empty?
69
+ raise SignatureVerificationError, "webhook headers are missing" unless @headers.respond_to?(:each)
70
+ end
71
+
72
+ def valid?(timestamp, signature)
73
+ digest = OpenSSL::HMAC.digest("SHA256", @secret.to_s, "#{timestamp}#{@raw_body}")
74
+ expected = [digest].pack("m0")
75
+ signature.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(expected, signature)
76
+ end
77
+
78
+ def check_window!(timestamp)
79
+ return if @tolerance.nil? || within_window?(timestamp)
80
+
81
+ raise SignatureVerificationError, "webhook timestamp is outside the allowed window"
82
+ end
83
+
84
+ def within_window?(timestamp)
85
+ (Time.now.to_f - (Integer(timestamp, 10) / 1000.0)).abs <= @tolerance
86
+ rescue ArgumentError
87
+ false
88
+ end
89
+
90
+ def present_header(name)
91
+ value = header(name).to_s
92
+ raise SignatureVerificationError, "webhook #{name.delete_prefix("x-webhook-")} is missing" if value.strip.empty?
93
+
94
+ value
95
+ end
96
+
97
+ def header(name)
98
+ @headers.each do |key, value|
99
+ return value if header_name(key) == name
100
+ end
101
+ nil
102
+ end
103
+
104
+ def header_name(key)
105
+ key.to_s.downcase.tr("_", "-").delete_prefix("http-")
106
+ end
107
+
108
+ def build_event(version)
109
+ payload = JSON.parse(@raw_body, decimal_class: BigDecimal)
110
+ raise SignatureVerificationError, "webhook body is not JSON" unless payload.is_a?(Hash)
111
+
112
+ Event.new(type: payload["type"], time: payload["event_time"], data: payload["data"], version: version)
113
+ rescue JSON::ParserError
114
+ raise SignatureVerificationError, "webhook body is not JSON"
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cashfree_pg/version"
4
+ require_relative "cashfree_pg/errors"
5
+ require_relative "cashfree_pg/money"
6
+ require_relative "cashfree_pg/response"
7
+ require_relative "cashfree_pg/client"
8
+ require_relative "cashfree_pg/resource"
9
+ require_relative "cashfree_pg/orders"
10
+ require_relative "cashfree_pg/payments"
11
+ require_relative "cashfree_pg/refunds"
12
+ require_relative "cashfree_pg/payment_links"
13
+ require_relative "cashfree_pg/webhook"
14
+
15
+ # Ruby client for the Cashfree Payments API.
16
+ module CashfreePg
17
+ end
@@ -0,0 +1,125 @@
1
+ module CashfreePg
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ attr_reader status: Integer?
6
+ attr_reader code: String?
7
+ attr_reader type: String?
8
+ attr_reader request_id: String?
9
+ attr_reader raw_body: String?
10
+
11
+ def initialize: (?String? message, ?status: Integer?, ?code: String?, ?type: String?, ?request_id: String?, ?raw_body: String?) -> void
12
+ def inspect: () -> String
13
+ end
14
+
15
+ class InvalidRequestError < Error
16
+ end
17
+
18
+ class AuthenticationError < Error
19
+ end
20
+
21
+ class PermissionError < Error
22
+ end
23
+
24
+ class NotFoundError < Error
25
+ end
26
+
27
+ class ConflictError < Error
28
+ end
29
+
30
+ class ValidationError < Error
31
+ end
32
+
33
+ class RateLimitError < Error
34
+ end
35
+
36
+ class APIError < Error
37
+ end
38
+
39
+ class ConnectionError < Error
40
+ end
41
+
42
+ class TimeoutError < Error
43
+ end
44
+
45
+ class SignatureVerificationError < Error
46
+ end
47
+
48
+ class Response
49
+ attr_reader status: Integer
50
+ attr_reader request_id: String
51
+
52
+ def initialize: (Hash[String, untyped] | Array[untyped] data, status: Integer, request_id: String) -> void
53
+ def []: (String | Symbol key) -> untyped
54
+ def dig: (*String | Symbol keys) -> untyped
55
+ def to_h: () -> (Hash[String, untyped] | Array[untyped])
56
+ end
57
+
58
+ class Client
59
+ SANDBOX_URL: String
60
+ PRODUCTION_URL: String
61
+ DEFAULT_API_VERSION: String
62
+
63
+ attr_reader client_id: String
64
+ attr_reader client_secret: String
65
+ attr_reader environment: Symbol
66
+ attr_reader api_version: String
67
+ attr_reader base_url: String
68
+
69
+ def initialize: (client_id: String, client_secret: String, ?environment: Symbol, ?api_version: String, ?open_timeout: Numeric, ?read_timeout: Numeric, ?base_url: String?, ?allow_custom_base_url: bool) -> void
70
+ def get: (String path, ?skip_auth: bool) -> Response
71
+ def post: (String path, ?Hash[untyped, untyped]? body, ?skip_auth: bool, ?idempotency_key: String?) -> Response
72
+ def patch: (String path, Hash[untyped, untyped] body) -> Response
73
+ def orders: () -> Orders
74
+ def payments: () -> Payments
75
+ def refunds: () -> Refunds
76
+ def payment_links: () -> PaymentLinks
77
+ def inspect: () -> String
78
+ def to_s: () -> String
79
+ end
80
+
81
+ class Resource
82
+ def initialize: (Client client) -> void
83
+ end
84
+
85
+ class Orders < Resource
86
+ def create: (?idempotency_key: String?, **untyped) -> Response
87
+ def fetch: (String order_id) -> Response
88
+ def terminate: (String order_id) -> Response
89
+ end
90
+
91
+ class Refunds < Resource
92
+ def create: (String order_id, ?idempotency_key: String?, **untyped) -> Response
93
+ def fetch: (String order_id, String refund_id) -> Response
94
+ def list: (String order_id) -> Response
95
+ end
96
+
97
+ class PaymentLinks < Resource
98
+ def create: (?idempotency_key: String?, **untyped) -> Response
99
+ def fetch: (String link_id) -> Response
100
+ def cancel: (String link_id) -> Response
101
+ def orders: (String link_id, ?status: String?) -> Array[Response]
102
+ end
103
+
104
+ class Payments < Resource
105
+ CHANNELS: Hash[Symbol, String]
106
+
107
+ def list: (String order_id) -> Array[Response]
108
+ def fetch: (String order_id, String cf_payment_id) -> Response
109
+ def upi: (untyped order, channel: Symbol | String, ?upi_id: String?, **untyped) -> Response
110
+ def authorize: (String order_id, action: Symbol | String, ?amount: untyped?, ?idempotency_key: String?) -> Response
111
+ end
112
+
113
+ class Webhook
114
+ class Event < ::Data
115
+ attr_reader type: String?
116
+ attr_reader time: String?
117
+ attr_reader data: untyped
118
+ attr_reader version: String?
119
+
120
+ def self.new: (type: String?, time: String?, data: untyped, version: String?) -> instance
121
+ end
122
+
123
+ def self.construct_event: (raw_body: String, headers: untyped, secret: String, ?tolerance: Numeric?) -> Event
124
+ end
125
+ end
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cashfree_pg
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Muballigh Faraz
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '2.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3'
33
+ description: 'A small, well-tested Ruby client for Cashfree Payment Gateway APIs:
34
+ orders, payments, UPI, refunds, payment links and webhook verification. Instance-based,
35
+ thread-safe, with API version pinning, exact money handling and secrets redacted
36
+ by default. Not affiliated with or endorsed by Cashfree Payments.'
37
+ email:
38
+ - support@shadbox.com
39
+ executables: []
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - CHANGELOG.md
44
+ - LICENSE.txt
45
+ - README.md
46
+ - lib/cashfree_pg.rb
47
+ - lib/cashfree_pg/client.rb
48
+ - lib/cashfree_pg/errors.rb
49
+ - lib/cashfree_pg/money.rb
50
+ - lib/cashfree_pg/orders.rb
51
+ - lib/cashfree_pg/payment_links.rb
52
+ - lib/cashfree_pg/payments.rb
53
+ - lib/cashfree_pg/refunds.rb
54
+ - lib/cashfree_pg/request.rb
55
+ - lib/cashfree_pg/resource.rb
56
+ - lib/cashfree_pg/response.rb
57
+ - lib/cashfree_pg/version.rb
58
+ - lib/cashfree_pg/webhook.rb
59
+ - sig/cashfree_pg.rbs
60
+ homepage: https://github.com/Shadbox-Infosystem/cashfree_pg
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ allowed_push_host: https://rubygems.org
65
+ homepage_uri: https://github.com/Shadbox-Infosystem/cashfree_pg
66
+ source_code_uri: https://github.com/Shadbox-Infosystem/cashfree_pg
67
+ changelog_uri: https://github.com/Shadbox-Infosystem/cashfree_pg/blob/main/CHANGELOG.md
68
+ bug_tracker_uri: https://github.com/Shadbox-Infosystem/cashfree_pg/issues
69
+ rubygems_mfa_required: 'true'
70
+ post_install_message:
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 3.3.0
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.5.22
86
+ signing_key:
87
+ specification_version: 4
88
+ summary: Unofficial Ruby client for the Cashfree Payments API.
89
+ test_files: []