fiscalrail 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ class Model
5
+ attr_reader :extra_fields, :request_id, :idempotent_replayed, :idempotency_key
6
+
7
+ def initialize(attributes, extra_fields: {}, metadata: {})
8
+ @attributes = self.class.deep_freeze(attributes)
9
+ @extra_fields = self.class.deep_freeze(extra_fields)
10
+ @request_id = metadata[:request_id]&.dup&.freeze
11
+ @idempotent_replayed = metadata[:idempotent_replayed]&.dup&.freeze
12
+ @idempotency_key = metadata[:idempotency_key]&.dup&.freeze
13
+ freeze
14
+ end
15
+
16
+ def [](key)
17
+ key = key.to_s
18
+ @attributes.key?(key) ? @attributes[key] : @extra_fields[key]
19
+ end
20
+
21
+ # Unknown fields remain accessible without defining methods on the class.
22
+ def method_missing(name, *args, **kwargs)
23
+ return @extra_fields[name.to_s] if args.empty? && kwargs.empty? && @extra_fields.key?(name.to_s)
24
+
25
+ super
26
+ end
27
+
28
+ def respond_to_missing?(name, include_private = false)
29
+ @extra_fields.key?(name.to_s) || super
30
+ end
31
+
32
+ def to_h
33
+ self.class.unwrap(@extra_fields.merge(@attributes))
34
+ end
35
+
36
+ def inspect
37
+ "#<#{self.class.name}#{self['id'] ? " id=#{self['id'].inspect}" : ''}>"
38
+ end
39
+
40
+ def self.unwrap(value)
41
+ case value
42
+ when Model then value.to_h
43
+ when Hash then value.transform_values { |item| unwrap(item) }
44
+ when Array then value.map { |item| unwrap(item) }
45
+ else value
46
+ end
47
+ end
48
+
49
+ def self.deep_freeze(value)
50
+ case value
51
+ when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
52
+ when Array then value.each { |item| deep_freeze(item) }
53
+ end
54
+ value.freeze
55
+ end
56
+ end
57
+
58
+ class Page < Model
59
+ include Enumerable
60
+
61
+ def each(&block)
62
+ return enum_for(:each) unless block
63
+
64
+ self["data"].each(&block)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module FiscalRail
6
+ class Resource
7
+ def initialize(transport)
8
+ @transport = transport
9
+ end
10
+
11
+ private
12
+
13
+ def request(operation_id, path: {}, params: {}, body: nil, retry_safe: false, idempotency_key: nil, headers: {}, binary: false)
14
+ operation = Generated::OPERATIONS.fetch(operation_id)
15
+ route = operation.fetch("path").gsub(/\{([^}]+)\}/) do
16
+ value = path.fetch(Regexp.last_match(1).to_sym)
17
+ raise ArgumentError, "Path identifiers must be nonempty strings" unless value.is_a?(String) && !value.empty?
18
+
19
+ URI.encode_www_form_component(value).gsub("+", "%20")
20
+ end
21
+ headers = headers.merge("Accept" => "application/pdf") if binary
22
+ response = @transport.request(method: operation.fetch("method"), path: route, params: params,
23
+ body: body, retry_safe: retry_safe, idempotency_key: idempotency_key, headers: headers)
24
+ metadata = { request_id: response.headers["request-id"], idempotent_replayed: response.headers["idempotent-replayed"], idempotency_key: idempotency_key }
25
+ unless operation.fetch("responses").key?(response.status.to_s)
26
+ raise ResponseParseError.new("unexpected HTTP #{response.status}", model: operation_id, field: "$", **metadata.slice(:request_id, :idempotency_key))
27
+ end
28
+ return BinaryContent.new(response) if binary
29
+
30
+ schema = operation.fetch("responses").fetch(response.status.to_s)
31
+ return nil unless schema
32
+
33
+ Decoder.decode(schema, JSON.parse(response.body), metadata: metadata)
34
+ rescue JSON::ParserError => error
35
+ raise ResponseParseError.new("invalid JSON", model: operation_id, field: "$", **metadata.slice(:request_id, :idempotency_key)), cause: error
36
+ end
37
+
38
+ def request_key(key)
39
+ return SecureRandom.uuid if key.nil?
40
+ raise ArgumentError, "idempotency_key must be a nonempty string of at most 255 bytes" unless key.is_a?(String) && key.bytesize.between?(1, 255)
41
+
42
+ key
43
+ end
44
+ end
45
+
46
+ module AutoPagination
47
+ # Iteration always proceeds forward; reverse cursors belong to #list.
48
+ def auto_paging_each(page_size: 100, **filters)
49
+ return enum_for(__method__, page_size: page_size, **filters) unless block_given?
50
+ raise ArgumentError, "Use list for explicit pagination cursors" unless (filters.keys & %i[limit starting_after ending_before]).empty?
51
+
52
+ cursor = nil
53
+ loop do
54
+ page = list(**filters, limit: page_size, starting_after: cursor)
55
+ page.each { |item| yield item }
56
+ break unless page.has_more && !page.data.empty?
57
+
58
+ next_cursor = page.data.last.id
59
+ raise ResponseParseError.new("pagination cursor did not advance", model: "Page", field: "$.data", request_id: page.request_id) if next_cursor == cursor
60
+
61
+ cursor = next_cursor
62
+ end
63
+ nil
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ module Resources
5
+ class Accounts < Resource
6
+ def list
7
+ request("listAccounts", retry_safe: true)
8
+ end
9
+
10
+ def retrieve(account_id)
11
+ request("retrieveAccount", path: { id: account_id }, retry_safe: true)
12
+ end
13
+
14
+ def update(account_id, **params)
15
+ request("updateAccount", path: { id: account_id }, body: params)
16
+ end
17
+ end
18
+
19
+ class Balances < Resource
20
+ def retrieve(account_id)
21
+ request("retrieveBalance", path: { account_id: account_id }, retry_safe: true)
22
+ end
23
+ end
24
+
25
+ class AccountTaxRegimes < Resource
26
+ def retrieve(account_id)
27
+ request("retrieveAccountTaxRegime", path: { account_id: account_id }, retry_safe: true)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ module Resources
5
+ class ApiKeys < Resource
6
+ include AutoPagination
7
+
8
+ def create(**params)
9
+ request("createApiKey", body: params)
10
+ end
11
+
12
+ def retrieve(api_key_id)
13
+ request("retrieveApiKey", path: { id: api_key_id }, retry_safe: true)
14
+ end
15
+
16
+ def delete(api_key_id)
17
+ request("deleteApiKey", path: { id: api_key_id })
18
+ end
19
+
20
+ def list(limit: nil, starting_after: nil, ending_before: nil)
21
+ request("listApiKeys", params: { limit: limit, starting_after: starting_after, ending_before: ending_before }, retry_safe: true)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ module Resources
5
+ class Customers < Resource
6
+ include AutoPagination
7
+
8
+ def create(**params)
9
+ request("createCustomer", body: params)
10
+ end
11
+
12
+ def retrieve(customer_id)
13
+ request("retrieveCustomer", path: { id: customer_id }, retry_safe: true)
14
+ end
15
+
16
+ def update(customer_id, **params)
17
+ request("updateCustomer", path: { id: customer_id }, body: params)
18
+ end
19
+
20
+ def delete(customer_id)
21
+ request("deleteCustomer", path: { id: customer_id })
22
+ end
23
+
24
+ def list(q: nil, country: nil, limit: nil, starting_after: nil, ending_before: nil)
25
+ request("listCustomers", params: { q: q, country: country, limit: limit, starting_after: starting_after, ending_before: ending_before }, retry_safe: true)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ module Resources
5
+ class EventDestinations < Resource
6
+ include AutoPagination
7
+
8
+ def create(**params)
9
+ request("createEventDestination", body: params)
10
+ end
11
+
12
+ def retrieve(destination_id)
13
+ request("retrieveEventDestination", path: { id: destination_id }, retry_safe: true)
14
+ end
15
+
16
+ def update(destination_id, **params)
17
+ request("updateEventDestination", path: { id: destination_id }, body: params)
18
+ end
19
+
20
+ def delete(destination_id)
21
+ request("deleteEventDestination", path: { id: destination_id })
22
+ end
23
+
24
+ def enable(destination_id)
25
+ request("enableEventDestination", path: { id: destination_id }, retry_safe: true)
26
+ end
27
+
28
+ def disable(destination_id)
29
+ request("disableEventDestination", path: { id: destination_id }, retry_safe: true)
30
+ end
31
+
32
+ def list(limit: nil, starting_after: nil, ending_before: nil)
33
+ request("listEventDestinations", params: { limit: limit, starting_after: starting_after, ending_before: ending_before }, retry_safe: true)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ module Resources
5
+ class Events < Resource
6
+ include AutoPagination
7
+
8
+ def retrieve(event_id)
9
+ request("retrieveEvent", path: { id: event_id }, retry_safe: true)
10
+ end
11
+
12
+ def list(types: nil, limit: nil, starting_after: nil, ending_before: nil)
13
+ request("listEvents", params: { types: types&.join(","), limit: limit, starting_after: starting_after, ending_before: ending_before }, retry_safe: true)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ module Resources
5
+ class InvoicePdfs < Resource
6
+ def retrieve(invoice_id)
7
+ request("retrieveInvoicePdf", path: { invoice_id: invoice_id }, retry_safe: true)
8
+ end
9
+
10
+ def retrieve_content(invoice_id)
11
+ request("retrieveInvoicePdf", path: { invoice_id: invoice_id }, retry_safe: true, binary: true)
12
+ end
13
+
14
+ def render(invoice_id, locale: nil)
15
+ request("renderInvoicePdf", path: { invoice_id: invoice_id }, headers: locale ? { "Accept-Language" => locale } : {}, retry_safe: true)
16
+ end
17
+
18
+ def render_content(invoice_id, locale: nil)
19
+ request("renderInvoicePdf", path: { invoice_id: invoice_id }, headers: locale ? { "Accept-Language" => locale } : {}, retry_safe: true, binary: true)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ module Resources
5
+ class InvoiceSeries < Resource
6
+ include AutoPagination
7
+
8
+ def create(**params)
9
+ request("createInvoiceSeries", body: params)
10
+ end
11
+
12
+ def retrieve(series_id)
13
+ request("retrieveInvoiceSeries", path: { id: series_id }, retry_safe: true)
14
+ end
15
+
16
+ def update(series_id, **params)
17
+ request("updateInvoiceSeries", path: { id: series_id }, body: params)
18
+ end
19
+
20
+ def delete(series_id)
21
+ request("deleteInvoiceSeries", path: { id: series_id })
22
+ end
23
+
24
+ def list(limit: nil, starting_after: nil, ending_before: nil)
25
+ request("listInvoiceSeries", params: { limit: limit, starting_after: starting_after, ending_before: ending_before }, retry_safe: true)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ module Resources
5
+ class Invoices < Resource
6
+ include AutoPagination
7
+
8
+ def issue(idempotency_key: nil, **params)
9
+ request("issueInvoice", body: params, retry_safe: true, idempotency_key: request_key(idempotency_key))
10
+ end
11
+
12
+ def retrieve(invoice_id)
13
+ request("retrieveInvoice", path: { id: invoice_id }, retry_safe: true)
14
+ end
15
+
16
+ def amend(invoice_id, reason:, replacement: nil, idempotency_key: nil)
17
+ body = { reason: reason }
18
+ body[:replacement] = replacement unless replacement.nil?
19
+ request("amendInvoice", path: { invoice_id: invoice_id }, body: body,
20
+ retry_safe: true, idempotency_key: request_key(idempotency_key))
21
+ end
22
+
23
+ def list(q: nil, customer: nil, issue_date_from: nil, issue_date_to: nil, limit: nil, starting_after: nil, ending_before: nil)
24
+ request("listInvoices", params: { q: q, customer: customer, issue_date_from: issue_date_from, issue_date_to: issue_date_to,
25
+ limit: limit, starting_after: starting_after, ending_before: ending_before }, retry_safe: true)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ module Resources
5
+ class PaymentInstructions < Resource
6
+ include AutoPagination
7
+
8
+ def create(**params)
9
+ request("createPaymentInstruction", body: params)
10
+ end
11
+
12
+ def retrieve(instruction_id)
13
+ request("retrievePaymentInstruction", path: { id: instruction_id }, retry_safe: true)
14
+ end
15
+
16
+ def update(instruction_id, **params)
17
+ request("updatePaymentInstruction", path: { id: instruction_id }, body: params)
18
+ end
19
+
20
+ def delete(instruction_id)
21
+ request("deletePaymentInstruction", path: { id: instruction_id })
22
+ end
23
+
24
+ def list(limit: nil, starting_after: nil, ending_before: nil)
25
+ request("listPaymentInstructions", params: { limit: limit, starting_after: starting_after, ending_before: ending_before }, retry_safe: true)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ module Resources
5
+ class TaxRegimes < Resource
6
+ def list
7
+ request("listTaxRegimes", retry_safe: true)
8
+ end
9
+
10
+ def retrieve(regime_id)
11
+ request("retrieveTaxRegime", path: { id: regime_id }, retry_safe: true)
12
+ end
13
+ end
14
+
15
+ class TaxIds < Resource
16
+ def retrieve(tax_id)
17
+ request("retrieveTaxId", path: { id: tax_id }, retry_safe: true)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+ require "time"
6
+
7
+ module FiscalRail
8
+ module Serialization
9
+ module_function
10
+
11
+ def json_value(value)
12
+ case value
13
+ when BigDecimal
14
+ raise ArgumentError, "Decimal must be finite" unless value.finite?
15
+
16
+ value.to_s("F")
17
+ when Time, DateTime then value.iso8601
18
+ when Date then value.iso8601
19
+ when Model then json_value(value.to_h)
20
+ when Hash
21
+ value.each_with_object({}) do |(key, item), result|
22
+ raise ArgumentError, "Duplicate JSON key: #{key}" if result.key?(key.to_s)
23
+
24
+ result[key.to_s] = json_value(item)
25
+ end
26
+ when Array then value.map { |item| json_value(item) }
27
+ when Symbol then value.to_s
28
+ when String, Integer, Float, TrueClass, FalseClass, NilClass then value
29
+ else raise ArgumentError, "Cannot serialize #{value.class} to JSON"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ module TaxRegimes
5
+ module ES
6
+ module VAT
7
+ def self.general = { tax: "vat", rule: "general" }.freeze
8
+ def self.reduced = { tax: "vat", rule: "reduced" }.freeze
9
+ def self.super_reduced = { tax: "vat", rule: "super_reduced" }.freeze
10
+ def self.exempt_intra_eu_goods = { tax: "vat", rule: "exempt_intra_eu_goods" }.freeze
11
+ def self.not_subject_place_of_supply = { tax: "vat", rule: "not_subject_place_of_supply" }.freeze
12
+ end
13
+
14
+ module IRPF
15
+ def self.professionals = { tax: "irpf", rule: "professionals" }.freeze
16
+ def self.new_professionals = { tax: "irpf", rule: "new_professionals" }.freeze
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "thread"
7
+
8
+ module FiscalRail
9
+ # The adapter boundary also makes custom proxy/TLS/instrumentation possible.
10
+ # Adapters return Response and implement #call(method:, uri:, headers:, body:).
11
+ class NetHTTPAdapter
12
+ Response = Struct.new(:status, :headers, :body, keyword_init: true)
13
+
14
+ def initialize(open_timeout:, read_timeout:, write_timeout:)
15
+ @open_timeout, @read_timeout, @write_timeout = open_timeout, read_timeout, write_timeout
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def call(method:, uri:, headers:, body:)
20
+ @mutex.synchronize do
21
+ origin = [Process.pid, uri.scheme, uri.host, uri.port]
22
+ if @origin != origin || !@http&.started?
23
+ disconnect
24
+ @http = Net::HTTP.new(uri.host, uri.port)
25
+ @http.use_ssl = uri.scheme == "https"
26
+ @http.open_timeout = @open_timeout
27
+ @http.read_timeout = @read_timeout
28
+ @http.write_timeout = @write_timeout
29
+ @http.max_retries = 0 # The SDK alone decides which operations are safe.
30
+ @http.start
31
+ @origin = origin
32
+ end
33
+ request = Net::HTTPGenericRequest.new(method, !body.nil?, method != "HEAD", uri.request_uri, headers)
34
+ request.body = body if body
35
+ result = @http.request(request)
36
+ Response.new(status: result.code.to_i, headers: result.each_header.to_h, body: result.body || "")
37
+ rescue IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::ProtocolError
38
+ disconnect
39
+ raise
40
+ end
41
+ end
42
+
43
+ def close
44
+ @mutex.synchronize { disconnect }
45
+ end
46
+
47
+ private
48
+
49
+ def disconnect
50
+ @http.finish if @http&.started?
51
+ rescue IOError, SystemCallError
52
+ nil
53
+ ensure
54
+ @http = nil
55
+ @origin = nil
56
+ end
57
+ end
58
+
59
+ class Transport
60
+ CONNECTION_ERRORS = [IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::ProtocolError].freeze
61
+
62
+ def initialize(api_key:, base_url:, max_retries:, adapter:, sleeper: Kernel.method(:sleep))
63
+ @api_key, @base_url, @max_retries, @adapter, @sleeper = api_key, base_url.sub(%r{/+\z}, ""), max_retries, adapter, sleeper
64
+ end
65
+
66
+ def request(method:, path:, body: nil, params: {}, headers: {}, retry_safe:, idempotency_key: nil)
67
+ uri = URI("#{@base_url}#{path}")
68
+ query = Serialization.json_value(params.reject { |_, value| value.nil? })
69
+ uri.query = URI.encode_www_form(query) unless query.empty?
70
+ request_headers = {
71
+ "Authorization" => "Bearer #{@api_key}", "Accept" => "application/json",
72
+ "User-Agent" => "fiscalrail-ruby/#{VERSION}"
73
+ }.merge(headers)
74
+ request_headers["Idempotency-Key"] = idempotency_key if idempotency_key
75
+ request_headers["Content-Type"] = "application/json" unless body.nil?
76
+ encoded = body.nil? ? nil : JSON.generate(Serialization.json_value(body))
77
+ attempt = 0
78
+ loop do
79
+ begin
80
+ response = @adapter.call(method: method, uri: uri, headers: request_headers, body: encoded)
81
+ rescue Timeout::Error => error
82
+ if retry_safe && attempt < @max_retries
83
+ wait(attempt)
84
+ attempt += 1
85
+ next
86
+ end
87
+ raise APITimeoutError.new("Request to FiscalRail timed out", idempotency_key: idempotency_key), cause: error
88
+ rescue *CONNECTION_ERRORS => error
89
+ if retry_safe && attempt < @max_retries
90
+ wait(attempt)
91
+ attempt += 1
92
+ next
93
+ end
94
+ raise APIConnectionError.new("Could not connect to FiscalRail", idempotency_key: idempotency_key), cause: error
95
+ end
96
+ response.headers = response.headers.transform_keys { |key| key.to_s.downcase }
97
+ return response if response.status.between?(200, 299)
98
+
99
+ if retry_safe && attempt < @max_retries && ([408, 429].include?(response.status) || response.status >= 500)
100
+ wait(attempt, response.headers["retry-after"])
101
+ attempt += 1
102
+ next
103
+ end
104
+ raise Errors.from_response(status: response.status, headers: response.headers, body: response.body, idempotency_key: idempotency_key)
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def wait(attempt, retry_after = nil)
111
+ delay = begin
112
+ Float(retry_after) if retry_after
113
+ rescue ArgumentError, TypeError
114
+ begin
115
+ Time.httpdate(retry_after) - Time.now
116
+ rescue ArgumentError
117
+ nil
118
+ end
119
+ end
120
+ delay = 0.25 * (2**attempt) unless delay&.finite?
121
+ @sleeper.call([[delay, 0].max, 30].min)
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ VERSION = "0.4.0"
5
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+ require_relative "errors"
6
+
7
+ module FiscalRail
8
+ module Webhooks
9
+ DEFAULT_TOLERANCE = 300
10
+ module_function
11
+
12
+ def verify_signature(payload, signature, secret, tolerance: DEFAULT_TOLERANCE, now: Time.now)
13
+ raise ArgumentError, "tolerance must be nonnegative or nil" unless tolerance.nil? || (tolerance.is_a?(Numeric) && tolerance.finite? && tolerance >= 0)
14
+ raise WebhookSignatureError, "Missing signing secret" unless secret.is_a?(String) && !secret.empty?
15
+ raise WebhookSignatureError, "Payload must be a raw string" unless payload.is_a?(String)
16
+ raise WebhookSignatureError, "Missing FiscalRail-Signature header" unless signature.is_a?(String)
17
+
18
+ fields = signature.split(",").map { |item| item.strip.split("=", 2) }
19
+ timestamps = fields.select { |key, _| key == "t" }.map(&:last)
20
+ signatures = fields.select { |key, _| key == "v1" }.map(&:last)
21
+ unless timestamps.length == 1 && timestamps.first&.match?(/\A\d+\z/) && !signatures.empty?
22
+ raise WebhookSignatureError, "Malformed FiscalRail-Signature header"
23
+ end
24
+ timestamp = Integer(timestamps.first, 10)
25
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.".b + payload.b)
26
+ valid = signatures.any? do |candidate|
27
+ candidate && candidate.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(expected, candidate)
28
+ end
29
+ raise WebhookSignatureError, "No matching FiscalRail webhook signature" unless valid
30
+ raise WebhookSignatureError, "FiscalRail webhook timestamp is outside the allowed tolerance" if tolerance && (now.to_f - timestamp).abs > tolerance
31
+
32
+ timestamp
33
+ end
34
+
35
+ def construct_event(payload, signature, secret, **options)
36
+ verify_signature(payload, signature, secret, **options)
37
+ event = JSON.parse(payload)
38
+ raise WebhookSignatureError, "FiscalRail webhook payload must be a JSON object" unless event.is_a?(Hash)
39
+
40
+ event
41
+ rescue JSON::ParserError
42
+ raise WebhookSignatureError, "FiscalRail webhook payload is not valid JSON"
43
+ end
44
+ end
45
+ end
data/lib/fiscalrail.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fiscalrail/version"
4
+ require_relative "fiscalrail/errors"
5
+ require_relative "fiscalrail/model"
6
+ require_relative "fiscalrail/serialization"
7
+ require_relative "fiscalrail/generated/contract"
8
+ require_relative "fiscalrail/generated/models"
9
+ require_relative "fiscalrail/decoder"
10
+ require_relative "fiscalrail/binary_content"
11
+ require_relative "fiscalrail/transport"
12
+ require_relative "fiscalrail/resource"
13
+ require_relative "fiscalrail/resources/accounts"
14
+ require_relative "fiscalrail/resources/api_keys"
15
+ require_relative "fiscalrail/resources/customers"
16
+ require_relative "fiscalrail/resources/event_destinations"
17
+ require_relative "fiscalrail/resources/events"
18
+ require_relative "fiscalrail/resources/invoice_series"
19
+ require_relative "fiscalrail/resources/invoices"
20
+ require_relative "fiscalrail/resources/invoice_pdfs"
21
+ require_relative "fiscalrail/resources/payment_instructions"
22
+ require_relative "fiscalrail/resources/tax_regimes"
23
+ require_relative "fiscalrail/client"
24
+ require_relative "fiscalrail/tax_regimes/es"
25
+ require_relative "fiscalrail/webhooks"