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
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
|