corvo-nfse 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "openssl"
7
+
8
+ module CorvoNfse
9
+ # Thin synchronous HTTP layer over +Net::HTTP+ (keep-alive).
10
+ #
11
+ # Every resource routes through here. It never raises for API, network, or
12
+ # timeout failures — those come back as ``[nil, error]``. Two envelope helpers
13
+ # live here: ``unwrap`` (modes ``data`` / ``none``) and
14
+ # ``to_error`` (non-2xx -> CorvoApiError).
15
+ class Transport
16
+ DEFAULT_PREFIX = "v1"
17
+ DEFAULT_TIMEOUT = 30
18
+
19
+ attr_reader :base_url
20
+
21
+ def initialize(api_key:, base_url:, prefix: DEFAULT_PREFIX, timeout: DEFAULT_TIMEOUT)
22
+ @api_key = api_key
23
+ root = base_url.to_s.sub(%r{/+\z}, "")
24
+ cleaned = (prefix || "").to_s.gsub(%r{\A/+|/+\z}, "")
25
+ @base_url = cleaned.empty? ? root : "#{root}/#{cleaned}"
26
+ @timeout = timeout
27
+
28
+ root_uri = URI.parse(root)
29
+ @http = Net::HTTP.new(root_uri.host, root_uri.port)
30
+ @http.use_ssl = root_uri.scheme == "https"
31
+ @http.open_timeout = timeout
32
+ @http.read_timeout = timeout
33
+ @http.write_timeout = timeout if @http.respond_to?(:write_timeout=)
34
+ end
35
+
36
+ # Returns ``[data, error]``. +type+ is an optional Types:: data class the
37
+ # unwrapped payload is mapped onto.
38
+ def request(method, path, json: nil, query: nil, idempotency_key: nil, unwrap: "data", type: nil)
39
+ uri = build_uri(path, query)
40
+ req = request_class(method).new(uri)
41
+ apply_headers(req, idempotency_key)
42
+ if json
43
+ req["Content-Type"] = "application/json"
44
+ req.body = JSON.generate(json)
45
+ end
46
+ res, error = perform(req)
47
+ return [nil, error] if error
48
+
49
+ data, herror = handle_response(res, unwrap)
50
+ return [nil, herror] if herror
51
+
52
+ [map_type(data, type), nil]
53
+ end
54
+
55
+ # Multipart upload. The SDK passes +fields+ to +set_form+, which sets the
56
+ # ``multipart/form-data`` Content-Type (with boundary) itself — we never set
57
+ # it manually.
58
+ def request_form(method, path, fields, unwrap: "data", type: nil)
59
+ uri = build_uri(path, nil)
60
+ req = request_class(method).new(uri)
61
+ apply_headers(req, nil)
62
+ boundary = "----CorvoFormBoundary#{rand(0x100000000).to_s(16)}"
63
+ req.body = encode_multipart(fields, boundary)
64
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
65
+ res, error = perform(req)
66
+ return [nil, error] if error
67
+
68
+ data, herror = handle_response(res, unwrap)
69
+ return [nil, herror] if herror
70
+
71
+ [map_type(data, type), nil]
72
+ end
73
+
74
+ # RFC 7578 multipart body: a `file` part (with filename + content type) and
75
+ # plain text parts, matching the canonical Node SDK's wire shape.
76
+ def encode_multipart(fields, boundary)
77
+ body = +""
78
+ fields.each do |field|
79
+ body << "--#{boundary}\r\n"
80
+ if field.length == 4
81
+ name, content, content_type, filename = field
82
+ body << %(Content-Disposition: form-data; name="#{name}"; filename="#{filename}"\r\n)
83
+ body << "Content-Type: #{content_type}\r\n\r\n"
84
+ body << content.to_s
85
+ body << "\r\n"
86
+ else
87
+ name, content = field
88
+ body << %(Content-Disposition: form-data; name="#{name}"\r\n\r\n)
89
+ body << content.to_s
90
+ body << "\r\n"
91
+ end
92
+ end
93
+ body << "--#{boundary}--\r\n"
94
+ body
95
+ end
96
+
97
+ # Binary download: returns a Types::DownloadedFile.
98
+ def request_raw(method, path, query: nil)
99
+ uri = build_uri(path, query)
100
+ req = request_class(method).new(uri)
101
+ apply_headers(req, nil)
102
+ res, error = perform(req)
103
+ return [nil, error] if error
104
+
105
+ status = res.code.to_i
106
+ return [nil, nil] if status == 204
107
+ return [nil, to_error(res)] unless (200..299).cover?(status)
108
+
109
+ disposition = res["Content-Disposition"]
110
+ file_name = disposition && disposition[/filename="([^"]+)"/, 1]
111
+ [
112
+ Types::DownloadedFile.new(
113
+ content: res.body.to_s.b,
114
+ mime_type: res["Content-Type"],
115
+ file_name: file_name
116
+ ),
117
+ nil
118
+ ]
119
+ end
120
+
121
+ # One list page as a Types::Paginated (``{ data, pagination }``).
122
+ def list_page(path, query = nil, mapper: nil)
123
+ raw, error = request("GET", path, query: query, unwrap: "none")
124
+ return [nil, error] if error
125
+
126
+ body = raw.is_a?(Hash) ? raw : {}
127
+ items = body["data"]
128
+ items = items.is_a?(Array) ? items : []
129
+ items = items.map { |item| mapper.call(item) } if mapper
130
+ pagination = body["pagination"]
131
+ pag = pagination.is_a?(Hash) ? Types::Pagination.from_wire(pagination) : nil
132
+ [Types::Paginated.new(data: items, pagination: pag), nil]
133
+ end
134
+
135
+ # Walk cursor pages, yielding every item. Without a block returns an
136
+ # Enumerator.
137
+ #
138
+ # The one place in the SDK that raises: a first-page (or any-page) failure
139
+ # raises CorvoApiError instead of returning a tuple, because an
140
+ # Enumerator/block has no result slot for it.
141
+ def iterate(path, query = nil, mapper: nil)
142
+ unless block_given?
143
+ return Enumerator.new do |yielder|
144
+ iterate(path, query, mapper: mapper) { |item| yielder << item }
145
+ end
146
+ end
147
+
148
+ base_query = (query || {}).dup
149
+ cursor = base_query[:cursor]
150
+ loop do
151
+ page_query = base_query.merge(cursor: cursor)
152
+ page, error = list_page(path, page_query, mapper: mapper)
153
+ raise error if error
154
+
155
+ page.data.each { |item| yield item }
156
+ pag = page.pagination
157
+ return unless pag && pag.hasMore
158
+
159
+ cursor = pag.nextCursor
160
+ return if cursor.nil?
161
+ end
162
+ end
163
+
164
+ # Map a non-2xx response to a typed error (see shared spec).
165
+ def to_error(res)
166
+ status = res.code.to_i
167
+ body = {}
168
+ if res["Content-Type"].to_s.start_with?("application/json")
169
+ begin
170
+ parsed = JSON.parse(res.body.to_s)
171
+ body = parsed if parsed.is_a?(Hash)
172
+ rescue JSON::ParserError
173
+ body = {}
174
+ end
175
+ end
176
+
177
+ code = body["code"].is_a?(String) && !body["code"].empty? ? body["code"] : "http_#{status}"
178
+ raw_message = body["message"]
179
+ message =
180
+ if raw_message.is_a?(String)
181
+ raw_message
182
+ elsif raw_message.is_a?(Array)
183
+ raw_message.map(&:to_s).join("; ")
184
+ elsif body["error"].is_a?(String)
185
+ body["error"]
186
+ else
187
+ "Request failed with status #{status}"
188
+ end
189
+ details = body["details"].is_a?(Array) ? body["details"] : []
190
+ request_id = body["requestId"] || res["X-Request-Id"]
191
+
192
+ retry_after = body["retryAfter"]
193
+ if retry_after.is_a?(Numeric)
194
+ retry_after = retry_after.to_i
195
+ else
196
+ header = res["Retry-After"]
197
+ retry_after = header && header.match?(/\A-?\d+\z/) ? header.to_i : nil
198
+ end
199
+
200
+ CorvoApiError.new(
201
+ status: status,
202
+ code: code,
203
+ message: message,
204
+ details: details,
205
+ request_id: request_id,
206
+ retry_after: retry_after
207
+ )
208
+ end
209
+
210
+ private
211
+
212
+ def build_uri(path, query)
213
+ uri = URI.parse("#{@base_url}#{path}")
214
+ params = (query || {}).compact
215
+ uri.query = URI.encode_www_form(params) unless params.empty?
216
+ uri
217
+ end
218
+
219
+ def request_class(method)
220
+ case method.to_s.upcase
221
+ when "GET" then Net::HTTP::Get
222
+ when "POST" then Net::HTTP::Post
223
+ when "PATCH" then Net::HTTP::Patch
224
+ when "PUT" then Net::HTTP::Put
225
+ when "DELETE" then Net::HTTP::Delete
226
+ else
227
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
228
+ end
229
+ end
230
+
231
+ def apply_headers(req, idempotency_key)
232
+ req["X-API-Key"] = @api_key
233
+ req["Accept"] = "application/json"
234
+ req["Idempotency-Key"] = idempotency_key if idempotency_key
235
+ end
236
+
237
+ def handle_response(res, unwrap_mode)
238
+ status = res.code.to_i
239
+ return [nil, nil] if status == 204
240
+ return [nil, to_error(res)] unless (200..299).cover?(status)
241
+
242
+ body = res.body.to_s
243
+ return [nil, nil] if body.empty?
244
+
245
+ begin
246
+ parsed = JSON.parse(body)
247
+ rescue JSON::ParserError
248
+ return [nil, invalid_json_error(status, res)]
249
+ end
250
+ [unwrap(parsed, unwrap_mode), nil]
251
+ end
252
+
253
+ def invalid_json_error(status, res)
254
+ CorvoApiError.new(
255
+ status: status,
256
+ code: "invalid_json",
257
+ message: "Invalid JSON in response",
258
+ details: [],
259
+ request_id: res["X-Request-Id"]
260
+ )
261
+ end
262
+
263
+ def unwrap(value, mode)
264
+ return value if mode == "none"
265
+
266
+ value.is_a?(Hash) ? value["data"] : nil
267
+ end
268
+
269
+ def map_type(data, type)
270
+ return data unless type
271
+
272
+ data.nil? ? nil : type.from_wire(data)
273
+ end
274
+
275
+ def perform(req)
276
+ [@http.request(req), nil]
277
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
278
+ [nil, timeout_error(e)]
279
+ rescue SocketError, EOFError, IOError, OpenSSL::SSL::SSLError,
280
+ Net::HTTPBadResponse, SystemCallError => e
281
+ [nil, network_error(e)]
282
+ end
283
+
284
+ def timeout_error(e)
285
+ CorvoApiError.new(
286
+ status: 0,
287
+ code: "timeout",
288
+ message: "Request timed out after #{@timeout}s: #{e}",
289
+ details: []
290
+ )
291
+ end
292
+
293
+ def network_error(e)
294
+ CorvoApiError.new(
295
+ status: 0,
296
+ code: "network_error",
297
+ message: "Network request failed: #{e}",
298
+ details: []
299
+ )
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorvoNfse
4
+ module Types
5
+ # ``GET /billing/plan`` payload. The nested +plan+, +subscription+ and
6
+ # +usage+ objects use snake_case wire keys and are returned as raw wire
7
+ # hashes under those members (pass-through, never re-cased).
8
+ BillingPlanResponse = define_data(:plan, :subscription, :usage)
9
+
10
+ # ``GET /billing/usage`` payload (camelCase wire keys).
11
+ QuotaStatus = define_data(
12
+ :canEmit, :creditsRemaining, :creditsUsed, :creditsReserved,
13
+ :monthlyAllowance, :dailyUsage, :dailyLimit, :activeCompanies,
14
+ :maxActiveCompanies, :periodStart, :periodEnd, :reason
15
+ )
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorvoNfse
4
+ module Types
5
+ # A stored A1/A3 signing certificate (PKCS#12) for a company.
6
+ Certificate = define_data(
7
+ :id, :companyId, :serialNumber, :issuer, :subject, :fingerprint,
8
+ :validFrom, :validUntil, :status, :createdAt
9
+ )
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorvoNfse
4
+ module Types
5
+ # Pagination cursor metadata returned by every list endpoint.
6
+ Pagination = define_data(:limit, :hasMore, :nextCursor)
7
+
8
+ # A single list page: +data+ items plus +pagination+ metadata.
9
+ Paginated = define_data(:data, :pagination)
10
+
11
+ # Binary payload from ``/invoices/:id/pdf|xml``. This one is SDK-side (not a
12
+ # wire envelope), hence the snake_case members.
13
+ DownloadedFile = define_data(:content, :mime_type, :file_name)
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorvoNfse
4
+ module Types
5
+ # A fiscal company (the invoice issuer) registered with Corvo. Optional fields
6
+ # are absent from the JSON when unset and read as nil. +setupGaps+ lists what is
7
+ # still pending (municipality_code, certificate).
8
+ Company = define_data(
9
+ :id, :tenantId, :legalName, :tradeName, :taxId, :municipalReg,
10
+ :sendMunicipalReg, :municipalityCode, :defaultServiceCode, :taxRegime, :simpleNational, :specialRegime,
11
+ :addressStreet, :addressNumber, :addressComplement, :addressDistrict,
12
+ :addressCity, :addressState, :addressZip, :fiscalSchemaVersion,
13
+ :environment, :status, :createdAt, :updatedAt, :deletedAt, :setupGaps
14
+ )
15
+ end
16
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorvoNfse
4
+ module Types
5
+ # Current credit balance for the tenant.
6
+ CreditBalance = define_data(
7
+ :tenantId, :balance, :overageUnits, :overageAmountCents, :hardLimit
8
+ )
9
+
10
+ # A purchasable credit plan.
11
+ CreditPlan = define_data(
12
+ :id, :slug, :name, :description, :monthlyCreditAllowance, :dailyLimit,
13
+ :maxActiveCompanies,
14
+ :creditUnitPriceCents, :throughputRps, :includesWebhooks,
15
+ :includesXmlDownload, :includesPdfDownload, :includesApiKeys,
16
+ :supportTier, :active, :createdAt, :updatedAt
17
+ )
18
+
19
+ # A credit ledger transaction. +amount+ is in credits (not BRL); members
20
+ # ending in +Cents+ are in centavos.
21
+ CreditTransaction = define_data(
22
+ :id, :tenantId, :type, :amount, :description, :source, :amountBrlCents,
23
+ :unitPriceCentsApplied, :planIdSnapshot, :reservedInvoiceId, :periodId,
24
+ :isReservation, :relatedTransactionId, :createdAt
25
+ )
26
+
27
+ # A public price catalog entry (+GET /plans+), prices in centavos.
28
+ PlanQuote = define_data(
29
+ :slug, :name, :monthlyAllowance, :basePriceCents, :perCreditOverageCents,
30
+ :monthlyTotalCents, :currency
31
+ )
32
+
33
+ # The plan recommended for a monthly volume (+GET /plans/quote+). The wire
34
+ # names are snake_case.
35
+ PlanRecommendation = define_data(
36
+ :volume, :recommended_plan, :plan_name, :monthly_allowance,
37
+ :overage_credits, :overage_cost_cents, :base_price_cents,
38
+ :total_estimated_cents, :currency, :billing_cycle
39
+ )
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorvoNfse
4
+ module Types
5
+ # An NFS-e invoice through its lifecycle.
6
+ Invoice = define_data(
7
+ :id, :companyId, :tenantId, :environment, :documentKeyVersion,
8
+ :emailSnapshot, :externalId, :idempotencyKey, :status, :customerSnapshot,
9
+ :serviceSnapshot, :taxSnapshot, :amount, :currency, :competenceDate,
10
+ :dpsId, :dpsSeries, :dpsNumber, :accessKey, :nfseNumber, :fiscalProvider,
11
+ :fiscalSchemaVersion, :issuedAt, :authorizedAt, :lastErrorCode,
12
+ :lastErrorMessage, :lastErrorProvider, :documentsReadyAt, :lastEmailStatus,
13
+ :lastEmailSentAt, :createdAt, :updatedAt, :documentsStatus, :emailStatus,
14
+ :stage, :scheduledFor
15
+ )
16
+
17
+ # A status transition recorded for an invoice.
18
+ InvoiceEvent = define_data(
19
+ :id, :invoiceId, :fromStatus, :toStatus, :reason, :createdAt
20
+ )
21
+
22
+ # A time-limited signed download URL for a generated document.
23
+ InvoiceDownloadUrl = define_data(
24
+ :downloadUrl, :expiresAt, :fileName, :mimeType
25
+ )
26
+
27
+ # Result of triggering an invoice e-mail send.
28
+ InvoiceEmailResult = define_data(:emailId, :status)
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorvoNfse
4
+ module Types
5
+ # A registered webhook endpoint.
6
+ Webhook = define_data(:id, :url, :events, :active, :createdAt, :updatedAt)
7
+
8
+ # A webhook as returned once at creation — includes the signing +secret+.
9
+ WebhookWithSecret = define_data(
10
+ :id, :url, :events, :active, :createdAt, :updatedAt, :secret
11
+ )
12
+
13
+ # One delivery attempt record for a webhook.
14
+ WebhookDelivery = define_data(
15
+ :id, :tenantId, :webhookConfigId, :eventType, :environment, :url, :payload,
16
+ :statusCode, :responseBody, :errorMessage, :status, :durationMs,
17
+ :retryCount, :nextRetryAt, :lastError, :createdAt
18
+ )
19
+
20
+ # Aggregated delivery counters over a recent window.
21
+ WebhookDeliveryStats = define_data(
22
+ :total, :sent, :failed, :pending, :delayed
23
+ )
24
+ end
25
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorvoNfse
4
+ # Immutable wire-faithful data types.
5
+ #
6
+ # Every type is a +Data+ class whose members mirror the API wire format
7
+ # exactly (camelCase where the wire is camelCase, snake_case where the wire
8
+ # is snake_case — e.g. the billing payloads). The SDK never re-cases server
9
+ # fields: +from_wire+ maps the parsed JSON onto the type by key, and +to_wire+
10
+ # maps it back verbatim.
11
+ module Types
12
+ # Class-side helpers mixed into every type.
13
+ module ModelMethods
14
+ # Build an instance from a parsed wire hash (string keys). Missing keys
15
+ # default to +nil+; unknown extra keys are ignored so the type tolerates
16
+ # server-side additions.
17
+ def from_wire(hash)
18
+ return nil if hash.nil?
19
+
20
+ src = hash.is_a?(Hash) ? hash.transform_keys(&:to_sym) : {}
21
+ vals = members.to_h { |m| [m, src[m]] }
22
+ new(**vals)
23
+ end
24
+ end
25
+
26
+ # Instance-side helpers mixed into every type.
27
+ module InstanceMethods
28
+ # Back to a wire-format hash (string keys), pass-through of values.
29
+ def to_wire
30
+ to_h.transform_keys(&:to_s)
31
+ end
32
+ end
33
+
34
+ # Define an immutable wire type with the shared from_wire/to_wire helpers.
35
+ def self.define_data(*members, &blk)
36
+ Data.define(*members) do
37
+ extend ModelMethods
38
+ include InstanceMethods
39
+ class_eval(&blk) if blk
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ require_relative "types/common"
46
+ require_relative "types/company"
47
+ require_relative "types/invoice"
48
+ require_relative "types/certificate"
49
+ require_relative "types/webhook"
50
+ require_relative "types/credits"
51
+ require_relative "types/billing"
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module CorvoNfse
6
+ # Small shared helpers.
7
+ module Util
8
+ module_function
9
+
10
+ # Percent-encode a single path segment (encodeURIComponent-equivalent).
11
+ def enc(value)
12
+ URI.encode_www_form_component(value.to_s).gsub("+", "%20")
13
+ end
14
+
15
+ # True for loopback / RFC1918 / link-local hosts (http:// allowed there).
16
+ def private_host?(hostname)
17
+ host = hostname.to_s.downcase.sub(/\.\z/, "")
18
+ return true if ["localhost", "[::1]", "::1"].include?(host) || host.end_with?(".localhost")
19
+
20
+ parts = host.split(".")
21
+ return false unless parts.length == 4
22
+ return false unless parts.all? { |p| p.match?(/\A\d+\z/) && p.to_i <= 255 }
23
+
24
+ a = parts[0].to_i
25
+ b = parts[1].to_i
26
+ a == 127 || a == 10 || a == 0 ||
27
+ (a == 192 && b == 168) ||
28
+ (a == 172 && (16..31).cover?(b)) ||
29
+ (a == 169 && b == 254)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorvoNfse
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module CorvoNfse
6
+ # Helpers for consuming Corvo webhooks.
7
+ module Webhooks
8
+ SIGNATURE_PATTERN = /\Asha256=([0-9a-f]{64})\z/
9
+
10
+ # The events a webhook can subscribe to. +webhook.test+ is only sent by
11
+ # +client.webhooks.test+ and cannot be subscribed.
12
+ EVENTS = %w[
13
+ nfse.invoice.authorized
14
+ nfse.invoice.rejected
15
+ nfse.invoice.failed
16
+ nfse.invoice.reconciliation_required
17
+ nfse.invoice.manual_review
18
+ nfse.invoice.documents_ready
19
+ nfse.invoice.email_sent
20
+ nfse.invoice.email_failed
21
+ nfse.invoice.fiscal_discrepancy
22
+ ].freeze
23
+
24
+ module_function
25
+
26
+ # Verify an ``X-Corvo-Signature: sha256=<hex>`` header (HMAC-SHA256).
27
+ #
28
+ # Pass the raw request bytes — re-serialized JSON will not match the HMAC.
29
+ # Comparison is constant-time via +OpenSSL.fixed_length_secure_compare+.
30
+ #
31
+ # raw = request.body.read
32
+ # CorvoNfse::Webhooks.verify_signature(raw, headers["X-Corvo-Signature"], secret)
33
+ def verify_signature(raw_body, signature_header, secret)
34
+ return false unless signature_header.is_a?(String) && secret.is_a?(String) && !secret.empty?
35
+
36
+ match = SIGNATURE_PATTERN.match(signature_header.strip)
37
+ return false unless match
38
+
39
+ body = raw_body.is_a?(String) ? raw_body : raw_body.to_s
40
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
41
+ constant_time_equals(match[1], expected)
42
+ end
43
+
44
+ def constant_time_equals(a, b)
45
+ return false unless a.bytesize == b.bytesize
46
+
47
+ OpenSSL.fixed_length_secure_compare(a, b)
48
+ end
49
+ end
50
+ end
data/lib/corvo_nfse.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby SDK for the Corvo NFS-e API (https://corvo.tech).
4
+ #
5
+ # A small, stdlib-only client (net/http, uri, json, openssl, base64) exposing
6
+ # the full Corvo NFS-e surface with result-style ``[data, error]`` returns.
7
+ module CorvoNfse
8
+ end
9
+
10
+ require_relative "corvo_nfse/version"
11
+ require_relative "corvo_nfse/error"
12
+ require_relative "corvo_nfse/util"
13
+ require_relative "corvo_nfse/types"
14
+ require_relative "corvo_nfse/transport"
15
+ require_relative "corvo_nfse/webhooks"
16
+
17
+ require_relative "corvo_nfse/resources/base"
18
+ require_relative "corvo_nfse/resources/companies"
19
+ require_relative "corvo_nfse/resources/certificates"
20
+ require_relative "corvo_nfse/resources/invoices"
21
+ require_relative "corvo_nfse/resources/webhooks"
22
+ require_relative "corvo_nfse/resources/credits"
23
+ require_relative "corvo_nfse/resources/plans"
24
+ require_relative "corvo_nfse/resources/billing"
25
+ require_relative "corvo_nfse/resources/health"
26
+
27
+ require_relative "corvo_nfse/client"