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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d2a0b93b946f75e4d2733bafaea87dee185f6b7abf5db7c60b7d73864472d63f
4
+ data.tar.gz: 40ec395e28ee22197bdeb479517022bdf4894c3b89b721fd80a9db8c693309cf
5
+ SHA512:
6
+ metadata.gz: c4b48066bb84842295979af02f44e7e002fadf22c88bb73252684f748bb71ac82ec3867673bc8c3227a2b18ac7e1b22df98f0ded6c180bf824550e6bb745f5ff
7
+ data.tar.gz: 8fff2a61e27c474e4ae2c43fb688d3c5aac67212dda270ac332b6c112a256fb0844a248117801798700804c1236437cbaeeb0229fce437f2c13b95cb1b9fbf38
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 — 2026-09-07
4
+
5
+ - Start at 0.4.0 to match the Python SDK release with full resource coverage.
6
+ - Initial Ruby SDK covering all 42 operations in the current FiscalRail OpenAPI contract.
7
+ - Handwritten resources with generated response models and operation metadata.
8
+ - Precise decimals, date/time decoding, deeply frozen responses and unknown-field preservation.
9
+ - Explicit client configuration, persistent HTTP connections, safe retries and invoice idempotency.
10
+ - Lazy pagination, PDF metadata/downloads, webhook verification and Spanish tax conveniences.
11
+ - Standalone tests, optional Rails integration verification and gem CI.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FiscalRail
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,231 @@
1
+ # FiscalRail Ruby SDK
2
+
3
+ A Ruby client for issuing immutable invoices through FiscalRail. Ruby 3.3 or later; no Rails dependency.
4
+
5
+ Install with Bundler:
6
+
7
+ ```ruby
8
+ gem "fiscalrail", "~> 0.4.0"
9
+ ```
10
+
11
+ ## Issue an invoice
12
+
13
+ ```ruby
14
+ require "fiscalrail"
15
+
16
+ client = FiscalRail::Client.new(api_key: ENV.fetch("FISCALRAIL_API_KEY"))
17
+
18
+ invoice = client.invoices.issue(
19
+ customer: "cus_...",
20
+ lines: [
21
+ {
22
+ description: "Consulting services",
23
+ unit_price: BigDecimal("2500.00"),
24
+ taxes: [
25
+ FiscalRail::TaxRegimes::ES::VAT.general,
26
+ FiscalRail::TaxRegimes::ES::IRPF.professionals
27
+ ]
28
+ }
29
+ ]
30
+ )
31
+
32
+ client.invoice_pdfs
33
+ .render_content(invoice.id, locale: "en")
34
+ .write_to_file("#{invoice.code}.pdf")
35
+
36
+ client.close
37
+ ```
38
+
39
+ The API key is explicit: the SDK never reads process configuration. The key selects the Test or Live account; there is no separate environment switch.
40
+
41
+ Requests use ordinary keyword arguments and nested hashes. Both symbol and string keys work in nested hashes. Use `BigDecimal` or strings for precise monetary inputs. `Date` and `Time` are serialized as ISO 8601 strings. Omitted body fields stay omitted; explicit `nil`, `false` and empty arrays are preserved. Business validation stays on the API.
42
+
43
+ ## Responses
44
+
45
+ Responses are generated, read-only objects under `FiscalRail::Models`:
46
+
47
+ ```ruby
48
+ invoice.id # String
49
+ invoice.issue_date # Date
50
+ invoice.created_at # Time
51
+ invoice.totals.payable # BigDecimal
52
+ invoice.request_id # Request-Id header
53
+ invoice.idempotency_key # Key used to issue this invoice
54
+ invoice.idempotent_replayed # Original request ID, or nil
55
+ invoice.to_h # Nested hashes with string keys and Ruby values
56
+ ```
57
+
58
+ Unknown response fields are retained in `extra_fields`, accessible through `response["field"]` and, when the name does not collide with an existing method, `response.field`. Nested arrays, hashes and values are frozen. `to_h` creates fresh containers. Unknown enum strings are accepted for forward compatibility. Invalid required fields or structural types raise `FiscalRail::ResponseParseError` with the field path and request ID.
59
+
60
+ ## Idempotency and retries
61
+
62
+ Invoice `issue` and `amend` calls generate an idempotency key when none is supplied. Every retry within that call uses the same key and serialized body. For durable jobs, create and persist a key with the job before its first attempt:
63
+
64
+ ```ruby
65
+ invoice = client.invoices.issue(
66
+ idempotency_key: saved_job_key,
67
+ lines: [{ description: "Consulting", unit_price: "100.00",
68
+ taxes: [FiscalRail::TaxRegimes::ES::VAT.general] }]
69
+ )
70
+
71
+ amendment = client.invoices.amend(
72
+ invoice.id,
73
+ reason: "issued_by_mistake",
74
+ idempotency_key: saved_amendment_key
75
+ )
76
+ ```
77
+
78
+ A new SDK call without a supplied key generates a new key. It does not deduplicate retries made by your job framework. Never reuse a key for a different operation or payload.
79
+
80
+ By default, the SDK makes at most two retries for connection failures, timeouts, HTTP 408/429 and 5xx responses, only for operations designated safe: reads, idempotency-protected issuance/amendments, PDF rendering, and destination enable/disable. Ordinary create/update/delete calls are not automatically retried. The SDK honors numeric and HTTP-date `Retry-After` values, bounded to 30 seconds; otherwise it uses exponential backoff. Set `max_retries: 0` to disable retries. Redirects are not followed. Response decoding failures are not retried.
81
+
82
+ ## Resources
83
+
84
+ | Resource | Methods |
85
+ | --- | --- |
86
+ | `accounts` | `list`, `retrieve`, `update` |
87
+ | `balances` | `retrieve(account_id)` |
88
+ | `account_tax_regimes` | `retrieve(account_id)` |
89
+ | `api_keys` | `list`, `create`, `retrieve`, `delete` |
90
+ | `customers` | `list`, `create`, `retrieve`, `update`, `delete` |
91
+ | `event_destinations` | `list`, `create`, `retrieve`, `update`, `delete`, `enable`, `disable` |
92
+ | `events` | `list`, `retrieve` |
93
+ | `invoice_series` | `list`, `create`, `retrieve`, `update`, `delete` |
94
+ | `invoices` | `list`, `issue`, `retrieve`, `amend` |
95
+ | `invoice_pdfs` | `retrieve`, `render`, `retrieve_content`, `render_content` |
96
+ | `payment_instructions` | `list`, `create`, `retrieve`, `update`, `delete` |
97
+ | `tax_ids` | `retrieve` |
98
+ | `tax_regimes` | `list`, `retrieve` |
99
+
100
+ `invoice_pdfs.retrieve` and `render` return metadata. Their `_content` variants return `FiscalRail::BinaryContent`, with `content`, `content_type`, `request_id` and `write_to_file(path)`. PDF content is buffered in memory. `locale:` on rendering becomes `Accept-Language`.
101
+
102
+ ## Pagination
103
+
104
+ `list` retrieves one page, exposing `data`, `has_more`, `request_id` and Enumerable methods over that page. Paginated resources also expose `auto_paging_each`, which fetches subsequent pages lazily:
105
+
106
+ ```ruby
107
+ page = client.customers.list(country: "ES", limit: 25)
108
+ page.each { |customer| puts customer.name }
109
+
110
+ client.invoices.auto_paging_each(customer: "cus_...", page_size: 100) do |invoice|
111
+ puts invoice.code
112
+ end
113
+
114
+ first_ten = client.customers.auto_paging_each.lazy.take(10).to_a
115
+ ```
116
+
117
+ Automatic iteration proceeds forward and preserves filters. Use `list(starting_after: ...)` or `list(ending_before: ...)` to manage cursors yourself. Accounts and tax regimes return single lists and do not expose automatic pagination.
118
+
119
+ ## Payment instructions
120
+
121
+ ```ruby
122
+ instruction = client.payment_instructions.create(
123
+ label: "Main EUR account",
124
+ type: "bank_transfer",
125
+ bank_transfer: {
126
+ beneficiary: "Example supplier",
127
+ iban: "ES9121000418450200051332",
128
+ bic: "CAIXESBBXXX"
129
+ }
130
+ )
131
+
132
+ client.accounts.update("acct_...", default_payment_instructions: [instruction.id])
133
+
134
+ invoice = client.invoices.issue(
135
+ payment_terms: { due_date: Date.new(2026, 9, 30) },
136
+ lines: [{ description: "Consulting", unit_price: "100.00",
137
+ taxes: [FiscalRail::TaxRegimes::ES::VAT.general] }]
138
+ )
139
+ ```
140
+
141
+ Use `payment_terms: { options: [instruction.id] }` to override account defaults, or `options: []` to omit payment instructions.
142
+
143
+ ## Webhooks
144
+
145
+ Verify the exact raw body before parsing or processing it. For example, in a Rails controller:
146
+
147
+ ```ruby
148
+ event = FiscalRail::Webhooks.construct_event(
149
+ request.raw_post,
150
+ request.headers["FiscalRail-Signature"],
151
+ signing_secret
152
+ )
153
+ ```
154
+
155
+ The verifier uses constant-time HMAC comparison and a five-minute timestamp tolerance in both directions. It accepts multiple `v1` signatures, returns a hash with string keys, and raises `FiscalRail::WebhookSignatureError` on failure. `verify_signature` verifies without parsing and returns the timestamp. Tests can inject `now:`; `tolerance: nil` explicitly disables the age check.
156
+
157
+ ## Errors
158
+
159
+ ```ruby
160
+ begin
161
+ client.invoices.issue(idempotency_key: saved_job_key, **params)
162
+ rescue FiscalRail::InvalidInvoiceError => error
163
+ warn error.message
164
+ error.details.each { |detail| warn "#{detail['field']}: #{detail['message']}" }
165
+ rescue FiscalRail::APIConnectionError => error
166
+ # Includes APITimeoutError. Retain the key if the outcome is uncertain.
167
+ warn "Request failed; idempotency key: #{error.idempotency_key}"
168
+ rescue FiscalRail::APIError => error
169
+ warn "#{error.code}: HTTP #{error.status_code}, request #{error.request_id}"
170
+ end
171
+ ```
172
+
173
+ All SDK errors inherit from `FiscalRail::Error`. API errors retain `code`, `status_code`, `request_id`, `details`, `body` and `idempotency_key`. Known error codes have subclasses; unknown codes remain `APIError`. Parse errors also retain the request ID and idempotency key.
174
+
175
+ ## Connections and configuration
176
+
177
+ ```ruby
178
+ FiscalRail::Client.open(
179
+ api_key: secret,
180
+ open_timeout: 5,
181
+ read_timeout: 30,
182
+ write_timeout: 30,
183
+ max_retries: 2
184
+ ) do |client|
185
+ client.accounts.list
186
+ end
187
+ ```
188
+
189
+ `timeout:` sets all three timeout defaults. The default `Net::HTTP` adapter reuses one connection and serializes concurrent requests with a mutex. Use separate clients for concurrent HTTP throughput. A new connection is opened after a process fork. The SDK disables the HTTP library's own retries so requests are not retried twice. `close` releases the connection; the next request can reconnect.
190
+
191
+ For custom proxy, TLS or observability behavior, inject `adapter:`. It must implement:
192
+
193
+ ```ruby
194
+ def call(method:, uri:, headers:, body:)
195
+ # Return FiscalRail::NetHTTPAdapter::Response.new(
196
+ # status: 200, headers: { "content-type" => "application/json" }, body: "..."
197
+ # )
198
+ end
199
+ ```
200
+
201
+ `uri` is a `URI` object; `body` is already serialized JSON or nil. Use standard socket/timeout exceptions for transport failures, and disable adapter-level retries. Injected adapters remain caller-owned and are never closed by the SDK. The SDK does not inspect environment variables; the default HTTP library can use its standard proxy environment configuration.
202
+
203
+ ## Development
204
+
205
+ ```sh
206
+ bundle install
207
+ bundle exec rake test
208
+ bundle exec rake generate
209
+ bundle exec rake check_generated
210
+ bundle exec rake build
211
+ ```
212
+
213
+ Generation defaults to the current [published OpenAPI document](https://docs.fiscalrail.com/openapi.yml). To work against a local contract:
214
+
215
+ ```sh
216
+ bundle exec rake generate SCHEMA=../path/to/api.oas.yml
217
+ bundle exec rake check_generated SCHEMA=../path/to/api.oas.yml
218
+ ```
219
+
220
+ The generator emits Ruby response readers and schema/operation metadata under `lib/fiscalrail/generated`. Resource methods, transport, decoding, errors, pagination, webhook verification and tax conveniences are handwritten. Do not edit generated files. There is no RBS support, vendored schema or pinned schema revision. Regular tests are offline; `check_generated` needs the published schema unless a local path is supplied, and intentionally fails when that contract changes.
221
+
222
+ The fixtures contain public OpenAPI examples plus synthetic webhook, event and amendment payloads. HTTP tests run against a loopback TCP server. An optional test exercises the actual Rails application, test database and PDF renderer:
223
+
224
+ ```sh
225
+ # Run in the FiscalRail Rails checkout with its local dependencies running.
226
+ FISCALRAIL_APP_ROOT="$PWD" bundle exec ruby /absolute/path/to/ruby/test/rails_integration.rb
227
+ ```
228
+
229
+ It creates a Test account inside a test transaction and checks customer creation, null clearing, payment defaults, issuance/replay, pagination, PDF download and amendment. Set `FISCALRAIL_SDK_PDF=/tmp/sdk-invoice.pdf` to retain the rendered PDF for inspection. This test is optional and is not part of the standalone gem CI.
230
+
231
+ See [RELEASING.md](RELEASING.md) for the release checklist.
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ class BinaryContent
5
+ attr_reader :content, :content_type, :request_id
6
+
7
+ def initialize(response)
8
+ @content = response.body.b.freeze
9
+ @content_type = response.headers["content-type"]&.freeze
10
+ @request_id = response.headers["request-id"]&.freeze
11
+ freeze
12
+ end
13
+
14
+ def write_to_file(path)
15
+ File.binwrite(path, content)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ class Client
5
+ attr_reader :accounts, :balances, :account_tax_regimes, :api_keys, :customers,
6
+ :event_destinations, :events, :invoice_series, :invoices, :invoice_pdfs,
7
+ :payment_instructions, :tax_ids, :tax_regimes
8
+
9
+ def initialize(api_key:, base_url: "https://api.fiscalrail.com/v1", timeout: 30,
10
+ open_timeout: timeout, read_timeout: timeout, write_timeout: timeout, max_retries: 2, adapter: nil)
11
+ raise ArgumentError, "api_key must be a nonempty string" unless api_key.is_a?(String) && !api_key.empty?
12
+ raise ArgumentError, "max_retries must be a nonnegative integer" unless max_retries.is_a?(Integer) && max_retries >= 0
13
+ [open_timeout, read_timeout, write_timeout].each do |value|
14
+ raise ArgumentError, "timeouts must be positive finite numbers" unless value.is_a?(Numeric) && value.finite? && value.positive?
15
+ end
16
+ uri = URI(base_url)
17
+ raise ArgumentError, "base_url must be an HTTP(S) URL without credentials, query or fragment" unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
18
+
19
+ @owns_adapter = adapter.nil?
20
+ @adapter = adapter || NetHTTPAdapter.new(open_timeout: open_timeout, read_timeout: read_timeout, write_timeout: write_timeout)
21
+ transport = Transport.new(api_key: api_key, base_url: base_url, max_retries: max_retries, adapter: @adapter)
22
+ @accounts = Resources::Accounts.new(transport)
23
+ @balances = Resources::Balances.new(transport)
24
+ @account_tax_regimes = Resources::AccountTaxRegimes.new(transport)
25
+ @api_keys = Resources::ApiKeys.new(transport)
26
+ @customers = Resources::Customers.new(transport)
27
+ @event_destinations = Resources::EventDestinations.new(transport)
28
+ @events = Resources::Events.new(transport)
29
+ @invoice_series = Resources::InvoiceSeries.new(transport)
30
+ @invoices = Resources::Invoices.new(transport)
31
+ @invoice_pdfs = Resources::InvoicePdfs.new(transport)
32
+ @payment_instructions = Resources::PaymentInstructions.new(transport)
33
+ @tax_ids = Resources::TaxIds.new(transport)
34
+ @tax_regimes = Resources::TaxRegimes.new(transport)
35
+ end
36
+
37
+ def self.open(**options)
38
+ client = new(**options)
39
+ return client unless block_given?
40
+
41
+ begin
42
+ yield client
43
+ ensure
44
+ client.close
45
+ end
46
+ end
47
+
48
+ def close
49
+ @adapter.close if @owns_adapter
50
+ nil
51
+ end
52
+
53
+ def inspect
54
+ "#<#{self.class.name}>"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ class Decoder
5
+ class Mismatch < StandardError
6
+ attr_reader :field
7
+
8
+ def initialize(message, field)
9
+ @field = field
10
+ super(message)
11
+ end
12
+ end
13
+
14
+ def self.decode(schema, value, metadata: {})
15
+ new.decode(schema, value, "$", metadata)
16
+ rescue Mismatch => error
17
+ name = schema["$ref"]&.split("/")&.last || "API"
18
+ raise ResponseParseError.new(error.message, model: name, field: error.field,
19
+ request_id: metadata[:request_id], idempotency_key: metadata[:idempotency_key])
20
+ end
21
+
22
+ def decode(schema, value, path, metadata = {})
23
+ mismatch("value is not allowed", path) if schema == false
24
+ return value if schema == true || schema.empty?
25
+ if schema["$ref"]
26
+ referenced = Generated::SCHEMAS.fetch(schema.fetch("$ref").split("/").last)
27
+ return decode(referenced.merge(schema.reject { |key, _| key == "$ref" }), value, path, metadata)
28
+ end
29
+
30
+ if (branches = schema["oneOf"] || schema["anyOf"])
31
+ if (discriminator = schema["discriminator"]) && value.is_a?(Hash)
32
+ ref = discriminator.fetch("mapping", {})[value[discriminator.fetch("propertyName")]]
33
+ return decode({ "$ref" => ref }, value, path, metadata) if ref
34
+ end
35
+ failures = []
36
+ branches.each do |branch|
37
+ begin
38
+ return decode(branch, value, path, metadata)
39
+ rescue Mismatch => error
40
+ failures << error
41
+ end
42
+ end
43
+ raise failures.max_by { |error| error.field.length }
44
+ end
45
+
46
+ types = Array(schema["type"])
47
+ return nil if value.nil? && types.include?("null")
48
+ mismatch("expected #{types.join(' or ')}", path) if value.nil? && !types.empty?
49
+ mismatch("expected #{schema['const'].inspect}", path) if schema.key?("const") && value != schema["const"]
50
+
51
+ case (types - ["null"]).first
52
+ when "object"
53
+ mismatch("expected an object", path) unless value.is_a?(Hash)
54
+ properties = schema.fetch("properties", {})
55
+ schema.fetch("required", []).each { |key| mismatch("required field is missing", "#{path}.#{key}") unless value.key?(key) }
56
+ attributes, extra = {}, {}
57
+ value.each do |key, item|
58
+ if properties.key?(key)
59
+ attributes[key] = decode(properties[key], item, "#{path}.#{key}")
60
+ else
61
+ additional = schema["additionalProperties"]
62
+ extra[key] = additional.is_a?(Hash) ? decode(additional, item, "#{path}.#{key}") : item
63
+ end
64
+ end
65
+ return attributes.merge(extra) unless schema["model"]
66
+
67
+ Models.const_get(schema.fetch("model"), false).new(attributes, extra_fields: extra, metadata: metadata)
68
+ when "array"
69
+ mismatch("expected an array", path) unless value.is_a?(Array)
70
+ value.each_with_index.map { |item, index| decode(schema.fetch("items", {}), item, "#{path}[#{index}]") }
71
+ when "string"
72
+ mismatch("expected a string", path) unless value.is_a?(String)
73
+ case schema["format"]
74
+ when "decimal"
75
+ mismatch("expected a finite decimal string", path) unless value.match?(/\A-?\d+(?:\.\d+)?\z/)
76
+ BigDecimal(value)
77
+ when "date" then Date.iso8601(value)
78
+ when "date-time" then Time.iso8601(value)
79
+ else value
80
+ end
81
+ when "integer"
82
+ mismatch("expected an integer", path) unless value.is_a?(Integer)
83
+ value
84
+ when "number"
85
+ mismatch("expected a number", path) unless value.is_a?(Numeric)
86
+ value
87
+ when "boolean"
88
+ mismatch("expected a boolean", path) unless value == true || value == false
89
+ value
90
+ when "null" then mismatch("expected null", path)
91
+ else value
92
+ end
93
+ rescue ArgumentError => error
94
+ mismatch(error.message, path)
95
+ end
96
+
97
+ private
98
+
99
+ def mismatch(message, path)
100
+ raise Mismatch.new(message, path)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiscalRail
4
+ class Error < StandardError; end
5
+ class WebhookSignatureError < Error; end
6
+
7
+ class APIConnectionError < Error
8
+ attr_reader :idempotency_key
9
+
10
+ def initialize(message, idempotency_key: nil)
11
+ @idempotency_key = idempotency_key
12
+ super(message)
13
+ end
14
+ end
15
+
16
+ class APITimeoutError < APIConnectionError; end
17
+
18
+ class ResponseParseError < Error
19
+ attr_reader :model, :field, :request_id, :idempotency_key
20
+
21
+ def initialize(message, model:, field:, request_id: nil, idempotency_key: nil)
22
+ @model, @field, @request_id, @idempotency_key = model, field, request_id, idempotency_key
23
+ super("Could not parse FiscalRail #{model} response at #{field}: #{message}")
24
+ end
25
+ end
26
+
27
+ class APIError < Error
28
+ attr_reader :code, :status_code, :request_id, :details, :idempotency_key, :body
29
+
30
+ def initialize(message, code:, status_code:, request_id:, details: [], idempotency_key: nil, body: nil)
31
+ @code, @status_code, @request_id = code, status_code, request_id
32
+ @details, @idempotency_key, @body = details, idempotency_key, body
33
+ super(request_id ? "#{message} (request_id: #{request_id})" : message)
34
+ end
35
+ end
36
+
37
+ class AuthenticationError < APIError; end
38
+ class InvalidRequestError < APIError; end
39
+ class ResourceNotFoundError < APIError; end
40
+ class InvalidCustomerError < APIError; end
41
+ class CustomerNotFoundError < APIError; end
42
+ class InvalidInvoiceError < APIError; end
43
+ class InvalidInvoiceSeriesError < APIError; end
44
+ class InvalidPaymentInstructionError < APIError; end
45
+ class InvalidInvoiceAmendmentError < APIError; end
46
+ class AccountNotConfiguredError < APIError; end
47
+ class BalanceExhaustedError < APIError; end
48
+ class IdempotencyConflictError < APIError; end
49
+ class PdfRenderInProgressError < APIError; end
50
+ class PdfRenderingUnavailableError < APIError; end
51
+
52
+ module Errors
53
+ CLASSES = {
54
+ "authentication_required" => AuthenticationError,
55
+ "invalid_request" => InvalidRequestError,
56
+ "invalid_idempotency_key" => InvalidRequestError,
57
+ "resource_not_found" => ResourceNotFoundError,
58
+ "invalid_customer" => InvalidCustomerError,
59
+ "customer_not_found" => CustomerNotFoundError,
60
+ "invalid_invoice" => InvalidInvoiceError,
61
+ "invalid_invoice_series" => InvalidInvoiceSeriesError,
62
+ "invalid_payment_instruction" => InvalidPaymentInstructionError,
63
+ "invalid_invoice_amendment" => InvalidInvoiceAmendmentError,
64
+ "account_not_configured" => AccountNotConfiguredError,
65
+ "balance_exhausted" => BalanceExhaustedError,
66
+ "idempotency_key_in_use" => IdempotencyConflictError,
67
+ "idempotency_key_mismatch" => IdempotencyConflictError,
68
+ "pdf_render_in_progress" => PdfRenderInProgressError,
69
+ "pdf_rendering_unavailable" => PdfRenderingUnavailableError
70
+ }.freeze
71
+
72
+ def self.from_response(status:, headers:, body:, idempotency_key: nil)
73
+ payload = JSON.parse(body)
74
+ build(status, headers, payload, idempotency_key)
75
+ rescue JSON::ParserError
76
+ build(status, headers, body, idempotency_key)
77
+ end
78
+
79
+ def self.build(status, headers, payload, idempotency_key)
80
+ error = payload.is_a?(Hash) && payload["error"].is_a?(Hash) ? payload["error"] : {}
81
+ code = error["code"] || "http_#{status}"
82
+ details = Array(error["details"]).select { |detail| detail.is_a?(Hash) }
83
+ CLASSES.fetch(code, APIError).new(
84
+ error["message"] || "FiscalRail returned HTTP #{status}",
85
+ code: code, status_code: status, request_id: headers["request-id"],
86
+ details: details, idempotency_key: idempotency_key, body: payload
87
+ )
88
+ end
89
+ end
90
+ end