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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 601ea757a5ae5b368fb3c3937d12f3281ac35f2f8ab4128d288919cf81208f03
|
|
4
|
+
data.tar.gz: 87e14bc708bc3d5def1eba63ecd2291d46a8c76d5f9c5dc8212bdb09c5a0de3e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c64873b92386ffcf036a7c63e599aa3c05885fa8d53ee18ecd8ae3b679bc26cd84bd3129bae8651bd3a762eb9a32e90ae4c7e3db42e51433c29191c0a708eda1
|
|
7
|
+
data.tar.gz: 595b54f8b913642cc8d2a4c91a790ad781607060c49d12e9aa5caa0f254244a98860d8df8587ddaa364aa17c55bb53f4bb2c5443b472f85ec226c540b91d2c91
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-10-26
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `CashfreePg::Client`: one instance per merchant, sandbox or production, pinned API version `2025-01-01`, client secret hidden in `inspect`, `to_s`, `pp` and error messages.
|
|
12
|
+
- HTTPS only, TLS verified, no redirects, no request logging and no automatic retries. A timed-out POST raises `TimeoutError` telling you to fetch before retrying.
|
|
13
|
+
- Typed errors for HTTP 400, 401, 403, 404, 409, 422, 429 and 5xx, plus `ConnectionError` and `TimeoutError`, each with the Cashfree request id.
|
|
14
|
+
- Exact amounts: String, Integer or BigDecimal in, two-decimal JSON numbers out, BigDecimal back. A Float amount warns.
|
|
15
|
+
- Orders: create (with an optional idempotency key), fetch and terminate.
|
|
16
|
+
- Payments: list, fetch, and capture or void a pre-authorised payment.
|
|
17
|
+
- UPI on the order session: intent, QR and collect, without sending the client secret.
|
|
18
|
+
- Refunds: create with your own `refund_id`, fetch and list.
|
|
19
|
+
- Payment links: create, fetch, cancel, and list the orders a link created (needed to refund a link payment).
|
|
20
|
+
- `docs/api.md`: every method with its Cashfree endpoint and reference link.
|
|
21
|
+
- No telemetry: the gem sends nothing except the Cashfree calls you make.
|
|
22
|
+
- Webhook verification with `CashfreePg::Webhook.construct_event`: raw body, constant-time signature check, optional timestamp window, and `SignatureVerificationError` for anything that cannot be trusted, including a missing secret.
|
|
23
|
+
- Ruby 3.3 or newer. Faraday 2 is the only runtime dependency.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muballigh Faraz
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="docs/assets/logo.png" alt="cashfree_pg: Unofficial Ruby Client for Cashfree Payments" width="360">
|
|
4
|
+
|
|
5
|
+
**Orders, payments, UPI, refunds, payment links and webhook verification<br>for the Cashfree Payment Gateway, from plain Ruby or Rails.**
|
|
6
|
+
|
|
7
|
+
[](https://github.com/Shadbox-Infosystem/cashfree_pg/actions/workflows/ci.yml)
|
|
8
|
+
[](cashfree_pg.gemspec)
|
|
9
|
+
[](docs/api.md)
|
|
10
|
+
[](LICENSE.txt)
|
|
11
|
+
|
|
12
|
+
[Quick start](#-quick-start) · [Payment flow](#-payment-flow) · [Rails](#-rails-integration) · [API reference](docs/api.md) · [Webhooks](docs/webhooks.md) · [Sandbox testing](#-sandbox-testing)
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
> [!IMPORTANT]
|
|
17
|
+
> **Community project, not an official SDK.** `cashfree_pg` is built and maintained independently. It is not affiliated with, endorsed by, or supported by Cashfree Payments.
|
|
18
|
+
|
|
19
|
+
## ⚡ Overview
|
|
20
|
+
|
|
21
|
+
`cashfree_pg` wraps the Cashfree Payment Gateway (PG) REST API in a small, instance-based Ruby interface. It keeps Cashfree's own field names, so Cashfree's docs still apply, and it handles the parts that are easy to get wrong in a payment integration.
|
|
22
|
+
|
|
23
|
+
> **You write:** `client.orders.create(order_id: "order_42", order_amount: "499.00", ...)`<br>
|
|
24
|
+
> **You get:** a `Response` with Cashfree's fields, amounts as `BigDecimal`, the `request_id` for support, and a typed exception for every failure. No raw HTTP, no `Float` money, no secrets in your logs.
|
|
25
|
+
|
|
26
|
+
The gem talks only to Cashfree over HTTPS. It never handles card data, and it has no telemetry, request logging or automatic retries. Faraday is its only runtime dependency.
|
|
27
|
+
|
|
28
|
+
<table>
|
|
29
|
+
<tr>
|
|
30
|
+
<td width="33%" valign="top"><h4>🧾 Orders</h4>Create, fetch and terminate, with an optional idempotency key.</td>
|
|
31
|
+
<td width="33%" valign="top"><h4>💳 Payments</h4>List and fetch, plus capture or void pre-authorised payments.</td>
|
|
32
|
+
<td width="33%" valign="top"><h4>📱 UPI</h4>Intent, QR and collect on the order session, without sending your secret.</td>
|
|
33
|
+
</tr>
|
|
34
|
+
<tr>
|
|
35
|
+
<td valign="top"><h4>↩️ Refunds</h4>Create with your own <code>refund_id</code>, then fetch or list.</td>
|
|
36
|
+
<td valign="top"><h4>🔗 Payment Links</h4>Create, fetch and cancel, and find the orders a paid link created.</td>
|
|
37
|
+
<td valign="top"><h4>🔐 Webhooks</h4>Constant-time HMAC-SHA256 check, optional timestamp window.</td>
|
|
38
|
+
</tr>
|
|
39
|
+
<tr>
|
|
40
|
+
<td valign="top"><h4>🧯 Typed Errors</h4>One exception per HTTP status, with Cashfree's <code>code</code> and <code>request_id</code>.</td>
|
|
41
|
+
<td valign="top"><h4>💰 Exact Money</h4>Two-decimal JSON amounts out, <code>BigDecimal</code> back in.</td>
|
|
42
|
+
<td valign="top"><h4>🛡️ Safe Defaults</h4>HTTPS only, TLS verified, secret redacted from <code>inspect</code> and errors.</td>
|
|
43
|
+
</tr>
|
|
44
|
+
</table>
|
|
45
|
+
|
|
46
|
+
## 🔄 Payment flow
|
|
47
|
+
|
|
48
|
+
How one payment moves through your app, `cashfree_pg` and Cashfree:
|
|
49
|
+
|
|
50
|
+
<p align="center">
|
|
51
|
+
<picture>
|
|
52
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/payment-flow-dark.svg">
|
|
53
|
+
<img src="docs/assets/payment-flow-light.svg" width="100%" alt="Payment flow: 1. create the order with orders.create on your server, 2. the customer pays on Cashfree checkout or UPI, 3. verify the webhook with Webhook.construct_event, 4. confirm with orders.fetch that order_status is PAID, 5. optionally refund with refunds.create">
|
|
54
|
+
</picture>
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
- 🔁 **Retries are yours.** The gem never retries. Reuse your own `order_id` or `refund_id` and the call is safe to repeat.
|
|
58
|
+
- 🔔 **Verify, then confirm.** Check the webhook signature, deduplicate repeats, and fetch the order before you fulfil it.
|
|
59
|
+
- 🧾 **Log the `request_id`.** Every response and error carries one, and it is what Cashfree support asks for.
|
|
60
|
+
|
|
61
|
+
## 🚀 Quick start
|
|
62
|
+
|
|
63
|
+
| Requirement | Version |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| Ruby | 3.3 or newer |
|
|
66
|
+
| Cashfree account | PG API credentials, sandbox or production |
|
|
67
|
+
|
|
68
|
+
**1. Install.** Add the gem to your `Gemfile` and run `bundle install`:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
gem "cashfree_pg", "~> 0.1.0"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**2. Create a client.** There is no global configuration: credentials live on the instance, one client per merchant account. The gem reads nothing from `ENV` itself.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
require "cashfree_pg"
|
|
78
|
+
|
|
79
|
+
client = CashfreePg::Client.new(
|
|
80
|
+
client_id: ENV.fetch("CASHFREE_CLIENT_ID"),
|
|
81
|
+
client_secret: ENV.fetch("CASHFREE_CLIENT_SECRET"),
|
|
82
|
+
environment: :sandbox # or :production, with production credentials
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**3. Take a payment.**
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
order = client.orders.create(
|
|
90
|
+
order_id: "order_#{SecureRandom.hex(6)}",
|
|
91
|
+
order_amount: "499.00", # String, Integer or BigDecimal; never Float
|
|
92
|
+
order_currency: "INR",
|
|
93
|
+
customer_details: { customer_id: "cust_42", customer_phone: "9999999999" }
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
order.payment_session_id # hand this to Cashfree checkout on the frontend
|
|
97
|
+
client.orders.fetch(order.order_id).order_status # => "ACTIVE", "PAID", ...
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
<details>
|
|
101
|
+
<summary><b>All client options</b></summary>
|
|
102
|
+
|
|
103
|
+
<br>
|
|
104
|
+
|
|
105
|
+
| Option | Default | Notes |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| `client_id` | required | Your Cashfree PG app id. |
|
|
108
|
+
| `client_secret` | required | Never shown by `inspect`, `to_s` or `pp`. Also the webhook signing secret. |
|
|
109
|
+
| `environment` | `:sandbox` | `:sandbox` (`sandbox.cashfree.com/pg`) or `:production` (`api.cashfree.com/pg`). Must be a Symbol. |
|
|
110
|
+
| `api_version` | `"2025-01-01"` | Sent as `x-api-version` on every call. |
|
|
111
|
+
| `open_timeout` | `30` | Seconds to wait for a connection. |
|
|
112
|
+
| `read_timeout` | `60` | Seconds to wait for a response. |
|
|
113
|
+
|
|
114
|
+
[`.env.example`](.env.example) lists the variable names this repository's tooling uses. Never commit real credentials; `.env` is git-ignored.
|
|
115
|
+
|
|
116
|
+
To try unreleased changes, install from GitHub: `gem "cashfree_pg", github: "Shadbox-Infosystem/cashfree_pg", branch: "main"`.
|
|
117
|
+
|
|
118
|
+
</details>
|
|
119
|
+
|
|
120
|
+
## 📦 Usage
|
|
121
|
+
|
|
122
|
+
Single objects come back as a `CashfreePg::Response`, with Cashfree's field names exactly:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
order.order_status # field as a method
|
|
126
|
+
order["order_amount"] # => BigDecimal("499.0")
|
|
127
|
+
order.dig(:customer_details, :customer_id) # use [] or dig for keys like hash or class
|
|
128
|
+
order.request_id # quote this to Cashfree support
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`payments.list` and `payment_links.orders` return an Array of `Response`. Every method and its Cashfree endpoint is listed in [docs/api.md](docs/api.md).
|
|
132
|
+
|
|
133
|
+
<details>
|
|
134
|
+
<summary><b>UPI: intent, QR and collect</b></summary>
|
|
135
|
+
|
|
136
|
+
<br>
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
client.payments.upi(order, channel: :intent) # sent as Cashfree's "link"
|
|
140
|
+
client.payments.upi(order, channel: :qr) # sent as "qrcode"
|
|
141
|
+
client.payments.upi(order, channel: :collect, upi_id: "testsuccess@gocash")
|
|
142
|
+
client.payments.upi(order.payment_session_id, channel: :qr, upi_expiry_minutes: 10)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Calling Order Pay from a server needs Cashfree's S2S feature on your account. Without it, Cashfree returns 403 and the gem raises `CashfreePg::PermissionError` saying so.
|
|
146
|
+
|
|
147
|
+
</details>
|
|
148
|
+
|
|
149
|
+
<details>
|
|
150
|
+
<summary><b>Payments and pre-authorisation</b></summary>
|
|
151
|
+
|
|
152
|
+
<br>
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
payments = client.payments.list(order.order_id)
|
|
156
|
+
client.payments.fetch(order.order_id, payments.first.cf_payment_id)
|
|
157
|
+
|
|
158
|
+
client.payments.authorize(order.order_id, action: :capture, amount: "499.00", idempotency_key: "cap_1")
|
|
159
|
+
client.payments.authorize(order.order_id, action: :void, idempotency_key: "void_1")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
</details>
|
|
163
|
+
|
|
164
|
+
<details>
|
|
165
|
+
<summary><b>Refunds</b></summary>
|
|
166
|
+
|
|
167
|
+
<br>
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
client.refunds.create(order.order_id, refund_amount: "499.00", refund_id: "refund_00912")
|
|
171
|
+
client.refunds.fetch(order.order_id, "refund_00912")
|
|
172
|
+
client.refunds.list(order.order_id)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Always pass your own `refund_id`. If a create times out, fetch that id before trying again; a duplicate raises `CashfreePg::ConflictError`.
|
|
176
|
+
|
|
177
|
+
</details>
|
|
178
|
+
|
|
179
|
+
<details>
|
|
180
|
+
<summary><b>Payment links</b></summary>
|
|
181
|
+
|
|
182
|
+
<br>
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
link = client.payment_links.create(
|
|
186
|
+
link_id: "link_123", link_amount: "499.00", link_currency: "INR", link_purpose: "Invoice 1042",
|
|
187
|
+
customer_details: { customer_phone: "9999999999" }
|
|
188
|
+
)
|
|
189
|
+
link.link_url # send this to the customer
|
|
190
|
+
client.payment_links.cancel(link.link_id) # ACTIVE links only
|
|
191
|
+
|
|
192
|
+
paid = client.payment_links.orders(link.link_id) # Cashfree creates the order when the link is paid
|
|
193
|
+
client.refunds.create(paid.first.order_id, refund_amount: "499.00", refund_id: "refund_link_1")
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
</details>
|
|
197
|
+
|
|
198
|
+
<details>
|
|
199
|
+
<summary><b>Terminating orders and calling other endpoints</b></summary>
|
|
200
|
+
|
|
201
|
+
<br>
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
client.orders.terminate(order.order_id).order_status # => "TERMINATED" or "TERMINATION_REQUESTED"
|
|
205
|
+
client.get("/orders/order_123") # any PG endpoint, same auth headers, errors and Response
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`client.get`, `client.post` and `client.patch` take paths relative to `/pg`.
|
|
209
|
+
|
|
210
|
+
</details>
|
|
211
|
+
|
|
212
|
+
## 🧯 Error handling
|
|
213
|
+
|
|
214
|
+
Every exception inherits from `CashfreePg::Error` and exposes `status`, `code`, `type` and `request_id`. Messages never contain the client secret.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
begin
|
|
218
|
+
order = client.orders.create(order_id: "order_123", order_amount: "499.00", order_currency: "INR",
|
|
219
|
+
customer_details: { customer_id: "cust_42", customer_phone: "9999999999" })
|
|
220
|
+
rescue CashfreePg::ConflictError, CashfreePg::TimeoutError
|
|
221
|
+
order = client.orders.fetch("order_123") # already exists, or outcome unknown: check, don't recreate
|
|
222
|
+
rescue CashfreePg::Error => e
|
|
223
|
+
warn "Cashfree #{e.class}: status=#{e.status} code=#{e.code} request_id=#{e.request_id}"
|
|
224
|
+
raise
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
HTTP 400, 401, 403, 404, 409, 422 and 429 each have their own class; 5xx raises `APIError`, and a missing response raises `ConnectionError` or `TimeoutError`. The full mapping is in [docs/api.md](docs/api.md#errors). Bad local input, such as a blank id or an unknown UPI channel, raises `ArgumentError` before any request is sent.
|
|
229
|
+
|
|
230
|
+
## 🚂 Rails integration
|
|
231
|
+
|
|
232
|
+
No Railtie needed: an initializer, a service object, and a controller for webhooks.
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
# config/initializers/cashfree.rb
|
|
236
|
+
CASHFREE = CashfreePg::Client.new(
|
|
237
|
+
client_id: ENV.fetch("CASHFREE_CLIENT_ID"),
|
|
238
|
+
client_secret: ENV.fetch("CASHFREE_CLIENT_SECRET"),
|
|
239
|
+
environment: ENV.fetch("CASHFREE_ENVIRONMENT", "sandbox").to_sym
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# app/services/cashfree_checkout.rb
|
|
243
|
+
class CashfreeCheckout
|
|
244
|
+
def self.call(order_id:, amount:, customer_id:, phone:)
|
|
245
|
+
CASHFREE.orders.create(
|
|
246
|
+
order_id: order_id, order_amount: amount.to_s, order_currency: "INR",
|
|
247
|
+
customer_details: { customer_id: customer_id, customer_phone: phone }
|
|
248
|
+
).payment_session_id
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# app/controllers/cashfree_webhooks_controller.rb (route: post "/webhooks/cashfree")
|
|
253
|
+
class CashfreeWebhooksController < ActionController::API
|
|
254
|
+
def create
|
|
255
|
+
event = CashfreePg::Webhook.construct_event(
|
|
256
|
+
raw_body: request.raw_post,
|
|
257
|
+
headers: request.headers,
|
|
258
|
+
secret: ENV.fetch("CASHFREE_CLIENT_SECRET")
|
|
259
|
+
)
|
|
260
|
+
CashfreeEventJob.perform_later(event.type, event.data) # your job; deduplicate there
|
|
261
|
+
head :ok
|
|
262
|
+
rescue CashfreePg::SignatureVerificationError
|
|
263
|
+
head :bad_request
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
> [!WARNING]
|
|
269
|
+
> Pass `request.raw_post`, never parsed params. Re-encoding changes amounts like `10.10` and the signature will not match. See [docs/webhooks.md](docs/webhooks.md) for deduplication, the optional `tolerance:` window and Cashfree's IP allow-list.
|
|
270
|
+
|
|
271
|
+
## 🧪 Sandbox testing
|
|
272
|
+
|
|
273
|
+
**Unit specs run on every push.** RSpec with WebMock (real network blocked) covers every resource method, headers, money handling, each HTTP status mapping, timeouts, secret redaction and webhook signatures. SimpleCov fails the run below 90% line coverage, and [CI](.github/workflows/ci.yml) runs on Ruby 3.3, 3.4 and latest, with `bundler-audit` and a `gitleaks` secret scan.
|
|
274
|
+
|
|
275
|
+
**Integration specs hit the real sandbox.** [`spec/integration/sandbox_spec.rb`](spec/integration/sandbox_spec.rb) runs against `sandbox.cashfree.com/pg` when you provide credentials. It skips without them, refuses to run with `CASHFREE_ENVIRONMENT=production`, and does not run in CI.
|
|
276
|
+
|
|
277
|
+
| Flow | Verified against the sandbox |
|
|
278
|
+
| --- | --- |
|
|
279
|
+
| Order → UPI intent → payments | `payment_session_id` returned, intent channel is `link`, payments list is an Array |
|
|
280
|
+
| Terminate | Unpaid order becomes `TERMINATED` or `TERMINATION_REQUESTED` |
|
|
281
|
+
| Payment link lifecycle | Created, fetched, orders listed, cancelled |
|
|
282
|
+
| Refund | Created on a paid order and fetched by `refund_id` (needs `CASHFREE_PAID_ORDER_ID`) |
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
cp .env.example .env # sandbox credentials only
|
|
286
|
+
set -a; source .env; set +a
|
|
287
|
+
bundle exec rspec spec/integration
|
|
288
|
+
bin/console # IRB with the gem loaded, sandbox only
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
<details>
|
|
292
|
+
<summary><b>Sandbox test values</b></summary>
|
|
293
|
+
|
|
294
|
+
<br>
|
|
295
|
+
|
|
296
|
+
| Value | Effect in sandbox |
|
|
297
|
+
| --- | --- |
|
|
298
|
+
| `upi_id: "testsuccess@gocash"` | Successful UPI collect |
|
|
299
|
+
| `refund_note: "SUCCESS"`, `"FAILED"`, `"PENDING"`, `"ACTIVE"` | Simulates that refund status |
|
|
300
|
+
| `link_notify: { send_email: true }` | Sandbox sends email; SMS is production only |
|
|
301
|
+
|
|
302
|
+
</details>
|
|
303
|
+
|
|
304
|
+
## 🧭 Coverage and roadmap
|
|
305
|
+
|
|
306
|
+
| Status | Scope |
|
|
307
|
+
| :---: | --- |
|
|
308
|
+
| ✅ | Orders, payments, pre-authorisation, UPI (intent, QR, collect), refunds, payment links |
|
|
309
|
+
| ✅ | Webhook verification, typed errors, exact money, RBS signatures, idempotency keys |
|
|
310
|
+
| 🚧 | First RubyGems release (v0.1.0) through the trusted-publishing [release workflow](.github/workflows/release.yml) |
|
|
311
|
+
| 📋 | Stabilise the public API for v1.0 |
|
|
312
|
+
| 📋 | More PG endpoints, such as Order Pay for cards and netbanking, settlements and subscriptions |
|
|
313
|
+
|
|
314
|
+
Until v1.0, a minor release may include breaking changes, listed in the [changelog](CHANGELOG.md). Pin with `~> 0.1.0` to get only patch updates. Missing something? [Open an issue](https://github.com/Shadbox-Infosystem/cashfree_pg/issues) before starting larger work.
|
|
315
|
+
|
|
316
|
+
## 🤝 Contributing
|
|
317
|
+
|
|
318
|
+
Bug reports and pull requests are welcome. This gem sits in payment flows, so correctness and security come before new features. Read [CONTRIBUTING.md](CONTRIBUTING.md) first.
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
git checkout dev && git checkout -b fix/short-description
|
|
322
|
+
bin/setup # install dependencies
|
|
323
|
+
bundle exec rake # specs, RuboCop, RBS validation and gem contents check
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Match new endpoints to Cashfree's current API reference, cover them with RSpec and WebMock, never use `Float` for amounts, add a line under `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md), and open the pull request against `dev`. Never put real credentials or customer data in code, fixtures or screenshots. Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md). Everyone taking part follows the [code of conduct](CODE_OF_CONDUCT.md).
|
|
327
|
+
|
|
328
|
+
## 📄 License
|
|
329
|
+
|
|
330
|
+
Released under the [MIT License](LICENSE.txt). Copyright © 2026 Muballigh Faraz.
|
|
331
|
+
|
|
332
|
+
"Cashfree" and "Cashfree Payments" are names of their respective owner, used here only to describe which API this library talks to. For official SDKs and support, see [cashfree.com](https://www.cashfree.com).
|
|
333
|
+
|
|
334
|
+
<br>
|
|
335
|
+
|
|
336
|
+
<div align="center">
|
|
337
|
+
|
|
338
|
+
**Built for the Ruby community ❤️**
|
|
339
|
+
|
|
340
|
+
<sub><code>cashfree_pg</code> · Unofficial Ruby Client for Cashfree Payments</sub>
|
|
341
|
+
|
|
342
|
+
</div>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require_relative "request"
|
|
5
|
+
|
|
6
|
+
module CashfreePg
|
|
7
|
+
# One merchant's credentials and the calls made with them.
|
|
8
|
+
#
|
|
9
|
+
# Checkout, refunds and payment links are available on this client.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# client = CashfreePg::Client.new(
|
|
13
|
+
# client_id: ENV.fetch("CASHFREE_CLIENT_ID"),
|
|
14
|
+
# client_secret: ENV.fetch("CASHFREE_CLIENT_SECRET"),
|
|
15
|
+
# environment: :sandbox
|
|
16
|
+
# )
|
|
17
|
+
class Client
|
|
18
|
+
SANDBOX_URL = "https://sandbox.cashfree.com/pg"
|
|
19
|
+
PRODUCTION_URL = "https://api.cashfree.com/pg"
|
|
20
|
+
DEFAULT_API_VERSION = "2025-01-01"
|
|
21
|
+
|
|
22
|
+
# @return [String]
|
|
23
|
+
attr_reader :client_id
|
|
24
|
+
|
|
25
|
+
# @return [String]
|
|
26
|
+
attr_reader :client_secret
|
|
27
|
+
|
|
28
|
+
# @return [Symbol]
|
|
29
|
+
attr_reader :environment
|
|
30
|
+
|
|
31
|
+
# @return [String]
|
|
32
|
+
attr_reader :api_version
|
|
33
|
+
|
|
34
|
+
# @return [String]
|
|
35
|
+
attr_reader :base_url
|
|
36
|
+
|
|
37
|
+
# @param client_id [String]
|
|
38
|
+
# @param client_secret [String]
|
|
39
|
+
# @param environment [Symbol] `:sandbox` or `:production`
|
|
40
|
+
# @param api_version [String]
|
|
41
|
+
# @param open_timeout [Numeric]
|
|
42
|
+
# @param read_timeout [Numeric]
|
|
43
|
+
# @param base_url [String, nil] test-only override; must be https
|
|
44
|
+
# @param allow_custom_base_url [Boolean]
|
|
45
|
+
def initialize(client_id:, client_secret:, environment: :sandbox, api_version: DEFAULT_API_VERSION,
|
|
46
|
+
open_timeout: 30, read_timeout: 60, base_url: nil, allow_custom_base_url: false)
|
|
47
|
+
@client_id = required_string(client_id, "client_id")
|
|
48
|
+
@client_secret = required_string(client_secret, "client_secret")
|
|
49
|
+
@environment = environment
|
|
50
|
+
@api_version = required_string(api_version, "api_version")
|
|
51
|
+
@base_url = resolve_base_url(base_url, allow_custom_base_url)
|
|
52
|
+
@connection = build_connection(open_timeout, read_timeout)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param path [String] absolute path on the Cashfree host, such as `/orders`
|
|
56
|
+
# @param skip_auth [Boolean] omit client id and secret; `x-api-version` is still sent
|
|
57
|
+
# @return [Response]
|
|
58
|
+
# @example
|
|
59
|
+
# client.get("/orders/order_123")
|
|
60
|
+
def get(path, skip_auth: false)
|
|
61
|
+
call(:get, path, skip_auth: skip_auth)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param path [String]
|
|
65
|
+
# @param body [Hash, nil]
|
|
66
|
+
# @param skip_auth [Boolean]
|
|
67
|
+
# @param idempotency_key [String, nil] sent as `x-idempotency-key`; the gem does not retry
|
|
68
|
+
# @return [Response]
|
|
69
|
+
# @example
|
|
70
|
+
# client.post("/orders", { order_amount: "10.10", order_currency: "INR" })
|
|
71
|
+
def post(path, body = nil, skip_auth: false, idempotency_key: nil)
|
|
72
|
+
call(:post, path, body: body, skip_auth: skip_auth, idempotency_key: idempotency_key)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param path [String]
|
|
76
|
+
# @param body [Hash]
|
|
77
|
+
# @return [Response]
|
|
78
|
+
# @example
|
|
79
|
+
# client.patch("/orders/order_123", { order_status: "TERMINATED" })
|
|
80
|
+
def patch(path, body)
|
|
81
|
+
call(:patch, path, body: body)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Refunds]
|
|
85
|
+
# @example
|
|
86
|
+
# client.refunds.create("order_123", refund_amount: "10.10", refund_id: "refund_00912")
|
|
87
|
+
def refunds
|
|
88
|
+
@refunds ||= Refunds.new(self)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [PaymentLinks]
|
|
92
|
+
# @example
|
|
93
|
+
# client.payment_links.fetch("link_123")
|
|
94
|
+
def payment_links
|
|
95
|
+
@payment_links ||= PaymentLinks.new(self)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Orders]
|
|
99
|
+
# @example
|
|
100
|
+
# client.orders.create(order_amount: "10.10", order_currency: "INR")
|
|
101
|
+
def orders
|
|
102
|
+
@orders ||= Orders.new(self)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Payments]
|
|
106
|
+
# @example
|
|
107
|
+
# client.payments.upi(order, channel: :intent)
|
|
108
|
+
def payments
|
|
109
|
+
@payments ||= Payments.new(self)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# `pp` uses this too, so the secret stays hidden there as well.
|
|
113
|
+
#
|
|
114
|
+
# @return [String] client secret is always `[FILTERED]`
|
|
115
|
+
def inspect
|
|
116
|
+
"#<CashfreePg::Client environment=#{environment.inspect} api_version=#{api_version.inspect} " \
|
|
117
|
+
"client_id=#{client_id.inspect} client_secret=[FILTERED]>"
|
|
118
|
+
end
|
|
119
|
+
alias to_s inspect
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def call(method, path, **)
|
|
124
|
+
Request.new(self, @connection).perform(method, path, **)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def required_string(value, name)
|
|
128
|
+
string = value.to_s
|
|
129
|
+
raise ArgumentError, "#{name} is required" if string.strip.empty?
|
|
130
|
+
|
|
131
|
+
string
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def resolve_base_url(custom, allowed)
|
|
135
|
+
return environment_url if custom.nil?
|
|
136
|
+
raise ArgumentError, "base_url is only allowed when allow_custom_base_url: true" unless allowed
|
|
137
|
+
raise ArgumentError, "base_url must use https" unless custom.start_with?("https://")
|
|
138
|
+
|
|
139
|
+
custom.chomp("/")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def environment_url
|
|
143
|
+
case environment
|
|
144
|
+
when :sandbox then SANDBOX_URL
|
|
145
|
+
when :production then PRODUCTION_URL
|
|
146
|
+
else
|
|
147
|
+
raise ArgumentError, "environment must be :sandbox or :production"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def build_connection(open_timeout, read_timeout)
|
|
152
|
+
Faraday.new(url: base_url) do |faraday|
|
|
153
|
+
faraday.options.open_timeout = open_timeout
|
|
154
|
+
faraday.options.timeout = read_timeout
|
|
155
|
+
faraday.ssl.verify = true
|
|
156
|
+
faraday.adapter Faraday.default_adapter
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CashfreePg
|
|
4
|
+
# Base error. The message never includes the raw body or the client secret.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# @return [Integer, nil] HTTP status, when the server responded
|
|
7
|
+
attr_reader :status
|
|
8
|
+
|
|
9
|
+
# @return [String, nil] Cashfree error code
|
|
10
|
+
attr_reader :code
|
|
11
|
+
|
|
12
|
+
# @return [String, nil] Cashfree error type
|
|
13
|
+
attr_reader :type
|
|
14
|
+
|
|
15
|
+
# @return [String, nil] x-request-id for this call
|
|
16
|
+
attr_reader :request_id
|
|
17
|
+
|
|
18
|
+
# @return [String, nil] response body, for callers who need it
|
|
19
|
+
attr_reader :raw_body
|
|
20
|
+
|
|
21
|
+
# @param message [String, nil]
|
|
22
|
+
# @param status [Integer, nil]
|
|
23
|
+
# @param code [String, nil]
|
|
24
|
+
# @param type [String, nil]
|
|
25
|
+
# @param request_id [String, nil]
|
|
26
|
+
# @param raw_body [String, nil]
|
|
27
|
+
def initialize(message = nil, status: nil, code: nil, type: nil, request_id: nil, raw_body: nil)
|
|
28
|
+
@status = status
|
|
29
|
+
@code = code
|
|
30
|
+
@type = type
|
|
31
|
+
@request_id = request_id
|
|
32
|
+
@raw_body = raw_body
|
|
33
|
+
super(message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [String]
|
|
37
|
+
def inspect
|
|
38
|
+
"#<#{self.class.name} status=#{status.inspect} code=#{code.inspect} " \
|
|
39
|
+
"type=#{type.inspect} request_id=#{request_id.inspect} message=#{message.inspect} raw_body=[FILTERED]>"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# HTTP 400.
|
|
44
|
+
class InvalidRequestError < Error; end
|
|
45
|
+
|
|
46
|
+
# HTTP 401.
|
|
47
|
+
class AuthenticationError < Error; end
|
|
48
|
+
|
|
49
|
+
# HTTP 403. Product not enabled, or the caller is not allowed.
|
|
50
|
+
class PermissionError < Error; end
|
|
51
|
+
|
|
52
|
+
# HTTP 404.
|
|
53
|
+
class NotFoundError < Error; end
|
|
54
|
+
|
|
55
|
+
# HTTP 409. A duplicate order, refund, or link id.
|
|
56
|
+
class ConflictError < Error; end
|
|
57
|
+
|
|
58
|
+
# HTTP 422.
|
|
59
|
+
class ValidationError < Error; end
|
|
60
|
+
|
|
61
|
+
# HTTP 429.
|
|
62
|
+
class RateLimitError < Error; end
|
|
63
|
+
|
|
64
|
+
# HTTP 5xx and any other unexpected status.
|
|
65
|
+
class APIError < Error; end
|
|
66
|
+
|
|
67
|
+
# No response was received (DNS, connection refused, TLS).
|
|
68
|
+
class ConnectionError < Error; end
|
|
69
|
+
|
|
70
|
+
# The call timed out and was not retried. For POST, fetch the resource before trying again.
|
|
71
|
+
class TimeoutError < Error; end
|
|
72
|
+
|
|
73
|
+
# The webhook signature, headers, or body could not be trusted.
|
|
74
|
+
class SignatureVerificationError < Error; end
|
|
75
|
+
end
|