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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +231 -0
- data/lib/fiscalrail/binary_content.rb +18 -0
- data/lib/fiscalrail/client.rb +57 -0
- data/lib/fiscalrail/decoder.rb +103 -0
- data/lib/fiscalrail/errors.rb +90 -0
- data/lib/fiscalrail/generated/contract.rb +3586 -0
- data/lib/fiscalrail/generated/models.rb +724 -0
- data/lib/fiscalrail/model.rb +67 -0
- data/lib/fiscalrail/resource.rb +66 -0
- data/lib/fiscalrail/resources/accounts.rb +31 -0
- data/lib/fiscalrail/resources/api_keys.rb +25 -0
- data/lib/fiscalrail/resources/customers.rb +29 -0
- data/lib/fiscalrail/resources/event_destinations.rb +37 -0
- data/lib/fiscalrail/resources/events.rb +17 -0
- data/lib/fiscalrail/resources/invoice_pdfs.rb +23 -0
- data/lib/fiscalrail/resources/invoice_series.rb +29 -0
- data/lib/fiscalrail/resources/invoices.rb +29 -0
- data/lib/fiscalrail/resources/payment_instructions.rb +29 -0
- data/lib/fiscalrail/resources/tax_regimes.rb +21 -0
- data/lib/fiscalrail/serialization.rb +33 -0
- data/lib/fiscalrail/tax_regimes/es.rb +20 -0
- data/lib/fiscalrail/transport.rb +124 -0
- data/lib/fiscalrail/version.rb +5 -0
- data/lib/fiscalrail/webhooks.rb +45 -0
- data/lib/fiscalrail.rb +25 -0
- metadata +110 -0
|
@@ -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,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"
|