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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +342 -0
- data/lib/cashfree_pg/client.rb +160 -0
- data/lib/cashfree_pg/errors.rb +75 -0
- data/lib/cashfree_pg/money.rb +84 -0
- data/lib/cashfree_pg/orders.rb +42 -0
- data/lib/cashfree_pg/payment_links.rb +55 -0
- data/lib/cashfree_pg/payments.rb +110 -0
- data/lib/cashfree_pg/refunds.rb +38 -0
- data/lib/cashfree_pg/request.rb +137 -0
- data/lib/cashfree_pg/resource.rb +39 -0
- data/lib/cashfree_pg/response.rb +65 -0
- data/lib/cashfree_pg/version.rb +6 -0
- data/lib/cashfree_pg/webhook.rb +117 -0
- data/lib/cashfree_pg.rb +17 -0
- data/sig/cashfree_pg.rbs +125 -0
- metadata +89 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module CashfreePg
|
|
7
|
+
# Converts amounts to a two-decimal JSON number.
|
|
8
|
+
#
|
|
9
|
+
# Ruby's JSON library turns a raw BigDecimal into a quoted exponent string
|
|
10
|
+
# such as "0.101e2". {Number} emits `10.10` instead.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module Money
|
|
14
|
+
AMOUNT_KEY = /(?:\A|_)amount\z/
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Walks a payload and wraps amount fields for JSON.
|
|
19
|
+
#
|
|
20
|
+
# @param value [Object]
|
|
21
|
+
# @return [Object]
|
|
22
|
+
def normalize(value)
|
|
23
|
+
case value
|
|
24
|
+
when Hash
|
|
25
|
+
value.to_h { |key, item| [key, key.to_s.match?(AMOUNT_KEY) ? number_for(item) : normalize(item)] }
|
|
26
|
+
when Array
|
|
27
|
+
value.map { |item| normalize(item) }
|
|
28
|
+
else
|
|
29
|
+
value
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param value [Integer, String, BigDecimal, Float]
|
|
34
|
+
# @return [BigDecimal] rounded half-up to two places
|
|
35
|
+
def coerce(value)
|
|
36
|
+
decimal = case value
|
|
37
|
+
when Integer, BigDecimal then BigDecimal(value)
|
|
38
|
+
when String then decimal_from_string(value)
|
|
39
|
+
when Float then decimal_from_float(value)
|
|
40
|
+
else raise ArgumentError, "amount must be a String, Integer, or BigDecimal"
|
|
41
|
+
end
|
|
42
|
+
raise ArgumentError, "amount is not a number" unless decimal.finite?
|
|
43
|
+
|
|
44
|
+
decimal.round(2, BigDecimal::ROUND_HALF_UP)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param decimal [BigDecimal] already rounded to two places
|
|
48
|
+
# @return [String] unquoted JSON number with two decimal places
|
|
49
|
+
def format_number(decimal)
|
|
50
|
+
whole, fraction = decimal.to_s("F").split(".", 2)
|
|
51
|
+
"#{whole}.#{fraction.ljust(2, "0")}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def number_for(value)
|
|
55
|
+
value.nil? ? nil : Number.new(coerce(value))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def decimal_from_string(value)
|
|
59
|
+
BigDecimal(value.strip)
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
raise ArgumentError, "amount is not a number"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def decimal_from_float(value)
|
|
65
|
+
warn("CashfreePg received a Float amount; pass a String or BigDecimal", uplevel: 2)
|
|
66
|
+
BigDecimal(value.to_s)
|
|
67
|
+
end
|
|
68
|
+
private_class_method :number_for, :decimal_from_string, :decimal_from_float
|
|
69
|
+
|
|
70
|
+
# JSON number that does not use exponent notation.
|
|
71
|
+
class Number
|
|
72
|
+
# @param decimal [BigDecimal]
|
|
73
|
+
def initialize(decimal)
|
|
74
|
+
@decimal = decimal
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [String]
|
|
78
|
+
def to_json(*)
|
|
79
|
+
Money.format_number(@decimal)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
private_constant :Number
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CashfreePg
|
|
4
|
+
# Create, fetch and terminate Cashfree orders.
|
|
5
|
+
class Orders < Resource
|
|
6
|
+
# A duplicate merchant `order_id` is a {ConflictError}, so reusing your own
|
|
7
|
+
# `order_id` is the safest way to retry after a timeout.
|
|
8
|
+
#
|
|
9
|
+
# @param idempotency_key [String, nil]
|
|
10
|
+
# @param attrs [Hash] Cashfree create-order fields. `order_amount` is sent as a two-decimal number.
|
|
11
|
+
# @return [Response]
|
|
12
|
+
# @example
|
|
13
|
+
# client.orders.create(
|
|
14
|
+
# order_id: "order_123",
|
|
15
|
+
# order_amount: "10.10",
|
|
16
|
+
# order_currency: "INR",
|
|
17
|
+
# customer_details: { customer_id: "cust_1", customer_phone: "9999999999" }
|
|
18
|
+
# )
|
|
19
|
+
def create(idempotency_key: nil, **attrs)
|
|
20
|
+
@client.post("/orders", attrs, idempotency_key: idempotency_key)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param order_id [String] merchant order id
|
|
24
|
+
# @return [Response]
|
|
25
|
+
# @example
|
|
26
|
+
# client.orders.fetch("order_123")
|
|
27
|
+
def fetch(order_id)
|
|
28
|
+
@client.get(order_path(order_id))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Ask Cashfree to terminate an unpaid order. The returned `order_status` may be
|
|
32
|
+
# `TERMINATION_REQUESTED`; fetch the order to confirm it reached `TERMINATED`.
|
|
33
|
+
#
|
|
34
|
+
# @param order_id [String] merchant order id
|
|
35
|
+
# @return [Response]
|
|
36
|
+
# @example
|
|
37
|
+
# client.orders.terminate("order_123").order_status
|
|
38
|
+
def terminate(order_id)
|
|
39
|
+
@client.patch(order_path(order_id), { order_status: "TERMINATED" })
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CashfreePg
|
|
4
|
+
# Collect a payment with a Cashfree link.
|
|
5
|
+
#
|
|
6
|
+
# SMS notifications are sent only in production. Sandbox sends email when
|
|
7
|
+
# `link_notify.send_email` is true, and does not send SMS.
|
|
8
|
+
class PaymentLinks < Resource
|
|
9
|
+
# @param idempotency_key [String, nil]
|
|
10
|
+
# @param attrs [Hash] keyword fields; requires `link_amount`, `link_currency`, `link_purpose` and `customer_details`
|
|
11
|
+
# @return [Response]
|
|
12
|
+
# @example
|
|
13
|
+
# client.payment_links.create(
|
|
14
|
+
# link_id: "link_123",
|
|
15
|
+
# link_amount: "10.10",
|
|
16
|
+
# link_currency: "INR",
|
|
17
|
+
# link_purpose: "Test payment",
|
|
18
|
+
# customer_details: { customer_phone: "9999999999" }
|
|
19
|
+
# )
|
|
20
|
+
def create(idempotency_key: nil, **attrs)
|
|
21
|
+
@client.post("/links", attrs, idempotency_key: idempotency_key)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param link_id [String] merchant link id
|
|
25
|
+
# @return [Response]
|
|
26
|
+
# @example
|
|
27
|
+
# client.payment_links.fetch("link_123")
|
|
28
|
+
def fetch(link_id)
|
|
29
|
+
@client.get("/links/#{segment(link_id, "link_id")}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Only an ACTIVE link can be cancelled.
|
|
33
|
+
#
|
|
34
|
+
# @param link_id [String]
|
|
35
|
+
# @return [Response]
|
|
36
|
+
# @example
|
|
37
|
+
# client.payment_links.cancel("link_123")
|
|
38
|
+
def cancel(link_id)
|
|
39
|
+
@client.post("/links/#{segment(link_id, "link_id")}/cancel")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Orders Cashfree created for a link. Use an order's `order_id` to refund a link payment.
|
|
43
|
+
#
|
|
44
|
+
# @param link_id [String]
|
|
45
|
+
# @param status [String, nil] `"PAID"` or `"ALL"`; Cashfree returns paid orders when nil
|
|
46
|
+
# @return [Array<Response>]
|
|
47
|
+
# @example
|
|
48
|
+
# order_id = client.payment_links.orders("link_123").first.order_id
|
|
49
|
+
def orders(link_id, status: nil)
|
|
50
|
+
path = "/links/#{segment(link_id, "link_id")}/orders"
|
|
51
|
+
path += "?#{URI.encode_www_form(status: status)}" if status
|
|
52
|
+
list_of(@client.get(path), "link orders")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CashfreePg
|
|
4
|
+
# Payments for an order, including UPI via Cashfree order sessions.
|
|
5
|
+
#
|
|
6
|
+
# The gem talks only to Cashfree. It never connects to NPCI or a bank.
|
|
7
|
+
# Calling Order Pay from a server needs Cashfree's S2S feature enabled.
|
|
8
|
+
# A 403 is a {PermissionError} that says so.
|
|
9
|
+
class Payments < Resource
|
|
10
|
+
# Cashfree channel names. `:intent` is UPI intent; Cashfree calls that channel `link`.
|
|
11
|
+
CHANNELS = {
|
|
12
|
+
intent: "link",
|
|
13
|
+
link: "link",
|
|
14
|
+
qr: "qrcode",
|
|
15
|
+
qrcode: "qrcode",
|
|
16
|
+
pod_qr: "podQrCode",
|
|
17
|
+
collect: "collect"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# @param order_id [String]
|
|
21
|
+
# @return [Array<Response>]
|
|
22
|
+
# @example
|
|
23
|
+
# client.payments.list("order_123")
|
|
24
|
+
def list(order_id)
|
|
25
|
+
list_of(@client.get("#{order_path(order_id)}/payments"), "payments")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param order_id [String]
|
|
29
|
+
# @param cf_payment_id [String]
|
|
30
|
+
# @return [Response]
|
|
31
|
+
# @example
|
|
32
|
+
# client.payments.fetch("order_123", "12376123")
|
|
33
|
+
def fetch(order_id, cf_payment_id)
|
|
34
|
+
@client.get("#{order_path(order_id)}/payments/#{segment(cf_payment_id, "cf_payment_id")}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Start a UPI payment on an order session. No client secret is sent.
|
|
38
|
+
#
|
|
39
|
+
# Channels: `:intent` (opens the UPI app), `:qr` (QR image), `:collect` (legacy; needs `upi_id`).
|
|
40
|
+
# Sandbox collect VPAs include `testsuccess@gocash`.
|
|
41
|
+
#
|
|
42
|
+
# @param order [Response, Hash, String] order response, or the payment_session_id
|
|
43
|
+
# @param channel [Symbol, String]
|
|
44
|
+
# @param upi_id [String, nil] required for `:collect`
|
|
45
|
+
# @param options [Hash] other Cashfree UPI fields, such as `upi_expiry_minutes`
|
|
46
|
+
# @return [Response]
|
|
47
|
+
# @example
|
|
48
|
+
# client.payments.upi(order, channel: :intent)
|
|
49
|
+
# client.payments.upi(order, channel: :collect, upi_id: "testsuccess@gocash")
|
|
50
|
+
def upi(order, channel:, upi_id: nil, **options)
|
|
51
|
+
cashfree_channel = CHANNELS[channel.to_sym]
|
|
52
|
+
raise ArgumentError, "channel must be one of #{CHANNELS.keys.join(", ")}" unless cashfree_channel
|
|
53
|
+
raise ArgumentError, "collect requires upi_id" if cashfree_channel == "collect" && upi_id.nil?
|
|
54
|
+
|
|
55
|
+
check_vpa!(upi_id) if upi_id
|
|
56
|
+
upi = { channel: cashfree_channel, upi_id: upi_id }.compact.merge(options)
|
|
57
|
+
pay(session_id(order), upi)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Capture or void a pre-authorised payment. `CAPTURE` requires an amount.
|
|
61
|
+
#
|
|
62
|
+
# @param order_id [String]
|
|
63
|
+
# @param action [Symbol, String] `:capture` or `:void`
|
|
64
|
+
# @param amount [String, Integer, BigDecimal, nil]
|
|
65
|
+
# @param idempotency_key [String, nil]
|
|
66
|
+
# @return [Response]
|
|
67
|
+
# @example
|
|
68
|
+
# client.payments.authorize("order_123", action: :capture, amount: "10.10", idempotency_key: "cap_1")
|
|
69
|
+
def authorize(order_id, action:, amount: nil, idempotency_key: nil)
|
|
70
|
+
name = action.to_s.upcase
|
|
71
|
+
raise ArgumentError, "action must be CAPTURE or VOID" unless %w[CAPTURE VOID].include?(name)
|
|
72
|
+
raise ArgumentError, "CAPTURE requires an amount" if name == "CAPTURE" && amount.nil?
|
|
73
|
+
raise ArgumentError, "VOID does not take an amount" if name == "VOID" && !amount.nil?
|
|
74
|
+
|
|
75
|
+
body = { action: name, amount: amount }.compact
|
|
76
|
+
@client.post("#{order_path(order_id)}/authorization", body, idempotency_key: idempotency_key)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def pay(payment_session_id, upi)
|
|
82
|
+
@client.post(
|
|
83
|
+
"/orders/sessions",
|
|
84
|
+
{ payment_session_id: payment_session_id, payment_method: { upi: upi } },
|
|
85
|
+
skip_auth: true
|
|
86
|
+
)
|
|
87
|
+
rescue PermissionError => e
|
|
88
|
+
raise PermissionError.new(
|
|
89
|
+
"#{e.message}. Order Pay from a server needs Cashfree S2S enabled on the account",
|
|
90
|
+
status: e.status, code: e.code, type: e.type,
|
|
91
|
+
request_id: e.request_id, raw_body: e.raw_body
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def session_id(order)
|
|
96
|
+
return order if order.is_a?(String) && !order.empty?
|
|
97
|
+
|
|
98
|
+
id = order["payment_session_id"] || order[:payment_session_id] if order.respond_to?(:[])
|
|
99
|
+
raise ArgumentError, "payment_session_id is required" if id.nil? || id.to_s.empty?
|
|
100
|
+
|
|
101
|
+
id
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def check_vpa!(upi_id)
|
|
105
|
+
return if upi_id.to_s.match?(/\A[^@\s]+@[^@\s]+\z/)
|
|
106
|
+
|
|
107
|
+
raise ArgumentError, "upi_id must look like name@bank"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CashfreePg
|
|
4
|
+
# Refunds for a paid order.
|
|
5
|
+
#
|
|
6
|
+
# Pass your own `refund_id`. If a create times out, fetch that id before sending
|
|
7
|
+
# another refund. Cashfree rejects a duplicate `refund_id` with 409, which is a {ConflictError}.
|
|
8
|
+
class Refunds < Resource
|
|
9
|
+
# @param order_id [String]
|
|
10
|
+
# @param idempotency_key [String, nil]
|
|
11
|
+
# @param attrs [Hash] keyword fields; requires `refund_amount` and `refund_id`
|
|
12
|
+
# @return [Response]
|
|
13
|
+
# @example
|
|
14
|
+
# client.refunds.create("order_123", refund_amount: "10.10", refund_id: "refund_00912")
|
|
15
|
+
def create(order_id, idempotency_key: nil, **attrs)
|
|
16
|
+
@client.post("#{order_path(order_id)}/refunds", attrs, idempotency_key: idempotency_key)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param order_id [String]
|
|
20
|
+
# @param refund_id [String] the merchant refund id
|
|
21
|
+
# @return [Response]
|
|
22
|
+
# @example
|
|
23
|
+
# client.refunds.fetch("order_123", "refund_00912")
|
|
24
|
+
def fetch(order_id, refund_id)
|
|
25
|
+
@client.get("#{order_path(order_id)}/refunds/#{segment(refund_id, "refund_id")}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Cashfree returns a JSON array. It is not wrapped item by item.
|
|
29
|
+
#
|
|
30
|
+
# @param order_id [String]
|
|
31
|
+
# @return [Response]
|
|
32
|
+
# @example
|
|
33
|
+
# client.refunds.list("order_123").to_h
|
|
34
|
+
def list(order_id)
|
|
35
|
+
@client.get("#{order_path(order_id)}/refunds")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module CashfreePg
|
|
7
|
+
# One HTTPS call. No retries and no logging.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class Request
|
|
11
|
+
STATUS_ERRORS = {
|
|
12
|
+
400 => InvalidRequestError,
|
|
13
|
+
401 => AuthenticationError,
|
|
14
|
+
403 => PermissionError,
|
|
15
|
+
404 => NotFoundError,
|
|
16
|
+
409 => ConflictError,
|
|
17
|
+
422 => ValidationError,
|
|
18
|
+
429 => RateLimitError
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# @param client [Client]
|
|
22
|
+
# @param connection [Faraday::Connection]
|
|
23
|
+
def initialize(client, connection)
|
|
24
|
+
@client = client
|
|
25
|
+
@connection = connection
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param method [Symbol] `:get`, `:post` or `:patch`
|
|
29
|
+
# @param path [String]
|
|
30
|
+
# @param body [Hash, nil]
|
|
31
|
+
# @param skip_auth [Boolean]
|
|
32
|
+
# @param idempotency_key [String, nil]
|
|
33
|
+
# @return [Response]
|
|
34
|
+
def perform(method, path, body: nil, skip_auth: false, idempotency_key: nil)
|
|
35
|
+
validate_path!(path)
|
|
36
|
+
@method = method
|
|
37
|
+
@request_id = SecureRandom.uuid
|
|
38
|
+
handle(dispatch(request_path(path), body, request_headers(skip_auth, idempotency_key)))
|
|
39
|
+
rescue Faraday::TimeoutError, Timeout::Error
|
|
40
|
+
raise timeout_error
|
|
41
|
+
rescue Faraday::ConnectionFailed, Faraday::SSLError, Errno::ECONNREFUSED, SocketError
|
|
42
|
+
raise ConnectionError.new("connection failed request_id=#{@request_id}", request_id: @request_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def dispatch(path, body, headers)
|
|
48
|
+
@connection.public_send(@method, path) do |req|
|
|
49
|
+
req.headers.update(headers)
|
|
50
|
+
req.body = JSON.generate(Money.normalize(body)) if body
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def request_headers(skip_auth, idempotency_key)
|
|
55
|
+
headers = {
|
|
56
|
+
"User-Agent" => "cashfree_pg/#{VERSION} ruby/#{RUBY_VERSION}",
|
|
57
|
+
"Accept" => "application/json",
|
|
58
|
+
"Content-Type" => "application/json",
|
|
59
|
+
"x-api-version" => @client.api_version,
|
|
60
|
+
"x-request-id" => @request_id
|
|
61
|
+
}
|
|
62
|
+
headers["x-idempotency-key"] = idempotency_key if idempotency_key
|
|
63
|
+
return headers if skip_auth
|
|
64
|
+
|
|
65
|
+
headers.merge("x-client-id" => @client.client_id, "x-client-secret" => @client.client_secret)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def handle(response)
|
|
69
|
+
id = response.headers["x-request-id"]
|
|
70
|
+
id = @request_id if id.nil? || id.empty?
|
|
71
|
+
return success(response, id) if response.success?
|
|
72
|
+
|
|
73
|
+
raise_api_error(response, id)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def success(response, request_id)
|
|
77
|
+
data = parse_json(response.body)
|
|
78
|
+
if data.nil?
|
|
79
|
+
raise APIError.new("invalid JSON response status=#{response.status} request_id=#{request_id}",
|
|
80
|
+
status: response.status, request_id: request_id, raw_body: response.body.to_s)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Response.new(data, status: response.status, request_id: request_id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def raise_api_error(response, request_id)
|
|
87
|
+
payload = parse_json(response.body)
|
|
88
|
+
payload = {} unless payload.is_a?(Hash)
|
|
89
|
+
klass = STATUS_ERRORS.fetch(response.status, APIError)
|
|
90
|
+
raise klass.new(
|
|
91
|
+
error_message(response.status, payload, request_id),
|
|
92
|
+
status: response.status,
|
|
93
|
+
code: payload["code"]&.to_s,
|
|
94
|
+
type: payload["type"]&.to_s,
|
|
95
|
+
request_id: request_id,
|
|
96
|
+
raw_body: response.body.to_s
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def error_message(status, payload, request_id)
|
|
101
|
+
parts = ["status=#{status}"]
|
|
102
|
+
parts << "code=#{payload["code"]}" if payload["code"]
|
|
103
|
+
parts << "type=#{payload["type"]}" if payload["type"]
|
|
104
|
+
detail = payload["message"].to_s.gsub(@client.client_secret, "[FILTERED]")
|
|
105
|
+
parts << detail unless detail.empty?
|
|
106
|
+
parts << "request_id=#{request_id}"
|
|
107
|
+
parts.join(" ")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# An empty body is `{}`. Invalid JSON is nil.
|
|
111
|
+
def parse_json(raw)
|
|
112
|
+
text = raw.to_s
|
|
113
|
+
return {} if text.strip.empty?
|
|
114
|
+
|
|
115
|
+
JSON.parse(text, decimal_class: BigDecimal)
|
|
116
|
+
rescue JSON::ParserError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def timeout_error
|
|
121
|
+
message = "request timed out request_id=#{@request_id}"
|
|
122
|
+
message = "#{message}; outcome is unknown; fetch before retrying" unless @method == :get
|
|
123
|
+
TimeoutError.new(message, request_id: @request_id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A leading slash would make Faraday drop the /pg prefix on the base URL.
|
|
127
|
+
def request_path(path)
|
|
128
|
+
path.delete_prefix("/")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_path!(path)
|
|
132
|
+
return if path.is_a?(String) && path.start_with?("/") && !path.start_with?("//")
|
|
133
|
+
|
|
134
|
+
raise ArgumentError, "path must be a relative path starting with /"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module CashfreePg
|
|
6
|
+
# Base for the API resources on a {Client}.
|
|
7
|
+
class Resource
|
|
8
|
+
# @param client [Client]
|
|
9
|
+
def initialize(client)
|
|
10
|
+
@client = client
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# Wraps each item of a JSON array response so callers get field readers.
|
|
16
|
+
def list_of(response, name)
|
|
17
|
+
items = response.to_h
|
|
18
|
+
unless items.is_a?(Array)
|
|
19
|
+
raise APIError.new("#{name} list was not an array", status: response.status, request_id: response.request_id)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
items.map { |item| Response.new(item, status: response.status, request_id: response.request_id) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def order_path(order_id)
|
|
26
|
+
"/orders/#{segment(order_id, "order_id")}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Encodes one path segment. Blank ids and ids containing `/` are rejected
|
|
30
|
+
# so a caller's value cannot change which endpoint is called.
|
|
31
|
+
def segment(id, name)
|
|
32
|
+
text = id.to_s.strip
|
|
33
|
+
raise ArgumentError, "#{name} is required" if text.empty?
|
|
34
|
+
raise ArgumentError, "#{name} must not contain a slash" if text.include?("/")
|
|
35
|
+
|
|
36
|
+
URI.encode_uri_component(text)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CashfreePg
|
|
4
|
+
# Hash-like Cashfree response. Field names stay as Cashfree sends them.
|
|
5
|
+
#
|
|
6
|
+
# Use {#[]} or {#dig} for keys that collide with Ruby methods (`hash`, `class`, `method`).
|
|
7
|
+
class Response
|
|
8
|
+
# @return [Integer]
|
|
9
|
+
attr_reader :status
|
|
10
|
+
|
|
11
|
+
# @return [String]
|
|
12
|
+
attr_reader :request_id
|
|
13
|
+
|
|
14
|
+
# @param data [Hash, Array]
|
|
15
|
+
# @param status [Integer]
|
|
16
|
+
# @param request_id [String]
|
|
17
|
+
def initialize(data, status:, request_id:)
|
|
18
|
+
@data = data
|
|
19
|
+
@status = status
|
|
20
|
+
@request_id = request_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param key [String, Symbol]
|
|
24
|
+
# @return [Object, nil]
|
|
25
|
+
def [](key)
|
|
26
|
+
body[key.to_s]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param keys [Array<String, Symbol>]
|
|
30
|
+
# @return [Object, nil]
|
|
31
|
+
def dig(*keys)
|
|
32
|
+
body.dig(*keys.map(&:to_s))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Hash, Array]
|
|
36
|
+
def to_h
|
|
37
|
+
@data.is_a?(Hash) ? @data.dup : @data
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param name [Symbol]
|
|
41
|
+
# @return [Object]
|
|
42
|
+
def method_missing(name, *args)
|
|
43
|
+
key = name.to_s
|
|
44
|
+
if args.empty? && !name.end_with?("=") && body.key?(key) && !self.class.method_defined?(name)
|
|
45
|
+
body[key]
|
|
46
|
+
else
|
|
47
|
+
super
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param name [Symbol]
|
|
52
|
+
# @param include_private [Boolean]
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def respond_to_missing?(name, include_private = false)
|
|
55
|
+
(body.key?(name.to_s) && !self.class.method_defined?(name)) || super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# @return [Hash]
|
|
61
|
+
def body
|
|
62
|
+
@data.is_a?(Hash) ? @data : {}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|