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,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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CashfreePg
4
+ # Gem version, following Semantic Versioning.
5
+ VERSION = "0.1.0"
6
+ end