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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +182 -0
- data/lib/corvo_nfse/client.rb +59 -0
- data/lib/corvo_nfse/error.rb +37 -0
- data/lib/corvo_nfse/resources/base.rb +12 -0
- data/lib/corvo_nfse/resources/billing.rb +16 -0
- data/lib/corvo_nfse/resources/certificates.rb +37 -0
- data/lib/corvo_nfse/resources/companies.rb +41 -0
- data/lib/corvo_nfse/resources/credits.rb +27 -0
- data/lib/corvo_nfse/resources/health.rb +16 -0
- data/lib/corvo_nfse/resources/invoices.rb +81 -0
- data/lib/corvo_nfse/resources/plans.rb +26 -0
- data/lib/corvo_nfse/resources/webhooks.rb +65 -0
- data/lib/corvo_nfse/transport.rb +302 -0
- data/lib/corvo_nfse/types/billing.rb +17 -0
- data/lib/corvo_nfse/types/certificate.rb +11 -0
- data/lib/corvo_nfse/types/common.rb +15 -0
- data/lib/corvo_nfse/types/company.rb +16 -0
- data/lib/corvo_nfse/types/credits.rb +41 -0
- data/lib/corvo_nfse/types/invoice.rb +30 -0
- data/lib/corvo_nfse/types/webhook.rb +25 -0
- data/lib/corvo_nfse/types.rb +51 -0
- data/lib/corvo_nfse/util.rb +32 -0
- data/lib/corvo_nfse/version.rb +5 -0
- data/lib/corvo_nfse/webhooks.rb +50 -0
- data/lib/corvo_nfse.rb +27 -0
- metadata +118 -0
|
@@ -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,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"
|