payriff 0.1.2
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 +15 -0
- data/LICENSE +21 -0
- data/README.md +266 -0
- data/lib/payriff/card.rb +96 -0
- data/lib/payriff/client.rb +28 -0
- data/lib/payriff/errors.rb +50 -0
- data/lib/payriff/payriff_object.rb +81 -0
- data/lib/payriff/services.rb +250 -0
- data/lib/payriff/transport.rb +106 -0
- data/lib/payriff/util.rb +72 -0
- data/lib/payriff/version.rb +5 -0
- data/lib/payriff.rb +25 -0
- metadata +53 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 34ca8457b73de24291fd8233c3b2b3ef39f9bebcb9fc0b3f55d993b70c528339
|
|
4
|
+
data.tar.gz: 618c510f75c442dca74c577216d4e22d1087a4413f645573dea8678a5e171107
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 76b857cbcc8e0597772e842871e3845676ba9d879cd08759a618dbd93c655eb3c0228b25141c060d7f53125f2a2fd4e059a22ac78a6c86f87042c1c4b7657fe5
|
|
7
|
+
data.tar.gz: b57f285973dffe727c31440ce0178f5f6e45a6f6e676d362db035f2592c9974eb3a68ec08d32ff0b5372648406fbf728f217c6398a44cc013f720f31788d150c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2 (2026-10-15)
|
|
4
|
+
|
|
5
|
+
- First RubyGems release: install with `gem "payriff"`.
|
|
6
|
+
- Test suite runs under Bundler on Ruby 4.0 (`logger` is no longer a default gem).
|
|
7
|
+
|
|
8
|
+
## 0.1.1 (2026-10-07)
|
|
9
|
+
|
|
10
|
+
- Fix `cannot load such file -- base64` under Bundler on Ruby 3.4+ (base64 is no longer a default gem).
|
|
11
|
+
|
|
12
|
+
## 0.1.0 (2026-10-07)
|
|
13
|
+
|
|
14
|
+
- Initial release: orders, direct and saved-card payments, saved cards, transactions, payouts,
|
|
15
|
+
v2 invoices, order callback parsing.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Payriff
|
|
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,266 @@
|
|
|
1
|
+
# Payriff Ruby SDK
|
|
2
|
+
|
|
3
|
+
Ruby client for the Payriff merchant API: orders, direct (host-to-host) card payments, saved cards,
|
|
4
|
+
transactions, payouts and invoices.
|
|
5
|
+
|
|
6
|
+
- Ruby 3.1 or newer
|
|
7
|
+
- No runtime dependencies (standard library `net/http`, `json` and `openssl` only)
|
|
8
|
+
- Talks to `https://api.payriff.com`
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
# Gemfile
|
|
14
|
+
gem "payriff"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then run `bundle install`, or install it directly with `gem install payriff`.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "payriff"
|
|
23
|
+
|
|
24
|
+
payriff = Payriff::Client.new(app_key: ENV.fetch("PAYRIFF_APP_KEY"))
|
|
25
|
+
|
|
26
|
+
order = payriff.orders.create(
|
|
27
|
+
amount: "10.00",
|
|
28
|
+
description: "Order #1001",
|
|
29
|
+
callback_url: "https://shop.example/payriff/callback",
|
|
30
|
+
request_rrn: "order-1001"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Send the customer to the hosted payment page
|
|
34
|
+
redirect_to order.payment_url, allow_other_host: true
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Responses are `Payriff::PayriffObject`s: read fields with snake_case methods (`order.payment_url`)
|
|
38
|
+
or `order[:payment_url]`, and convert with `to_h`. Fields Payriff did not send read as `nil`.
|
|
39
|
+
|
|
40
|
+
Create one `Payriff::Client` and reuse it.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
| Argument | Required | Default | Notes |
|
|
45
|
+
|---|---|---|---|
|
|
46
|
+
| `app_key:` | yes | — | Application key from the Payriff dashboard. |
|
|
47
|
+
| `merchant_id:` | for payouts and invoices | — | Merchant ID (e.g. `ES1000000`). |
|
|
48
|
+
| `timeout:` | no | `60` | Read/write timeout in seconds. Bank operations can take tens of seconds. |
|
|
49
|
+
| `open_timeout:` | no | `10` | Connection timeout in seconds. |
|
|
50
|
+
| `card_encryption_key:` | no | built-in Payriff key | Only if Payriff rotates the card-encryption key. Base64 or PEM. |
|
|
51
|
+
|
|
52
|
+
Keep the app key in an environment variable or secret store. Never commit it.
|
|
53
|
+
|
|
54
|
+
## Orders
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
created = payriff.orders.create(
|
|
58
|
+
amount: "25.00",
|
|
59
|
+
currency: "AZN", # default AZN
|
|
60
|
+
operation: "PRE_AUTH", # default PURCHASE
|
|
61
|
+
language: "AZ",
|
|
62
|
+
description: "Booking #77",
|
|
63
|
+
callback_url: "https://shop.example/payriff/callback",
|
|
64
|
+
metadata: { "bookingId" => "77" }
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
info = payriff.orders.get(created.order_id)
|
|
68
|
+
by_ref = payriff.orders.get_by_request_rrn("order-1001") # the request_rrn you sent on create
|
|
69
|
+
|
|
70
|
+
payriff.orders.complete(created.order_id, amount: "25.00")
|
|
71
|
+
payriff.orders.refund(created.order_id, amount: "5.00", refund_reason: "Partial return")
|
|
72
|
+
payriff.orders.expire(created.order_id) # cancel an unpaid order
|
|
73
|
+
|
|
74
|
+
receipt_pdf = payriff.orders.download_receipt(created.order_id)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`payment_status` is a string such as `"APPROVED"`, `"DECLINED"`, `"PREAUTH_APPROVED"` or `"REFUNDED"`.
|
|
78
|
+
Enum arguments also accept symbols (`language: :az`).
|
|
79
|
+
|
|
80
|
+
Amounts can be passed as numeric strings (`"10.50"`), integers, floats or `BigDecimal`s.
|
|
81
|
+
|
|
82
|
+
## Direct payments (host-to-host)
|
|
83
|
+
|
|
84
|
+
You collect the card details yourself, and the SDK encrypts them before sending
|
|
85
|
+
(AES-256-GCM + RSA-OAEP). Raw card data never leaves your server in clear text, but your
|
|
86
|
+
systems still handle card data, so PCI DSS requirements apply to you.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
result = payriff.payments.direct_pay(
|
|
90
|
+
amount: "1.00",
|
|
91
|
+
description: "Order #1002",
|
|
92
|
+
callback_url: "https://shop.example/payriff/callback",
|
|
93
|
+
request_rrn: "order-1002",
|
|
94
|
+
card: Payriff::Card.new(
|
|
95
|
+
pan: "4169 7413 3015 1979",
|
|
96
|
+
card_holder: "JOHN DOE",
|
|
97
|
+
expiry_month: "11",
|
|
98
|
+
expiry_year: "27",
|
|
99
|
+
cvv: "123"
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if result.redirect
|
|
104
|
+
# 3-D Secure: send the customer's browser to result.redirect_url.
|
|
105
|
+
# The final status arrives via your callback or payriff.orders.get(result.order_id).
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`Payriff::Card` strips spaces and dashes, pads the month (`1` → `01`), expands a 2-digit year
|
|
110
|
+
(`27` → `2027`) and rejects malformed values before any network call. It is frozen, prints and logs
|
|
111
|
+
with a masked number and no CVV, and cannot be serialized with `Marshal`.
|
|
112
|
+
|
|
113
|
+
### Charging a saved card
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
charge = payriff.payments.auto_pay(
|
|
117
|
+
card_uuid: saved_card_uuid,
|
|
118
|
+
amount: "9.99",
|
|
119
|
+
description: "Monthly subscription",
|
|
120
|
+
request_rrn: "sub-2026-10"
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Saved cards
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
session = payriff.cards.save(
|
|
128
|
+
customer_ref: "customer-42",
|
|
129
|
+
callback_url: "https://shop.example/payriff/card-saved",
|
|
130
|
+
idempotency_key: SecureRandom.uuid
|
|
131
|
+
)
|
|
132
|
+
# Redirect the customer to session.payment_url to verify the card.
|
|
133
|
+
|
|
134
|
+
details = payriff.cards.get_save(session.card_save_id)
|
|
135
|
+
if details.status == "VERIFIED"
|
|
136
|
+
card_uuid = details.card_uuid # store it; use with auto_pay
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
cards = payriff.cards.list("customer-42")
|
|
140
|
+
payriff.cards.delete(cards.first.card_uuid)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Transactions
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
page = payriff.transactions.list(
|
|
147
|
+
status: "APPROVED",
|
|
148
|
+
from: Date.new(2026, 9, 1), # Date, Time or "YYYY-MM-DD"
|
|
149
|
+
to: "2026-09-30",
|
|
150
|
+
page: 0,
|
|
151
|
+
size: 20 # server maximum is 20
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
page.content.each { |tx| puts "#{tx.order_id} #{tx.amount}" }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Payouts
|
|
158
|
+
|
|
159
|
+
Payouts require `merchant_id` on the client.
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
payriff = Payriff::Client.new(app_key: ENV.fetch("PAYRIFF_APP_KEY"), merchant_id: ENV.fetch("PAYRIFF_MERCHANT_ID"))
|
|
163
|
+
|
|
164
|
+
masked_name = payriff.payouts.check_cardholder("4169741330151979") # e.g. "J*** D**"
|
|
165
|
+
|
|
166
|
+
payout = payriff.payouts.create(
|
|
167
|
+
transfer_amount: "50.00", # minimum 1
|
|
168
|
+
description: "Refund for order #1001",
|
|
169
|
+
full_name: "JOHN DOE",
|
|
170
|
+
fin_code: "1AB2C3D",
|
|
171
|
+
card_pan: "4169741330151979",
|
|
172
|
+
request_rrn: "payout-1001",
|
|
173
|
+
idempotency_key: "payout-1001"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
status = payriff.payouts.get_by_request_rrn("payout-1001")
|
|
177
|
+
history = payriff.payouts.list(status: "SUCCESS")
|
|
178
|
+
receipt = payriff.payouts.download_receipt("payout-1001")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Invoices
|
|
182
|
+
|
|
183
|
+
Invoices require `merchant_id` on the client.
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
invoice = payriff.invoices.create(
|
|
187
|
+
amount: "15.00",
|
|
188
|
+
full_name: "JOHN DOE",
|
|
189
|
+
phone_number: "+994501234567",
|
|
190
|
+
description: "Consultation",
|
|
191
|
+
expire_date: Time.now + 7 * 24 * 3600,
|
|
192
|
+
send_sms: true
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
link = invoice.payment_url # share with the customer
|
|
196
|
+
details = payriff.invoices.get(invoice.invoice_uuid)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Callbacks
|
|
200
|
+
|
|
201
|
+
When an order changes state, Payriff POSTs JSON to the `callback_url` you set on the order.
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
class PayriffCallbacksController < ApplicationController
|
|
205
|
+
skip_forgery_protection
|
|
206
|
+
|
|
207
|
+
def create
|
|
208
|
+
notified = Payriff.parse_order_callback(request.raw_post) # also accepts a parsed Hash
|
|
209
|
+
|
|
210
|
+
# Callbacks are not signed: confirm the state with Payriff before fulfilling.
|
|
211
|
+
confirmed = PAYRIFF.orders.get(notified.order_id)
|
|
212
|
+
if confirmed.payment_status == "APPROVED"
|
|
213
|
+
# fulfil the order (make this idempotent: the same callback can arrive more than once)
|
|
214
|
+
end
|
|
215
|
+
head :ok
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Errors
|
|
221
|
+
|
|
222
|
+
Every SDK error extends `Payriff::Error`, which provides `http_status`, `code` (Payriff result code)
|
|
223
|
+
and `response_id` (quote it when contacting support).
|
|
224
|
+
|
|
225
|
+
| Exception | When |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `Payriff::AuthenticationError` | App key rejected (`14010`, `14013`, `14014`, `14015`) |
|
|
228
|
+
| `Payriff::ValidationError` | Invalid request (`15400` or HTTP 400) |
|
|
229
|
+
| `Payriff::RequestRejectedError` | Business refusal (`01000`), e.g. application under review |
|
|
230
|
+
| `Payriff::InsufficientBalanceError` | Not enough wallet balance for a payout (`01200`) |
|
|
231
|
+
| `Payriff::PayoutLimitError` | Payout limit reached (`01300`, `01400`, `01500`) |
|
|
232
|
+
| `Payriff::ApiError` | Any other failure reported by Payriff |
|
|
233
|
+
| `Payriff::ConnectionError` | No response: network error or timeout |
|
|
234
|
+
|
|
235
|
+
Payriff can report a failure with HTTP 200. The SDK checks the result code in the body, so you
|
|
236
|
+
only need to rescue exceptions. Invalid arguments raise `ArgumentError` before any request is sent.
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
begin
|
|
240
|
+
payriff.orders.refund(order_id)
|
|
241
|
+
rescue Payriff::ValidationError => e
|
|
242
|
+
Rails.logger.warn("Refund rejected: #{e.message} (#{e.code})")
|
|
243
|
+
rescue Payriff::ConnectionError
|
|
244
|
+
# Outcome unknown: check payriff.orders.get(order_id) before retrying
|
|
245
|
+
rescue Payriff::Error => e
|
|
246
|
+
Rails.logger.error("Payriff error #{e.code} response_id=#{e.response_id}")
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Retries
|
|
251
|
+
|
|
252
|
+
The SDK never retries on its own, because payment calls are not safe to repeat blindly. After a
|
|
253
|
+
`Payriff::ConnectionError`, look the operation up first (`orders.get_by_request_rrn`,
|
|
254
|
+
`payouts.get_by_request_rrn`) and retry only if it does not exist. Set `request_rrn` /
|
|
255
|
+
`idempotency_key` on every request so that this lookup is possible.
|
|
256
|
+
|
|
257
|
+
## Development
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
bundle install
|
|
261
|
+
bundle exec rake test
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## License
|
|
265
|
+
|
|
266
|
+
MIT
|
data/lib/payriff/card.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Payriff
|
|
7
|
+
class Card
|
|
8
|
+
def initialize(pan:, card_holder:, expiry_month:, expiry_year:, cvv:)
|
|
9
|
+
pan = digits(pan, "pan")
|
|
10
|
+
raise ArgumentError, "pan must contain 12-19 digits" unless pan.length.between?(12, 19)
|
|
11
|
+
|
|
12
|
+
month = digits(expiry_month, "expiry_month").to_i
|
|
13
|
+
raise ArgumentError, "expiry_month must be 1-12" unless month.between?(1, 12)
|
|
14
|
+
|
|
15
|
+
year = digits(expiry_year, "expiry_year")
|
|
16
|
+
year = "20#{year}" if year.length == 2
|
|
17
|
+
raise ArgumentError, "expiry_year must have 2 or 4 digits" unless year.length == 4
|
|
18
|
+
|
|
19
|
+
cvv = digits(cvv, "cvv")
|
|
20
|
+
raise ArgumentError, "cvv must contain 3-4 digits" unless cvv.length.between?(3, 4)
|
|
21
|
+
raise ArgumentError, "card_holder is required" if card_holder.nil?
|
|
22
|
+
|
|
23
|
+
@payload = JSON.generate(
|
|
24
|
+
"pan" => pan,
|
|
25
|
+
"cardHolder" => card_holder.to_s.strip,
|
|
26
|
+
"expiryYear" => year,
|
|
27
|
+
"expiryMonth" => format("%02d", month),
|
|
28
|
+
"cvv" => cvv
|
|
29
|
+
).freeze
|
|
30
|
+
@masked = "#{pan[0, 6]}******#{pan[-4, 4]}"
|
|
31
|
+
@expiry = "#{format('%02d', month)}/#{year}"
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s
|
|
36
|
+
"#<Payriff::Card #{@masked} #{@expiry}>"
|
|
37
|
+
end
|
|
38
|
+
alias inspect to_s
|
|
39
|
+
|
|
40
|
+
def to_json(*args)
|
|
41
|
+
to_s.to_json(*args)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def marshal_dump
|
|
45
|
+
raise TypeError, "Payriff::Card cannot be serialized"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def encrypted_payload(public_key)
|
|
49
|
+
CardEncryptor.encrypt(public_key, @payload)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def digits(value, field)
|
|
55
|
+
raise ArgumentError, "#{field} must contain digits only" if value.nil?
|
|
56
|
+
|
|
57
|
+
v = value.to_s.gsub(/[\s-]/, "")
|
|
58
|
+
raise ArgumentError, "#{field} must contain digits only" unless v.match?(/\A[0-9]+\z/)
|
|
59
|
+
|
|
60
|
+
v
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
module CardEncryptor
|
|
65
|
+
PRODUCTION_KEY =
|
|
66
|
+
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxRq5a+44T6Dac60XmVRQ/7cpPyFsBnamXbJlRVJk8CnES5Re5tVMohyD0hZr" \
|
|
67
|
+
"3zcQj+bxodYB4zZpQTPlrXvFBC3zz+rXnGlevxBQ6W2d3QC9q8vWH8p3ZOwTO3qVvDSHH9o+hMMRNbJ7kueq/KZlX/F+bjZ23CZw7iXE" \
|
|
68
|
+
"GQT3HYVYnnHsvpaguYDteWBag2sPPLLsVjeB3zhTfQ7OsWp5XTkDuRwLugHPvs6RHLcwGCnodukWyvwUaEUQR/kMGC+RbMsAIVkcLMP5" \
|
|
69
|
+
"csfR3Xo7Gi98+i44iLN00f7gE8QvEmvv8xDspyTAjDEL1a5gK7TijJ3yLG/Bwa1rr1uskYy7lwIDAQAB"
|
|
70
|
+
|
|
71
|
+
OAEP = { rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256" }.freeze
|
|
72
|
+
|
|
73
|
+
module_function
|
|
74
|
+
|
|
75
|
+
def parse_key(base64_or_pem)
|
|
76
|
+
body = base64_or_pem.to_s.gsub(/-----(BEGIN|END) PUBLIC KEY-----|\s/, "")
|
|
77
|
+
key = OpenSSL::PKey.read(body.unpack1("m0"))
|
|
78
|
+
raise ArgumentError, "Invalid RSA public key" unless key.is_a?(OpenSSL::PKey::RSA) && !key.private?
|
|
79
|
+
|
|
80
|
+
key
|
|
81
|
+
rescue OpenSSL::PKey::PKeyError, ArgumentError
|
|
82
|
+
raise ArgumentError, "Invalid RSA public key"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def encrypt(public_key, payload)
|
|
86
|
+
aes_key = OpenSSL::Random.random_bytes(32)
|
|
87
|
+
iv = OpenSSL::Random.random_bytes(12)
|
|
88
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
|
|
89
|
+
cipher.key = aes_key
|
|
90
|
+
cipher.iv = iv
|
|
91
|
+
ciphertext = cipher.update(payload) + cipher.final + cipher.auth_tag(16)
|
|
92
|
+
secret = public_key.encrypt(aes_key + iv, OAEP)
|
|
93
|
+
{ message: [ciphertext].pack("m0"), secret_key: [secret].pack("m0") }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Payriff
|
|
4
|
+
class Client
|
|
5
|
+
PRODUCTION_URL = "https://api.payriff.com"
|
|
6
|
+
|
|
7
|
+
attr_reader :orders, :payments, :cards, :transactions, :payouts, :invoices
|
|
8
|
+
|
|
9
|
+
def initialize(app_key:, merchant_id: nil, timeout: 60, open_timeout: 10, card_encryption_key: nil, base_url: PRODUCTION_URL)
|
|
10
|
+
raise ArgumentError, "app_key is required" if app_key.nil? || app_key.to_s.strip.empty?
|
|
11
|
+
raise ArgumentError, "timeouts must be positive" unless timeout.to_f.positive? && open_timeout.to_f.positive?
|
|
12
|
+
|
|
13
|
+
transport = Transport.new(base_url: base_url, app_key: app_key, merchant_id: merchant_id,
|
|
14
|
+
timeout: timeout, open_timeout: open_timeout)
|
|
15
|
+
key = CardEncryptor.parse_key(card_encryption_key || CardEncryptor::PRODUCTION_KEY)
|
|
16
|
+
@orders = Services::Orders.new(transport)
|
|
17
|
+
@payments = Services::Payments.new(transport, key)
|
|
18
|
+
@cards = Services::Cards.new(transport)
|
|
19
|
+
@transactions = Services::Transactions.new(transport)
|
|
20
|
+
@payouts = Services::Payouts.new(transport)
|
|
21
|
+
@invoices = Services::Invoices.new(transport)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def inspect
|
|
25
|
+
"#<Payriff::Client>"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Payriff
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :http_status, :code, :response_id
|
|
6
|
+
|
|
7
|
+
def initialize(message, http_status: 0, code: nil, response_id: nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@http_status = http_status
|
|
10
|
+
@code = code
|
|
11
|
+
@response_id = response_id
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class ApiError < Error; end
|
|
16
|
+
class AuthenticationError < ApiError; end
|
|
17
|
+
class ValidationError < ApiError; end
|
|
18
|
+
class RequestRejectedError < ApiError; end
|
|
19
|
+
class InsufficientBalanceError < ApiError; end
|
|
20
|
+
class PayoutLimitError < ApiError; end
|
|
21
|
+
class ConnectionError < Error; end
|
|
22
|
+
|
|
23
|
+
module ResultCodes
|
|
24
|
+
SUCCESS = "00000"
|
|
25
|
+
|
|
26
|
+
BY_CODE = {
|
|
27
|
+
"14010" => AuthenticationError,
|
|
28
|
+
"14013" => AuthenticationError,
|
|
29
|
+
"14014" => AuthenticationError,
|
|
30
|
+
"14015" => AuthenticationError,
|
|
31
|
+
"01300" => PayoutLimitError,
|
|
32
|
+
"01400" => PayoutLimitError,
|
|
33
|
+
"01500" => PayoutLimitError,
|
|
34
|
+
"01200" => InsufficientBalanceError,
|
|
35
|
+
"01000" => RequestRejectedError,
|
|
36
|
+
"15400" => ValidationError
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
def self.to_error(message, http_status, code, response_id)
|
|
40
|
+
msg = message.nil? || message.empty? ? "Payriff request failed (HTTP #{http_status})" : message
|
|
41
|
+
klass = BY_CODE[code] ||
|
|
42
|
+
case http_status
|
|
43
|
+
when 401, 403 then AuthenticationError
|
|
44
|
+
when 400 then ValidationError
|
|
45
|
+
else ApiError
|
|
46
|
+
end
|
|
47
|
+
klass.new(msg, http_status: http_status, code: code, response_id: response_id)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Payriff
|
|
4
|
+
class PayriffObject
|
|
5
|
+
RENAMES = { "comissionRate" => :commission_rate, "_final" => :final_state }.freeze
|
|
6
|
+
|
|
7
|
+
def self.convert(value)
|
|
8
|
+
case value
|
|
9
|
+
when Hash then new(value)
|
|
10
|
+
when Array then value.map { |v| convert(v) }
|
|
11
|
+
else value
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.snake(key)
|
|
16
|
+
RENAMES.fetch(key) do
|
|
17
|
+
key.to_s
|
|
18
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
19
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
20
|
+
.downcase
|
|
21
|
+
.to_sym
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(hash)
|
|
26
|
+
@values = {}
|
|
27
|
+
hash.each do |key, value|
|
|
28
|
+
name = self.class.snake(key)
|
|
29
|
+
@values[name] = self.class.convert(value) unless @values.key?(name) && value.nil?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def [](key)
|
|
34
|
+
@values[key.to_sym]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def key?(key)
|
|
38
|
+
@values.key?(key.to_sym)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def keys
|
|
42
|
+
@values.keys
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_h
|
|
46
|
+
@values.transform_values { |v| unwrap(v) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ==(other)
|
|
50
|
+
other.is_a?(PayriffObject) && other.to_h == to_h
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def inspect
|
|
54
|
+
"#<#{self.class.name} #{@values.inspect}>"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
FIELD_NAME = /\A(?!to_)[a-z][a-z0-9_]*\z/
|
|
58
|
+
|
|
59
|
+
def respond_to_missing?(name, include_private = false)
|
|
60
|
+
@values.key?(name) || super
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def method_missing(name, *args)
|
|
64
|
+
return super unless args.empty?
|
|
65
|
+
return @values[name] if @values.key?(name)
|
|
66
|
+
return nil if FIELD_NAME.match?(name)
|
|
67
|
+
|
|
68
|
+
super
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def unwrap(value)
|
|
74
|
+
case value
|
|
75
|
+
when PayriffObject then value.to_h
|
|
76
|
+
when Array then value.map { |v| unwrap(v) }
|
|
77
|
+
else value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Payriff
|
|
4
|
+
module Services
|
|
5
|
+
class Base
|
|
6
|
+
include Util
|
|
7
|
+
|
|
8
|
+
def initialize(transport)
|
|
9
|
+
@transport = transport
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def inspect
|
|
13
|
+
"#<#{self.class.name}>"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Orders < Base
|
|
18
|
+
def create(amount:, currency: "AZN", language: nil, operation: "PURCHASE", description: nil, callback_url: nil,
|
|
19
|
+
redirect_url: nil, card_save: nil, three_ds: nil, auto_payment_type: nil, installment: nil,
|
|
20
|
+
full_name: nil, phone_number: nil, metadata: nil, fields: nil, request_rrn: nil)
|
|
21
|
+
body = compact(
|
|
22
|
+
"amount" => to_amount(required(amount, "amount"), "amount"),
|
|
23
|
+
"currency" => stringify(currency),
|
|
24
|
+
"language" => stringify(language),
|
|
25
|
+
"operation" => stringify(operation),
|
|
26
|
+
"description" => description,
|
|
27
|
+
"callbackUrl" => callback_url,
|
|
28
|
+
"redirectUrl" => redirect_url,
|
|
29
|
+
"cardSave" => card_save,
|
|
30
|
+
"threeDS" => three_ds,
|
|
31
|
+
"autoPaymentType" => stringify(auto_payment_type),
|
|
32
|
+
"installment" => installment&.transform_keys(&:to_s)&.transform_values { |v| stringify(v) },
|
|
33
|
+
"fullName" => full_name,
|
|
34
|
+
"phoneNumber" => phone_number,
|
|
35
|
+
"metadata" => metadata,
|
|
36
|
+
"fields" => fields
|
|
37
|
+
)
|
|
38
|
+
@transport.execute("POST", "/api/v3/orders", headers: { "X-REQUEST-RRN" => request_rrn }, body: body)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def get(order_id)
|
|
42
|
+
@transport.execute("GET", "/api/v3/orders/#{segment(order_id, 'order_id')}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def get_status(order_id)
|
|
46
|
+
@transport.execute("GET", "/api/v3/orders/#{segment(order_id, 'order_id')}/status")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def get_by_request_rrn(request_rrn)
|
|
50
|
+
@transport.execute("GET", "/api/v3/orders/#{segment(request_rrn, 'request_rrn')}/rrn")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def expire(order_id)
|
|
54
|
+
segment(order_id, "order_id")
|
|
55
|
+
@transport.execute("PATCH", "/api/v3/expire-status", query: [["orderId", order_id]])
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def refund(order_id, amount: nil, refund_reason: nil, callback_url: nil)
|
|
60
|
+
body = compact("orderId" => required(order_id, "order_id"), "amount" => to_amount(amount, "amount"),
|
|
61
|
+
"refundReason" => refund_reason, "callbackUrl" => callback_url)
|
|
62
|
+
@transport.execute("POST", "/api/v3/refund", body: body)
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def complete(order_id, amount: nil, callback_url: nil)
|
|
67
|
+
body = compact("orderId" => required(order_id, "order_id"), "amount" => to_amount(amount, "amount"),
|
|
68
|
+
"callbackUrl" => callback_url)
|
|
69
|
+
@transport.execute("POST", "/api/v3/complete", body: body)
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def download_receipt(order_id_or_rrn)
|
|
74
|
+
@transport.download("GET", "/api/v3/acquiring/receipt/#{segment(order_id_or_rrn, 'order_id_or_rrn')}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class Payments < Base
|
|
79
|
+
def initialize(transport, public_key)
|
|
80
|
+
super(transport)
|
|
81
|
+
@public_key = public_key
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def direct_pay(amount:, description:, card:, operation: "PURCHASE", currency: "AZN", callback_url: nil,
|
|
85
|
+
three_ds: nil, custom_fields: nil, card_save: false, request_rrn: nil)
|
|
86
|
+
body = compact(
|
|
87
|
+
"amount" => to_amount(required(amount, "amount"), "amount"),
|
|
88
|
+
"operation" => stringify(operation),
|
|
89
|
+
"currency" => stringify(currency),
|
|
90
|
+
"description" => required(description, "description"),
|
|
91
|
+
"callbackUrl" => callback_url,
|
|
92
|
+
"threeDS" => three_ds,
|
|
93
|
+
"customFields" => custom_fields
|
|
94
|
+
)
|
|
95
|
+
raise ArgumentError, "card must be a Payriff::Card" unless card.is_a?(Card)
|
|
96
|
+
|
|
97
|
+
encrypted = card.encrypted_payload(@public_key)
|
|
98
|
+
body["paymentData"] = { "paymentWay" => "DIRECT", "encryptedMessage" => encrypted[:message], "cardSave" => card_save ? true : false }
|
|
99
|
+
@transport.execute("POST", "/api/v3/directPay",
|
|
100
|
+
headers: { "X-REQUEST-RRN" => request_rrn, "x-secret-key" => encrypted[:secret_key] }, body: body)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def auto_pay(card_uuid:, amount:, description:, operation: "PURCHASE", currency: "AZN", callback_url: nil,
|
|
104
|
+
three_ds: nil, one_click_payment: nil, request_rrn: nil)
|
|
105
|
+
body = compact(
|
|
106
|
+
"cardUuid" => required(card_uuid, "card_uuid"),
|
|
107
|
+
"amount" => to_amount(required(amount, "amount"), "amount"),
|
|
108
|
+
"operation" => stringify(operation),
|
|
109
|
+
"currency" => stringify(currency),
|
|
110
|
+
"description" => required(description, "description"),
|
|
111
|
+
"callbackUrl" => callback_url,
|
|
112
|
+
"threeDS" => three_ds,
|
|
113
|
+
"isOneCLickPayment" => one_click_payment
|
|
114
|
+
)
|
|
115
|
+
@transport.execute("POST", "/api/v3/autoPay", headers: { "X-REQUEST-RRN" => request_rrn }, body: body)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class Cards < Base
|
|
120
|
+
def save(customer_ref:, callback_url:, description: nil, language: nil, metadata: nil, idempotency_key: nil)
|
|
121
|
+
body = compact(
|
|
122
|
+
"customerRef" => required(customer_ref, "customer_ref"),
|
|
123
|
+
"callbackUrl" => required(callback_url, "callback_url"),
|
|
124
|
+
"description" => description,
|
|
125
|
+
"language" => stringify(language),
|
|
126
|
+
"metadata" => metadata
|
|
127
|
+
)
|
|
128
|
+
@transport.execute("POST", "/api/v3/cards/save", headers: { "X-Idempotency-Key" => idempotency_key }, body: body)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def get_save(card_save_id)
|
|
132
|
+
@transport.execute("GET", "/api/v3/cards/save/#{segment(card_save_id, 'card_save_id')}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def list(customer_ref)
|
|
136
|
+
segment(customer_ref, "customer_ref")
|
|
137
|
+
@transport.execute("GET", "/api/v3/cards/save", query: [["customerRef", customer_ref]]) || []
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def delete(card_uuid)
|
|
141
|
+
@transport.execute("DELETE", "/api/v3/cards/#{segment(card_uuid, 'card_uuid')}")
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
class Transactions < Base
|
|
147
|
+
def list(order_id: nil, rrn: nil, status: nil, amount: nil, description: nil, name: nil, full_name: nil,
|
|
148
|
+
card_number: nil, booking_id: nil, invoice_code: nil, from: nil, to: nil, page: 0, size: 10)
|
|
149
|
+
query = [
|
|
150
|
+
["orderId", order_id], ["rrn", rrn], ["status", stringify(status)], ["amount", amount],
|
|
151
|
+
["description", description], ["name", name], ["fullName", full_name], ["cardNumber", card_number],
|
|
152
|
+
["bookingId", booking_id], ["invoiceCode", invoice_code],
|
|
153
|
+
["from", filter_date(from, "from")], ["to", filter_date(to, "to")]
|
|
154
|
+
] + page_query(page, size)
|
|
155
|
+
@transport.execute("GET", "/api/v3/transactions", query: query)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
class Payouts < Base
|
|
160
|
+
def create(transfer_amount:, description:, full_name:, fin_code:, card_pan: nil, bank_name: nil, card_type: nil,
|
|
161
|
+
request_rrn: nil, customer_code: nil, voen: nil, birth_date: nil, callback_url: nil, idempotency_key: nil)
|
|
162
|
+
amount = to_amount(required(transfer_amount, "transfer_amount"), "transfer_amount")
|
|
163
|
+
raise ArgumentError, "transfer_amount must be at least 1" if amount < 1
|
|
164
|
+
|
|
165
|
+
body = compact(
|
|
166
|
+
"transferAmount" => amount,
|
|
167
|
+
"description" => required(description, "description"),
|
|
168
|
+
"fullName" => required(full_name, "full_name"),
|
|
169
|
+
"finCode" => required(fin_code, "fin_code"),
|
|
170
|
+
"cardPan" => card_pan,
|
|
171
|
+
"bankName" => bank_name,
|
|
172
|
+
"cardType" => card_type,
|
|
173
|
+
"requestRrn" => request_rrn,
|
|
174
|
+
"customerCode" => customer_code,
|
|
175
|
+
"voen" => voen,
|
|
176
|
+
"birthDate" => birth_date,
|
|
177
|
+
"callbackUrl" => callback_url
|
|
178
|
+
)
|
|
179
|
+
@transport.execute("POST", "/api/v3/payout", headers: { "X-IDEMPOTENCY-KEY" => idempotency_key },
|
|
180
|
+
body: body, merchant_envelope: true)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def get_by_request_rrn(request_rrn)
|
|
184
|
+
@transport.execute("GET", "/api/v3/payout/info/#{segment(request_rrn, 'request_rrn')}")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def check_cardholder(card_pan)
|
|
188
|
+
pan = card_pan.to_s.gsub(/[\s-]/, "")
|
|
189
|
+
raise ArgumentError, "card_pan must be a 16-digit number" unless pan.match?(/\A[0-9]{16}\z/)
|
|
190
|
+
|
|
191
|
+
@transport.execute("POST", "/api/v3/payout/check-cardholder", body: { "cardPan" => pan }).to_s
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def list(rrn: nil, status: nil, amount: nil, description: nil, full_name: nil, fin_code: nil, bank_source: nil,
|
|
195
|
+
from: nil, to: nil, page: 0, size: 10)
|
|
196
|
+
query = [
|
|
197
|
+
["rrn", rrn], ["status", stringify(status)], ["amount", amount], ["description", description],
|
|
198
|
+
["fullName", full_name], ["finCode", fin_code], ["bankSource", bank_source],
|
|
199
|
+
["from", filter_date(from, "from")], ["to", filter_date(to, "to")]
|
|
200
|
+
] + page_query(page, size)
|
|
201
|
+
@transport.execute("GET", "/api/v3/payouts", query: query)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def download_receipt(request_rrn)
|
|
205
|
+
@transport.download("GET", "/api/v3/payout/receipt/#{segment(request_rrn, 'request_rrn')}")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
class Invoices < Base
|
|
210
|
+
def create(amount: nil, amount_dynamic: nil, currency: "AZN", language: nil, full_name: nil, email: nil,
|
|
211
|
+
phone_number: nil, description: nil, custom_message: nil, expire_date: nil, approve_url: nil,
|
|
212
|
+
cancel_url: nil, decline_url: nil, redirect_url: nil, installment_product_type: nil,
|
|
213
|
+
installment_period: nil, direct_pay: nil, send_sms: nil, send_whatsapp: nil, send_email: nil,
|
|
214
|
+
metadata: nil, external_transaction_id: nil)
|
|
215
|
+
raise ArgumentError, "amount is required" if amount_dynamic != true && amount.nil?
|
|
216
|
+
|
|
217
|
+
body = compact(
|
|
218
|
+
"amount" => to_amount(amount, "amount"),
|
|
219
|
+
"amountDynamic" => amount_dynamic,
|
|
220
|
+
"currencyType" => stringify(currency),
|
|
221
|
+
"languageType" => stringify(language),
|
|
222
|
+
"fullName" => full_name,
|
|
223
|
+
"email" => email,
|
|
224
|
+
"phoneNumber" => phone_number,
|
|
225
|
+
"description" => description,
|
|
226
|
+
"customMessage" => custom_message,
|
|
227
|
+
"expireDate" => date_time(expire_date),
|
|
228
|
+
"approveURL" => approve_url,
|
|
229
|
+
"cancelURL" => cancel_url,
|
|
230
|
+
"declineURL" => decline_url,
|
|
231
|
+
"redirectURL" => redirect_url,
|
|
232
|
+
"installmentProductType" => stringify(installment_product_type),
|
|
233
|
+
"installmentPeriod" => installment_period,
|
|
234
|
+
"directPay" => direct_pay,
|
|
235
|
+
"sendSms" => send_sms,
|
|
236
|
+
"sendWhatsapp" => send_whatsapp,
|
|
237
|
+
"sendEmail" => send_email,
|
|
238
|
+
"metadata" => metadata,
|
|
239
|
+
"externalTransactionId" => external_transaction_id
|
|
240
|
+
)
|
|
241
|
+
@transport.execute("POST", "/api/v2/invoices", body: body, merchant_envelope: true)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def get(invoice_uuid)
|
|
245
|
+
segment(invoice_uuid, "invoice_uuid")
|
|
246
|
+
@transport.execute("POST", "/api/v2/get-invoice", body: { "uuid" => invoice_uuid }, merchant_envelope: true)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Payriff
|
|
8
|
+
class Transport
|
|
9
|
+
MAX_ERROR_BODY = 500
|
|
10
|
+
CONNECTION_ERRORS = [
|
|
11
|
+
IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, EOFError
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(base_url:, app_key:, merchant_id:, timeout:, open_timeout:)
|
|
15
|
+
@base_uri = URI(base_url.to_s.chomp("/"))
|
|
16
|
+
@app_key = app_key
|
|
17
|
+
@merchant_id = merchant_id
|
|
18
|
+
@timeout = timeout
|
|
19
|
+
@open_timeout = open_timeout
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def execute(method, path, query: nil, headers: nil, body: nil, merchant_envelope: false)
|
|
23
|
+
status, raw = send_request(method, path, query, headers, body, merchant_envelope, "application/json")
|
|
24
|
+
PayriffObject.convert(unwrap(status, raw))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def download(method, path)
|
|
28
|
+
status, raw = send_request(method, path, nil, nil, nil, false, "application/pdf, application/json")
|
|
29
|
+
return raw.b if (200..299).cover?(status)
|
|
30
|
+
|
|
31
|
+
unwrap(status, raw)
|
|
32
|
+
raise ApiError.new("Unexpected response", http_status: status)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def send_request(method, path, query, headers, body, merchant_envelope, accept)
|
|
38
|
+
payload = request_body(body, merchant_envelope)
|
|
39
|
+
uri = URI(@base_uri.to_s + path + query_string(query))
|
|
40
|
+
request = Net::HTTPGenericRequest.new(method, !payload.nil?, true, uri)
|
|
41
|
+
request["Accept"] = accept
|
|
42
|
+
request["User-Agent"] = "payriff-ruby/#{VERSION}"
|
|
43
|
+
request["Authorization"] = @app_key
|
|
44
|
+
(headers || {}).each { |name, value| request[name] = value.to_s unless value.nil? || value.to_s.empty? }
|
|
45
|
+
if payload
|
|
46
|
+
request["Content-Type"] = "application/json"
|
|
47
|
+
request.body = payload
|
|
48
|
+
end
|
|
49
|
+
response = http(uri).request(request)
|
|
50
|
+
[response.code.to_i, response.body.to_s]
|
|
51
|
+
rescue *CONNECTION_ERRORS => e
|
|
52
|
+
raise ConnectionError, "Payriff request failed: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def http(uri)
|
|
56
|
+
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
|
57
|
+
http.use_ssl = uri.scheme == "https"
|
|
58
|
+
http.open_timeout = @open_timeout
|
|
59
|
+
http.read_timeout = @timeout
|
|
60
|
+
http.write_timeout = @timeout
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def query_string(query)
|
|
65
|
+
pairs = (query || []).reject { |_, v| v.nil? || v.to_s.empty? }
|
|
66
|
+
return "" if pairs.empty?
|
|
67
|
+
|
|
68
|
+
"?" + pairs.map { |k, v| "#{Util.escape(k)}=#{Util.escape(v)}" }.join("&")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def request_body(body, merchant_envelope)
|
|
72
|
+
if merchant_envelope
|
|
73
|
+
raise ArgumentError, "merchant_id must be configured on Payriff::Client for this operation" if @merchant_id.to_s.empty?
|
|
74
|
+
|
|
75
|
+
body = { "merchant" => @merchant_id, "body" => body || {} }
|
|
76
|
+
end
|
|
77
|
+
body.nil? ? nil : JSON.generate(body)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def unwrap(status, raw)
|
|
81
|
+
root = begin
|
|
82
|
+
raw.empty? ? nil : JSON.parse(raw)
|
|
83
|
+
rescue JSON::ParserError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
ok = (200..299).cover?(status)
|
|
87
|
+
unless root.is_a?(Hash) && root.key?("code")
|
|
88
|
+
raise ApiError.new(ok ? "Unexpected response from Payriff" : truncate(raw, status), http_status: status)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
code = root["code"]&.to_s
|
|
92
|
+
unless ok && code == ResultCodes::SUCCESS
|
|
93
|
+
raise ResultCodes.to_error(root["message"]&.to_s, status, code, root["responseId"]&.to_s)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
root["payload"]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def truncate(raw, status)
|
|
100
|
+
return "Payriff request failed (HTTP #{status})" if raw.empty?
|
|
101
|
+
|
|
102
|
+
text = raw.dup.force_encoding(Encoding::UTF_8).scrub
|
|
103
|
+
text.length > MAX_ERROR_BODY ? "#{text[0, MAX_ERROR_BODY]}..." : text
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/payriff/util.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Payriff
|
|
7
|
+
module Util
|
|
8
|
+
MAX_PAGE_SIZE = 20
|
|
9
|
+
UNRESERVED = /[^A-Za-z0-9\-._~]/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def escape(value)
|
|
14
|
+
value.to_s.b.gsub(UNRESERVED) { |c| format("%%%02X", c.ord) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def segment(value, name)
|
|
18
|
+
raise ArgumentError, "#{name} must not be blank" if !value.is_a?(String) || value.strip.empty?
|
|
19
|
+
|
|
20
|
+
escape(value)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def required(value, name)
|
|
24
|
+
raise ArgumentError, "#{name} is required" if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
25
|
+
|
|
26
|
+
value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_amount(value, name)
|
|
30
|
+
return nil if value.nil?
|
|
31
|
+
|
|
32
|
+
Float(value.is_a?(String) ? value : value.to_s)
|
|
33
|
+
rescue ArgumentError, TypeError
|
|
34
|
+
raise ArgumentError, "#{name} must be a number"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def compact(hash)
|
|
38
|
+
hash.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def stringify(value)
|
|
42
|
+
value.is_a?(Symbol) ? value.to_s.upcase : value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def date_time(value)
|
|
46
|
+
case value
|
|
47
|
+
when nil then nil
|
|
48
|
+
when Time, DateTime then value.strftime("%Y-%m-%dT%H:%M:%S")
|
|
49
|
+
when Date then value.strftime("%Y-%m-%dT00:00:00")
|
|
50
|
+
else value.to_s
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def filter_date(value, name)
|
|
55
|
+
case value
|
|
56
|
+
when nil then nil
|
|
57
|
+
when Date, Time then value.strftime("%d.%m.%Y")
|
|
58
|
+
when /\A(\d{4})-(\d{2})-(\d{2})\z/ then "#{Regexp.last_match(3)}.#{Regexp.last_match(2)}.#{Regexp.last_match(1)}"
|
|
59
|
+
else raise ArgumentError, "#{name} must be a Date, Time or YYYY-MM-DD string"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def page_query(page, size)
|
|
64
|
+
page ||= 0
|
|
65
|
+
size ||= 10
|
|
66
|
+
raise ArgumentError, "page must be >= 0" unless page.is_a?(Integer) && page >= 0
|
|
67
|
+
raise ArgumentError, "size must be 1-#{MAX_PAGE_SIZE}" unless size.is_a?(Integer) && size.between?(1, MAX_PAGE_SIZE)
|
|
68
|
+
|
|
69
|
+
[["page", page], ["offset", size]]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/payriff.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require_relative "payriff/version"
|
|
5
|
+
require_relative "payriff/errors"
|
|
6
|
+
require_relative "payriff/util"
|
|
7
|
+
require_relative "payriff/payriff_object"
|
|
8
|
+
require_relative "payriff/card"
|
|
9
|
+
require_relative "payriff/transport"
|
|
10
|
+
require_relative "payriff/services"
|
|
11
|
+
require_relative "payriff/client"
|
|
12
|
+
|
|
13
|
+
module Payriff
|
|
14
|
+
NOT_A_CALLBACK = "Not a Payriff order callback"
|
|
15
|
+
|
|
16
|
+
def self.parse_order_callback(body)
|
|
17
|
+
root = body.is_a?(Hash) ? body : JSON.parse(body.to_s)
|
|
18
|
+
payload = root.is_a?(Hash) ? root["payload"] || root[:payload] : nil
|
|
19
|
+
raise ArgumentError, NOT_A_CALLBACK unless payload.is_a?(Hash) && !(payload["orderId"] || payload[:orderId]).nil?
|
|
20
|
+
|
|
21
|
+
PayriffObject.convert(payload.transform_keys(&:to_s))
|
|
22
|
+
rescue JSON::ParserError
|
|
23
|
+
raise ArgumentError, NOT_A_CALLBACK
|
|
24
|
+
end
|
|
25
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: payriff
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.2
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Payriff
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Orders, direct card payments, saved cards, transactions, payouts and
|
|
13
|
+
invoices for Payriff merchants.
|
|
14
|
+
executables: []
|
|
15
|
+
extensions: []
|
|
16
|
+
extra_rdoc_files: []
|
|
17
|
+
files:
|
|
18
|
+
- CHANGELOG.md
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
21
|
+
- lib/payriff.rb
|
|
22
|
+
- lib/payriff/card.rb
|
|
23
|
+
- lib/payriff/client.rb
|
|
24
|
+
- lib/payriff/errors.rb
|
|
25
|
+
- lib/payriff/payriff_object.rb
|
|
26
|
+
- lib/payriff/services.rb
|
|
27
|
+
- lib/payriff/transport.rb
|
|
28
|
+
- lib/payriff/util.rb
|
|
29
|
+
- lib/payriff/version.rb
|
|
30
|
+
homepage: https://github.com/PayRiff/payriff-ruby
|
|
31
|
+
licenses:
|
|
32
|
+
- MIT
|
|
33
|
+
metadata:
|
|
34
|
+
source_code_uri: https://github.com/PayRiff/payriff-ruby
|
|
35
|
+
changelog_uri: https://github.com/PayRiff/payriff-ruby/blob/main/CHANGELOG.md
|
|
36
|
+
rdoc_options: []
|
|
37
|
+
require_paths:
|
|
38
|
+
- lib
|
|
39
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '3.1'
|
|
44
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '0'
|
|
49
|
+
requirements: []
|
|
50
|
+
rubygems_version: 4.0.20
|
|
51
|
+
specification_version: 4
|
|
52
|
+
summary: Ruby client for the Payriff merchant API
|
|
53
|
+
test_files: []
|