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 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
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release: customers, orders and webhook handling extracted from the
6
+ Catavento and Lavenda Store adapters.
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,3 @@
1
+ LavendaPay::Engine.routes.draw do
2
+ post "/", to: "webhooks#create"
3
+ end
@@ -0,0 +1,2 @@
1
+ # Bundler requires a gem by its name; the code lives under `lavenda_pay`.
2
+ require "lavenda_pay"
@@ -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,10 @@
1
+ require "rails/engine"
2
+
3
+ module LavendaPay
4
+ # Mount it to get a webhook endpoint without writing a controller:
5
+ #
6
+ # mount LavendaPay::Engine => "/webhooks/lavenda_pay"
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace LavendaPay
9
+ end
10
+ 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,11 @@
1
+ module LavendaPay
2
+ module Orders
3
+ # LavendaPay::Orders::Find.call("order_...") # => LavendaPay::Order
4
+ class Find < Operation
5
+ def call(pid)
6
+ body = http.get("/v1/orders/#{escape(pid)}")
7
+ Order.new(body["order"] || body)
8
+ end
9
+ end
10
+ end
11
+ 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,6 @@
1
+ module LavendaPay
2
+ class Customer < Resource
3
+ # Attributes: pid, full_name, user_email, document_type, document_number,
4
+ # dial_code, phone_number.
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ module LavendaPay
2
+ class Order < Resource
3
+ # Attributes: pid, status, expires_at, requires_billing_address, customer.
4
+ def customer
5
+ value = self["customer"]
6
+ value.is_a?(Hash) ? Customer.new(value) : value
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ module LavendaPay
2
+ VERSION = "0.0.1"
3
+ 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,9 @@
1
+ module LavendaPay
2
+ module Webhooks
3
+ # LavendaPay::Webhooks::ParseEvent.call(request.raw_post) # => Event
4
+ # Raises InvalidWebhookPayloadError for bad JSON or missing keys.
5
+ class ParseEvent
6
+ def self.call(payload) = Event.parse(payload)
7
+ end
8
+ end
9
+ 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
@@ -0,0 +1,5 @@
1
+ require_relative "webhooks/event"
2
+ require_relative "webhooks/signature"
3
+ require_relative "webhooks/verify_signature"
4
+ require_relative "webhooks/parse_event"
5
+ require_relative "webhooks/construct_event"
@@ -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: []