lavenda-pay-ruby 0.0.1
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 +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +149 -0
- data/app/controllers/lavenda_pay/webhooks_controller.rb +29 -0
- data/config/routes.rb +3 -0
- data/lib/lavenda-pay-ruby.rb +2 -0
- data/lib/lavenda_pay/client.rb +32 -0
- data/lib/lavenda_pay/configuration.rb +34 -0
- data/lib/lavenda_pay/customers/create.rb +14 -0
- data/lib/lavenda_pay/customers/find.rb +11 -0
- data/lib/lavenda_pay/customers/find_by.rb +16 -0
- data/lib/lavenda_pay/customers/list.rb +16 -0
- data/lib/lavenda_pay/engine.rb +10 -0
- data/lib/lavenda_pay/errors.rb +37 -0
- data/lib/lavenda_pay/http.rb +95 -0
- data/lib/lavenda_pay/operation.rb +22 -0
- data/lib/lavenda_pay/orders/create.rb +15 -0
- data/lib/lavenda_pay/orders/find.rb +11 -0
- data/lib/lavenda_pay/resource.rb +30 -0
- data/lib/lavenda_pay/resources/customer.rb +6 -0
- data/lib/lavenda_pay/resources/order.rb +9 -0
- data/lib/lavenda_pay/version.rb +3 -0
- data/lib/lavenda_pay/webhooks/construct_event.rb +15 -0
- data/lib/lavenda_pay/webhooks/event.rb +69 -0
- data/lib/lavenda_pay/webhooks/parse_event.rb +9 -0
- data/lib/lavenda_pay/webhooks/signature.rb +22 -0
- data/lib/lavenda_pay/webhooks/verify_signature.rb +13 -0
- data/lib/lavenda_pay/webhooks.rb +5 -0
- data/lib/lavenda_pay.rb +33 -0
- metadata +156 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 574e87e7c3cdf2f4a985cc4269a4691d929153f6e515e304411595b061e94a7a
|
|
4
|
+
data.tar.gz: 4cd0279828335208b8a5582c144d9ceb88c5b67ca05f32f66dc842e86d8a1f07
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 23c4df6684ff61b6462c940da2a61c4dbb265ea44bceffa5d7d86e29d4d0b3a4e4689f73b0a22f6496ad7fb072e005285cab609546342e85926329c7551a1cc8
|
|
7
|
+
data.tar.gz: a266e6e3e0e4b556af750e0650c954a97496c3bf656c42ebf6062f86e7d6a690ef40b929f45a21f0fbef41fca13b41033a896cf65d6d74581762982d730d411f
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 La Venda Software
|
|
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,149 @@
|
|
|
1
|
+
# Lavenda Pay Ruby
|
|
2
|
+
|
|
3
|
+
Ruby client for the [Lavenda Pay](https://github.com/LaVendaSoftware/pay-ruby) payments API.
|
|
4
|
+
Framework-agnostic (no Rails/ActiveSupport dependency): create customers and orders, and verify/parse webhooks.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
gem "lavenda-pay-ruby", github: "LaVendaSoftware/pay-ruby"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Configuration
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
# config/initializers/lavenda_pay.rb
|
|
16
|
+
LavendaPay.configure do |config|
|
|
17
|
+
config.base_url = ENV.fetch("LAVENDA_PAY_BASE_URL", Rails.application.credentials.dig(:lavenda_pay, :base_url))
|
|
18
|
+
config.api_token = ENV.fetch("LAVENDA_PAY_API_TOKEN", Rails.application.credentials.dig(:lavenda_pay, :api_token))
|
|
19
|
+
config.webhook_secret = ENV.fetch("LAVENDA_PAY_WEBHOOK_SECRET", Rails.application.credentials.dig(:lavenda_pay, :webhook_secret))
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`base_url` is the root of the Lavenda Pay installation, without the `/api` suffix.
|
|
24
|
+
|
|
25
|
+
Outside Rails, set the values directly. Anything left unset falls back to the `LAVENDA_PAY_BASE_URL`,
|
|
26
|
+
`LAVENDA_PAY_API_TOKEN` and `LAVENDA_PAY_WEBHOOK_SECRET` environment variables.
|
|
27
|
+
|
|
28
|
+
Every operation is a class with `.call` that uses this global configuration. For several accounts, build a client and
|
|
29
|
+
pass it to the operation:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
client = LavendaPay::Client.new(base_url: "...", api_token: "...")
|
|
33
|
+
LavendaPay::Orders::Find.new(client:).call("order_...")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Customers
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
customer = LavendaPay::Customers::Create.call(
|
|
40
|
+
full_name: "Pedro Henrique", user_email: "pedro@example.com",
|
|
41
|
+
document_type: "cpf", document_number: "71415355436",
|
|
42
|
+
dial_code: "55", phone_number: "(49) 98726-1668", eid: "TUTOR-1"
|
|
43
|
+
)
|
|
44
|
+
customer.pid # => "cus_..."
|
|
45
|
+
|
|
46
|
+
# Only `user_email` and `document_number` are supported filters (the API ignores
|
|
47
|
+
# anything else and would return every customer); returns nil when none match.
|
|
48
|
+
LavendaPay::Customers::FindBy.call(document_number: "71415355436") ||
|
|
49
|
+
LavendaPay::Customers::FindBy.call(user_email: "pedro@example.com")
|
|
50
|
+
|
|
51
|
+
LavendaPay::Customers::Find.call("cus_...")
|
|
52
|
+
LavendaPay::Customers::List.call(user_email: "pedro@example.com", page: 1)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Orders
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
order = LavendaPay::Orders::Create.call(
|
|
59
|
+
customer_pid: customer.pid,
|
|
60
|
+
payment_method_kinds: ["pix", "credit_card"],
|
|
61
|
+
description: "Material didático",
|
|
62
|
+
eid: "ORD-0123",
|
|
63
|
+
frequency: "once",
|
|
64
|
+
currency: "brl",
|
|
65
|
+
items_attributes: [{product_title: "Material didático", quantity: 1, unit_price: 450.0}]
|
|
66
|
+
)
|
|
67
|
+
order.pid # => "order_..."
|
|
68
|
+
order.status # => "draft"
|
|
69
|
+
|
|
70
|
+
LavendaPay::Orders::Find.call(order.pid)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Errors
|
|
74
|
+
|
|
75
|
+
Non-2xx responses raise a `LavendaPay::ApiError` subclass exposing `status`, `body` and `errors`:
|
|
76
|
+
`BadRequestError` (400), `AuthenticationError` (401/403), `NotFoundError` (404), `UnprocessableError` (422),
|
|
77
|
+
`ServerError` (5xx). Network failures raise `ConnectionError`; missing config raises `ConfigurationError`.
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
begin
|
|
81
|
+
LavendaPay::Customers::Create.call(params)
|
|
82
|
+
rescue LavendaPay::UnprocessableError => error
|
|
83
|
+
error.errors # => {"user_email" => ["is invalid"]}
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Webhooks
|
|
88
|
+
|
|
89
|
+
Lavenda Pay POSTs JSON to your endpoint with the shared secret in the header exposed as
|
|
90
|
+
`LavendaPay::Webhooks::Signature::HEADER`.
|
|
91
|
+
|
|
92
|
+
### Rails engine (recommended)
|
|
93
|
+
|
|
94
|
+
Mount the engine and give it a handler. It verifies the secret, parses the payload and calls your handler with a
|
|
95
|
+
`LavendaPay::Webhooks::Event` for every supported event.
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# config/routes.rb
|
|
99
|
+
mount LavendaPay::Engine => "/webhooks/lavenda_pay"
|
|
100
|
+
|
|
101
|
+
# config/initializers/lavenda_pay.rb
|
|
102
|
+
LavendaPay.configure do |config|
|
|
103
|
+
# ...base_url, api_token, webhook_secret
|
|
104
|
+
config.on_event = ->(event) { MyApp::Webhooks::Process.call(event:) } # any callable
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Responses: `200` for handled and unsupported events, `401` for a wrong or missing secret, `422` for an invalid payload.
|
|
109
|
+
Exceptions raised by the handler are not rescued, so the request fails with `500` and the sender retries; make the
|
|
110
|
+
handler idempotent (see `event.idempotency_key`). Without `on_event` configured the endpoint raises a
|
|
111
|
+
`ConfigurationError`.
|
|
112
|
+
|
|
113
|
+
### Your own controller
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
class Webhooks::LavendaPayController < ActionController::API
|
|
117
|
+
def create
|
|
118
|
+
event = LavendaPay::Webhooks::ConstructEvent.call(
|
|
119
|
+
request.raw_post, request.headers[LavendaPay::Webhooks::Signature::HEADER]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return head :ok unless event.supported?
|
|
123
|
+
|
|
124
|
+
if event.order?
|
|
125
|
+
order = Order.find_by!(gateway_order_pid: event.pid)
|
|
126
|
+
order.update!(status: event.data["status"], paid: event.name == "order_paid")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
head :ok
|
|
130
|
+
rescue LavendaPay::InvalidWebhookSignatureError
|
|
131
|
+
head :unauthorized
|
|
132
|
+
rescue LavendaPay::InvalidWebhookPayloadError
|
|
133
|
+
head :unprocessable_entity
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`LavendaPay::Webhooks::VerifySignature.call(secret)` and `LavendaPay::Webhooks::ParseEvent.call(raw_body)` are
|
|
139
|
+
available separately. `event.idempotency_key` (`"order_paid:Order:order_123"`) is stable across redeliveries; use it to de-duplicate.
|
|
140
|
+
Supported events: `order_created`, `order_pending`, `order_partially_paid`, `order_paid`, `order_refund_requested`,
|
|
141
|
+
`order_refunded`, `order_cancelled`, `payment_created`, `payment_processing`, `payment_pending`, `payment_paid`,
|
|
142
|
+
`payment_refused`, `payment_refund_requested`, `payment_refunded`, `payment_partially_refunded`, `payment_canceled`.
|
|
143
|
+
|
|
144
|
+
## Development
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
bundle install
|
|
148
|
+
bundle exec rspec
|
|
149
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
class WebhooksController < ActionController::API
|
|
3
|
+
def create
|
|
4
|
+
event = Webhooks::ConstructEvent.call(
|
|
5
|
+
request.raw_post,
|
|
6
|
+
request.headers[Webhooks::Signature::HEADER]
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
handle(event) if event.supported?
|
|
10
|
+
|
|
11
|
+
head :ok
|
|
12
|
+
rescue InvalidWebhookSignatureError
|
|
13
|
+
head :unauthorized
|
|
14
|
+
rescue InvalidWebhookPayloadError
|
|
15
|
+
head :unprocessable_entity
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# Errors raised by the handler are not rescued on purpose: the request
|
|
21
|
+
# fails with 500 so the sender retries the delivery.
|
|
22
|
+
def handle(event)
|
|
23
|
+
handler = LavendaPay.configuration.on_event
|
|
24
|
+
raise ConfigurationError, "LavendaPay on_event handler is not configured" if handler.nil?
|
|
25
|
+
|
|
26
|
+
handler.call(event)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
# Holds a configuration and the HTTP transport. Pass it to an operation to
|
|
3
|
+
# talk to a different account than the global one:
|
|
4
|
+
#
|
|
5
|
+
# client = LavendaPay::Client.new(base_url: "...", api_token: "...")
|
|
6
|
+
# LavendaPay::Orders::Find.new(client: client).call("order_...")
|
|
7
|
+
class Client
|
|
8
|
+
attr_reader :configuration
|
|
9
|
+
|
|
10
|
+
# Uses the global configuration unless options override it.
|
|
11
|
+
def initialize(**options)
|
|
12
|
+
@configuration = build_configuration(options)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def http = @http ||= HTTP.new(configuration)
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def build_configuration(options)
|
|
20
|
+
return LavendaPay.configuration if options.empty?
|
|
21
|
+
|
|
22
|
+
base = LavendaPay.configuration
|
|
23
|
+
Configuration.new.tap do |config|
|
|
24
|
+
config.base_url = options.fetch(:base_url, base.base_url)
|
|
25
|
+
config.api_token = options.fetch(:api_token, base.api_token)
|
|
26
|
+
config.webhook_secret = options.fetch(:webhook_secret, base.webhook_secret)
|
|
27
|
+
config.open_timeout = options.fetch(:open_timeout, base.open_timeout)
|
|
28
|
+
config.read_timeout = options.fetch(:read_timeout, base.read_timeout)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
class Configuration
|
|
3
|
+
DEFAULT_TIMEOUT = 30
|
|
4
|
+
|
|
5
|
+
attr_writer :base_url, :api_token, :webhook_secret
|
|
6
|
+
attr_accessor :open_timeout, :read_timeout, :logger
|
|
7
|
+
|
|
8
|
+
# Callable receiving a LavendaPay::Webhooks::Event; used by the mounted engine.
|
|
9
|
+
attr_accessor :on_event
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@open_timeout = DEFAULT_TIMEOUT
|
|
13
|
+
@read_timeout = DEFAULT_TIMEOUT
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Root URL of the Lavenda Pay installation, without the `/api` suffix.
|
|
17
|
+
def base_url = @base_url || ENV["LAVENDA_PAY_BASE_URL"]
|
|
18
|
+
|
|
19
|
+
def api_token = @api_token || ENV["LAVENDA_PAY_API_TOKEN"]
|
|
20
|
+
|
|
21
|
+
def webhook_secret = @webhook_secret || ENV["LAVENDA_PAY_WEBHOOK_SECRET"]
|
|
22
|
+
|
|
23
|
+
def validate!
|
|
24
|
+
raise ConfigurationError, "LavendaPay base_url is not configured (LAVENDA_PAY_BASE_URL)" if blank?(base_url)
|
|
25
|
+
raise ConfigurationError, "LavendaPay api_token is not configured (LAVENDA_PAY_API_TOKEN)" if blank?(api_token)
|
|
26
|
+
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def blank?(value) = value.nil? || value.to_s.strip.empty?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
module Customers
|
|
3
|
+
# LavendaPay::Customers::Create.call(
|
|
4
|
+
# full_name: "...", user_email: "...", document_type: "cpf",
|
|
5
|
+
# document_number: "...", dial_code: "55", phone_number: "...", eid: "..."
|
|
6
|
+
# ) # => LavendaPay::Customer
|
|
7
|
+
class Create < Operation
|
|
8
|
+
def call(params)
|
|
9
|
+
body = http.post("/v1/customers", body: {customer: params})
|
|
10
|
+
Customer.new(body["customer"] || body)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
module Customers
|
|
3
|
+
# LavendaPay::Customers::Find.call("cus_...") # => LavendaPay::Customer
|
|
4
|
+
class Find < Operation
|
|
5
|
+
def call(pid)
|
|
6
|
+
body = http.get("/v1/customers/#{escape(pid)}")
|
|
7
|
+
Customer.new(body["customer"] || body)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
module Customers
|
|
3
|
+
# First customer matching the filters, or nil. Returns nil (without hitting
|
|
4
|
+
# the API) when no supported filter is given.
|
|
5
|
+
#
|
|
6
|
+
# LavendaPay::Customers::FindBy.call(document_number: "...")
|
|
7
|
+
class FindBy < Operation
|
|
8
|
+
def call(**filters)
|
|
9
|
+
filters = filters.slice(*FILTER_KEYS).reject { |_, value| value.nil? || value.to_s.strip.empty? }
|
|
10
|
+
return if filters.empty?
|
|
11
|
+
|
|
12
|
+
List.new(client: client).call(**filters).first
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
module Customers
|
|
3
|
+
# Query keys the API filters on. Any other key is ignored server-side and
|
|
4
|
+
# the API would return *every* customer, so we never send them.
|
|
5
|
+
FILTER_KEYS = %i[user_email document_number].freeze
|
|
6
|
+
|
|
7
|
+
# LavendaPay::Customers::List.call(user_email: "...", page: 1)
|
|
8
|
+
# # => [LavendaPay::Customer, ...]
|
|
9
|
+
class List < Operation
|
|
10
|
+
def call(page: nil, **filters)
|
|
11
|
+
body = http.get("/v1/customers", params: {page:}.merge(filters.slice(*FILTER_KEYS)))
|
|
12
|
+
Array(body["customers"]).map { |attributes| Customer.new(attributes) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
class ConfigurationError < Error; end
|
|
5
|
+
|
|
6
|
+
class ConnectionError < Error; end
|
|
7
|
+
|
|
8
|
+
# Raised when the API answers with a non-2xx status.
|
|
9
|
+
class ApiError < Error
|
|
10
|
+
attr_reader :status, :body
|
|
11
|
+
|
|
12
|
+
def initialize(message = nil, status: nil, body: nil)
|
|
13
|
+
@status = status
|
|
14
|
+
@body = body.is_a?(Hash) ? body : {}
|
|
15
|
+
super(message || "Lavenda Pay API responded with #{status}")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Errors as returned by the API (`{"errors" => ...}` or `{"error" => ...}`).
|
|
19
|
+
def errors
|
|
20
|
+
[body["errors"], body["error"]].find { |value| value && !value.empty? } || []
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class BadRequestError < ApiError; end
|
|
25
|
+
|
|
26
|
+
class AuthenticationError < ApiError; end
|
|
27
|
+
|
|
28
|
+
class NotFoundError < ApiError; end
|
|
29
|
+
|
|
30
|
+
class UnprocessableError < ApiError; end
|
|
31
|
+
|
|
32
|
+
class ServerError < ApiError; end
|
|
33
|
+
|
|
34
|
+
class InvalidWebhookSignatureError < Error; end
|
|
35
|
+
|
|
36
|
+
class InvalidWebhookPayloadError < Error; end
|
|
37
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module LavendaPay
|
|
6
|
+
# Minimal JSON-over-HTTP transport built on net/http.
|
|
7
|
+
class HTTP
|
|
8
|
+
ERROR_CLASSES = {
|
|
9
|
+
400 => BadRequestError,
|
|
10
|
+
401 => AuthenticationError,
|
|
11
|
+
403 => AuthenticationError,
|
|
12
|
+
404 => NotFoundError,
|
|
13
|
+
422 => UnprocessableError
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
NETWORK_ERRORS = [
|
|
17
|
+
SocketError, Timeout::Error, EOFError, IOError,
|
|
18
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, OpenSSL::SSL::SSLError
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(configuration)
|
|
22
|
+
@configuration = configuration
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get(path, params: {})
|
|
26
|
+
request(Net::HTTP::Get, path, params:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def post(path, body: nil)
|
|
30
|
+
request(Net::HTTP::Post, path, body:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :configuration
|
|
36
|
+
|
|
37
|
+
def request(verb_class, path, params: {}, body: nil)
|
|
38
|
+
configuration.validate!
|
|
39
|
+
|
|
40
|
+
uri = build_uri(path, params)
|
|
41
|
+
req = verb_class.new(uri, headers)
|
|
42
|
+
req.body = JSON.generate(body) unless body.nil?
|
|
43
|
+
|
|
44
|
+
response = perform(uri, req)
|
|
45
|
+
handle(response)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def perform(uri, req)
|
|
49
|
+
Net::HTTP.start(
|
|
50
|
+
uri.host, uri.port,
|
|
51
|
+
use_ssl: uri.scheme == "https",
|
|
52
|
+
open_timeout: configuration.open_timeout,
|
|
53
|
+
read_timeout: configuration.read_timeout
|
|
54
|
+
) { |http| http.request(req) }
|
|
55
|
+
rescue *NETWORK_ERRORS => error
|
|
56
|
+
raise ConnectionError, "#{error.class}: #{error.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handle(response)
|
|
60
|
+
status = response.code.to_i
|
|
61
|
+
parsed = parse(response.body)
|
|
62
|
+
|
|
63
|
+
return parsed if (200..299).cover?(status)
|
|
64
|
+
|
|
65
|
+
raise error_class(status).new(nil, status:, body: parsed)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def error_class(status)
|
|
69
|
+
ERROR_CLASSES.fetch(status) { (500..599).cover?(status) ? ServerError : ApiError }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse(raw)
|
|
73
|
+
return {} if raw.nil? || raw.strip.empty?
|
|
74
|
+
|
|
75
|
+
JSON.parse(raw)
|
|
76
|
+
rescue JSON::ParserError
|
|
77
|
+
{}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_uri(path, params)
|
|
81
|
+
uri = URI.parse("#{configuration.base_url.to_s.chomp("/")}/api#{path}")
|
|
82
|
+
compact = params.reject { |_, value| value.nil? || value.to_s.empty? }
|
|
83
|
+
uri.query = URI.encode_www_form(compact) unless compact.empty?
|
|
84
|
+
uri
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def headers
|
|
88
|
+
{
|
|
89
|
+
"Content-Type" => "application/json",
|
|
90
|
+
"Accept" => "application/json",
|
|
91
|
+
"Authorization" => "Bearer #{configuration.api_token}"
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
# Base class for every API operation. Each one is invoked as
|
|
3
|
+
# `LavendaPay::Customers::Create.call(params)` using the global
|
|
4
|
+
# configuration, or on an explicit client:
|
|
5
|
+
#
|
|
6
|
+
# LavendaPay::Customers::Create.new(client: client).call(params)
|
|
7
|
+
class Operation
|
|
8
|
+
def self.call(*args, **kwargs) = new.call(*args, **kwargs)
|
|
9
|
+
|
|
10
|
+
def initialize(client: LavendaPay.client)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
attr_reader :client
|
|
17
|
+
|
|
18
|
+
def http = client.http
|
|
19
|
+
|
|
20
|
+
def escape(value) = URI.encode_www_form_component(value.to_s)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
module Orders
|
|
3
|
+
# LavendaPay::Orders::Create.call(
|
|
4
|
+
# customer_pid: "...", payment_method_kinds: ["pix", "credit_card"],
|
|
5
|
+
# description: "...", eid: "ORD-1", frequency: "once", currency: "brl",
|
|
6
|
+
# items_attributes: [{product_title: "...", quantity: 1, unit_price: 450.0}]
|
|
7
|
+
# ) # => LavendaPay::Order
|
|
8
|
+
class Create < Operation
|
|
9
|
+
def call(params)
|
|
10
|
+
body = http.post("/v1/orders", body: {order: params})
|
|
11
|
+
Order.new(body["order"] || body)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
# Thin read-only wrapper over an API payload. Keys are exposed as methods
|
|
3
|
+
# (`customer.pid`) and via `#[]`; the raw payload stays available as `#to_h`.
|
|
4
|
+
class Resource
|
|
5
|
+
attr_reader :attributes
|
|
6
|
+
alias_method :to_h, :attributes
|
|
7
|
+
|
|
8
|
+
def initialize(attributes)
|
|
9
|
+
@attributes = (attributes || {}).transform_keys(&:to_s).freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def [](key) = attributes[key.to_s]
|
|
13
|
+
|
|
14
|
+
def pid = attributes["pid"]
|
|
15
|
+
|
|
16
|
+
def respond_to_missing?(name, include_private = false)
|
|
17
|
+
attributes.key?(name.to_s) || super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def method_missing(name, *args)
|
|
21
|
+
return attributes[name.to_s] if args.empty? && attributes.key?(name.to_s)
|
|
22
|
+
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ==(other) = other.is_a?(self.class) && other.attributes == attributes
|
|
27
|
+
|
|
28
|
+
def inspect = "#<#{self.class.name} #{attributes.inspect}>"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
module Webhooks
|
|
3
|
+
# Checks the secret header, then parses the raw body. For controllers:
|
|
4
|
+
#
|
|
5
|
+
# event = LavendaPay::Webhooks::ConstructEvent.call(
|
|
6
|
+
# request.raw_post, request.headers[LavendaPay::Webhooks::Signature::HEADER]
|
|
7
|
+
# )
|
|
8
|
+
class ConstructEvent
|
|
9
|
+
def self.call(raw_body, received_secret, secret: LavendaPay.configuration.webhook_secret)
|
|
10
|
+
VerifySignature.call(received_secret, secret:)
|
|
11
|
+
ParseEvent.call(raw_body)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
module Webhooks
|
|
3
|
+
# A parsed webhook delivery from Lavenda Pay:
|
|
4
|
+
# {"event" => "order_paid", "resource_type" => "Order",
|
|
5
|
+
# "resource_pid" => "order_123", "data" => {...}}
|
|
6
|
+
class Event
|
|
7
|
+
ORDER_EVENTS = %w[
|
|
8
|
+
order_created order_pending order_partially_paid order_paid
|
|
9
|
+
order_refund_requested order_refunded order_cancelled
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
PAYMENT_EVENTS = %w[
|
|
13
|
+
payment_created payment_processing payment_pending payment_paid
|
|
14
|
+
payment_refused payment_refund_requested payment_refunded
|
|
15
|
+
payment_partially_refunded payment_canceled
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
SUPPORTED_EVENTS = (ORDER_EVENTS + PAYMENT_EVENTS).freeze
|
|
19
|
+
|
|
20
|
+
REQUIRED_KEYS = %w[event resource_type data].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :payload
|
|
23
|
+
|
|
24
|
+
# Raises InvalidWebhookPayloadError when required keys are missing.
|
|
25
|
+
def self.parse(payload)
|
|
26
|
+
payload = JSON.parse(payload) if payload.is_a?(String)
|
|
27
|
+
raise InvalidWebhookPayloadError, "payload must be a JSON object" unless payload.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
payload = payload.transform_keys(&:to_s)
|
|
30
|
+
missing = REQUIRED_KEYS.reject { |key| present?(payload[key]) }
|
|
31
|
+
missing << "resource_pid" unless present?(payload["resource_pid"]) || present?(payload["resource_id"])
|
|
32
|
+
raise InvalidWebhookPayloadError, "missing keys: #{missing.join(", ")}" unless missing.empty?
|
|
33
|
+
|
|
34
|
+
new(payload)
|
|
35
|
+
rescue JSON::ParserError => error
|
|
36
|
+
raise InvalidWebhookPayloadError, "invalid JSON: #{error.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.present?(value) = !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
|
|
40
|
+
|
|
41
|
+
def initialize(payload)
|
|
42
|
+
@payload = payload
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def name = payload["event"]
|
|
46
|
+
|
|
47
|
+
def resource_type = payload["resource_type"]
|
|
48
|
+
|
|
49
|
+
# Lavenda Pay sends `resource_pid`; older consumers used `resource_id`.
|
|
50
|
+
def resource_id = (payload["resource_pid"] || payload["resource_id"]).to_s
|
|
51
|
+
|
|
52
|
+
def data = payload["data"]
|
|
53
|
+
|
|
54
|
+
# pid of the order/payment the event is about.
|
|
55
|
+
def pid = data.is_a?(Hash) ? data["pid"] : nil
|
|
56
|
+
|
|
57
|
+
def supported? = SUPPORTED_EVENTS.include?(name)
|
|
58
|
+
|
|
59
|
+
def order? = name.to_s.start_with?("order_")
|
|
60
|
+
|
|
61
|
+
def payment? = name.to_s.start_with?("payment_")
|
|
62
|
+
|
|
63
|
+
def order = order? && data.is_a?(Hash) ? Order.new(data) : nil
|
|
64
|
+
|
|
65
|
+
# Stable key for de-duplicating deliveries.
|
|
66
|
+
def idempotency_key = [name, resource_type, resource_id].join(":")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module LavendaPay
|
|
4
|
+
module Webhooks
|
|
5
|
+
# Lavenda Pay authenticates deliveries with a shared secret sent in the
|
|
6
|
+
# webhook secret header (see HEADER).
|
|
7
|
+
module Signature
|
|
8
|
+
HEADER = "X-Lavenda-Pay-Webhook-Secret".freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def valid?(received, secret: LavendaPay.configuration.webhook_secret)
|
|
13
|
+
return false if received.nil? || received.empty? || secret.nil? || secret.empty?
|
|
14
|
+
|
|
15
|
+
# Hash both sides so the comparison is constant-time regardless of length.
|
|
16
|
+
OpenSSL.fixed_length_secure_compare(digest(received), digest(secret))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def digest(value) = OpenSSL::Digest::SHA256.digest(value.to_s)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module LavendaPay
|
|
2
|
+
module Webhooks
|
|
3
|
+
# LavendaPay::Webhooks::VerifySignature.call(request.headers[Signature::HEADER])
|
|
4
|
+
# # => true, or raises InvalidWebhookSignatureError
|
|
5
|
+
class VerifySignature
|
|
6
|
+
def self.call(received, secret: LavendaPay.configuration.webhook_secret)
|
|
7
|
+
return true if Signature.valid?(received, secret:)
|
|
8
|
+
|
|
9
|
+
raise InvalidWebhookSignatureError, "invalid webhook secret"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/lavenda_pay.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require_relative "lavenda_pay/version"
|
|
2
|
+
require_relative "lavenda_pay/errors"
|
|
3
|
+
require_relative "lavenda_pay/configuration"
|
|
4
|
+
require_relative "lavenda_pay/http"
|
|
5
|
+
require_relative "lavenda_pay/resource"
|
|
6
|
+
require_relative "lavenda_pay/resources/customer"
|
|
7
|
+
require_relative "lavenda_pay/resources/order"
|
|
8
|
+
require_relative "lavenda_pay/client"
|
|
9
|
+
require_relative "lavenda_pay/operation"
|
|
10
|
+
require_relative "lavenda_pay/customers/create"
|
|
11
|
+
require_relative "lavenda_pay/customers/find"
|
|
12
|
+
require_relative "lavenda_pay/customers/list"
|
|
13
|
+
require_relative "lavenda_pay/customers/find_by"
|
|
14
|
+
require_relative "lavenda_pay/orders/create"
|
|
15
|
+
require_relative "lavenda_pay/orders/find"
|
|
16
|
+
require_relative "lavenda_pay/webhooks"
|
|
17
|
+
require_relative "lavenda_pay/engine" if defined?(Rails::Engine)
|
|
18
|
+
|
|
19
|
+
module LavendaPay
|
|
20
|
+
class << self
|
|
21
|
+
def configuration = @configuration ||= Configuration.new
|
|
22
|
+
|
|
23
|
+
def configure
|
|
24
|
+
yield configuration
|
|
25
|
+
configuration
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reset_configuration! = @configuration = nil
|
|
29
|
+
|
|
30
|
+
# Client bound to the global configuration.
|
|
31
|
+
def client = Client.new
|
|
32
|
+
end
|
|
33
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lavenda-pay-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- La Venda Software
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionpack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: railties
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rack-test
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.1'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rspec
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.13'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.13'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: webmock
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '3.23'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.23'
|
|
96
|
+
description: Create customers and orders on Lavenda Pay and verify/parse its webhooks,
|
|
97
|
+
without depending on Rails.
|
|
98
|
+
executables: []
|
|
99
|
+
extensions: []
|
|
100
|
+
extra_rdoc_files: []
|
|
101
|
+
files:
|
|
102
|
+
- CHANGELOG.md
|
|
103
|
+
- LICENSE.txt
|
|
104
|
+
- README.md
|
|
105
|
+
- app/controllers/lavenda_pay/webhooks_controller.rb
|
|
106
|
+
- config/routes.rb
|
|
107
|
+
- lib/lavenda-pay-ruby.rb
|
|
108
|
+
- lib/lavenda_pay.rb
|
|
109
|
+
- lib/lavenda_pay/client.rb
|
|
110
|
+
- lib/lavenda_pay/configuration.rb
|
|
111
|
+
- lib/lavenda_pay/customers/create.rb
|
|
112
|
+
- lib/lavenda_pay/customers/find.rb
|
|
113
|
+
- lib/lavenda_pay/customers/find_by.rb
|
|
114
|
+
- lib/lavenda_pay/customers/list.rb
|
|
115
|
+
- lib/lavenda_pay/engine.rb
|
|
116
|
+
- lib/lavenda_pay/errors.rb
|
|
117
|
+
- lib/lavenda_pay/http.rb
|
|
118
|
+
- lib/lavenda_pay/operation.rb
|
|
119
|
+
- lib/lavenda_pay/orders/create.rb
|
|
120
|
+
- lib/lavenda_pay/orders/find.rb
|
|
121
|
+
- lib/lavenda_pay/resource.rb
|
|
122
|
+
- lib/lavenda_pay/resources/customer.rb
|
|
123
|
+
- lib/lavenda_pay/resources/order.rb
|
|
124
|
+
- lib/lavenda_pay/version.rb
|
|
125
|
+
- lib/lavenda_pay/webhooks.rb
|
|
126
|
+
- lib/lavenda_pay/webhooks/construct_event.rb
|
|
127
|
+
- lib/lavenda_pay/webhooks/event.rb
|
|
128
|
+
- lib/lavenda_pay/webhooks/parse_event.rb
|
|
129
|
+
- lib/lavenda_pay/webhooks/signature.rb
|
|
130
|
+
- lib/lavenda_pay/webhooks/verify_signature.rb
|
|
131
|
+
homepage: https://github.com/LaVendaSoftware/pay-ruby
|
|
132
|
+
licenses:
|
|
133
|
+
- MIT
|
|
134
|
+
metadata:
|
|
135
|
+
homepage_uri: https://github.com/LaVendaSoftware/pay-ruby
|
|
136
|
+
source_code_uri: https://github.com/LaVendaSoftware/pay-ruby
|
|
137
|
+
changelog_uri: https://github.com/LaVendaSoftware/pay-ruby/blob/main/CHANGELOG.md
|
|
138
|
+
rubygems_mfa_required: 'true'
|
|
139
|
+
rdoc_options: []
|
|
140
|
+
require_paths:
|
|
141
|
+
- lib
|
|
142
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
143
|
+
requirements:
|
|
144
|
+
- - ">="
|
|
145
|
+
- !ruby/object:Gem::Version
|
|
146
|
+
version: '3.2'
|
|
147
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - ">="
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '0'
|
|
152
|
+
requirements: []
|
|
153
|
+
rubygems_version: 4.0.13
|
|
154
|
+
specification_version: 4
|
|
155
|
+
summary: Ruby client for the Lavenda Pay payments API.
|
|
156
|
+
test_files: []
|