flashpi 0.1.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: e99de7bd1116dba76f96d4777acde01faab1f23d53741d93796d7f761111920c
4
+ data.tar.gz: b3048538f68a053c888dccf4bbf87cfb6537da48e3442311451e68797405cb6f
5
+ SHA512:
6
+ metadata.gz: ae6dfcd60c93b516e82b96731446151f0c102c6624018eae17fae6998dfbf114201117a509c89429e9c21434b6da09f3715328328c1864b1499f3f28b0878bd8
7
+ data.tar.gz: 10263d7cc9844ed4cebcf7a90b86a1c9df748d840286adcd7cb7d7069232ff2c76d004b5de776918a4997ca0f3ee7c9c5b3cf9094344f96336d2fa15eb0e9e15
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * add the client and every API operation ([c267d7c](https://github.com/flashpi1/flashpi-ruby/commit/c267d7c83cadbc1a3c5c2f9696db58dee6c7b5fd))
9
+ * add the errors ([542b470](https://github.com/flashpi1/flashpi-ruby/commit/542b4703900d3e5ba426a49c2456d63ac9801c7e))
10
+ * add the request engine and its net/http transport ([cb83b6f](https://github.com/flashpi1/flashpi-ruby/commit/cb83b6f04e4e08197c59bedae824b8b385f624a5))
11
+ * generate RBS types from the OpenAPI contract ([e590c97](https://github.com/flashpi1/flashpi-ruby/commit/e590c97a6c39af50a1cc7495ed0268d6570a8e3f))
12
+ * type the gem with RBS signatures checked by Steep ([7b73fab](https://github.com/flashpi1/flashpi-ruby/commit/7b73fab55389e6dc438ae8c04da87714da883d5d))
13
+ * verify the webhooks Flashpi sends ([338c0fb](https://github.com/flashpi1/flashpi-ruby/commit/338c0fbf9885c45731df9a51ea2c05e242ceffc7))
14
+
15
+
16
+ ### Bug fixes
17
+
18
+ * check ids, filters and certificate fields as UTF-8 text ([8ec57d7](https://github.com/flashpi1/flashpi-ruby/commit/8ec57d7a3702b379544b47b0eaa7da87d5c5775e))
19
+ * **ci:** name the owning account when the release exchanges its token ([c36e333](https://github.com/flashpi1/flashpi-ruby/commit/c36e333ff67cf080f71b90e3cfa8e09ed0bfb9f0))
20
+ * end every bad webhook in a WebhookVerificationError ([3ef3ba7](https://github.com/flashpi1/flashpi-ruby/commit/3ef3ba7997f8bbcff10c07884bf57daf039f95a6))
21
+ * harden the transport's proxies, truncated answers and kept-alive connections ([387a725](https://github.com/flashpi1/flashpi-ruby/commit/387a725c47ce17e90148b06e2e7e1999ac8f7ec8))
22
+ * keep a failed upload's .p12 and password out of the engine's locals ([1dc5853](https://github.com/flashpi1/flashpi-ruby/commit/1dc5853993a60100c4e524ff7fe2abb7e8c1fdaa))
23
+ * let a StopIteration of the caller's block through Page#each ([4910a1c](https://github.com/flashpi1/flashpi-ruby/commit/4910a1c8a7bd7f7fb5eb902af3a2c392b6355866))
24
+ * **scripts:** type path-level parameters and refuse serialization settings ([4e6a5e6](https://github.com/flashpi1/flashpi-ruby/commit/4e6a5e6ff587c9850b6287eca136d9f3a11adc75))
25
+ * send a POST without a body with no Content-Type on Ruby 3.3 and 3.4 ([d3f6888](https://github.com/flashpi1/flashpi-ruby/commit/d3f6888a6a2ae56a324616384304c604090a22b6))
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SIGNIADIGITAL S.A.
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.en.md ADDED
@@ -0,0 +1,379 @@
1
+ # Flashpi for Ruby
2
+
3
+ The official Ruby SDK for [Flashpi](https://flashpi.com.ec), the electronic invoicing API for
4
+ Ecuador's SRI.
5
+
6
+ [Español](https://github.com/flashpi1/flashpi-ruby/blob/main/README.md)
7
+
8
+ - The 45 operations of the API: the six document types, documents (XML, RIDE PDF, email),
9
+ issuers with establishments, emission points and .p12 certificates, webhooks and events.
10
+ - One client (`Flashpi::Client`) you can share between threads, which reuses its connections.
11
+ - RBS signatures generated from the API's OpenAPI document, so Steep and the editors that read
12
+ RBS know every field.
13
+ - Safe retries: every issuing request carries an `Idempotency-Key`, so a retry never issues a
14
+ document twice.
15
+ - Typed errors (RFC 9457) with their `code`, field errors and `request_id`.
16
+ - Automatic pagination: every list is `Enumerable` and fetches the pages as you go.
17
+ - Webhook signature verification (Standard Webhooks) for Rails, Sinatra or any Rack app.
18
+ - Ruby 3.3 or later. No dependencies: the standard library only (net/http).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ bundle add flashpi
24
+ ```
25
+
26
+ Or without Bundler: `gem install flashpi`.
27
+
28
+ ## Getting started
29
+
30
+ Create an API key in the [console](https://flashpi.com.ec/app/) and keep it in the
31
+ `FLASHPI_API_KEY` environment variable. An `fp_test_` key issues in the SRI's test environment;
32
+ an `fp_live_` key issues real documents in production.
33
+
34
+ ```ruby
35
+ require "flashpi"
36
+
37
+ # Reads FLASHPI_API_KEY; you can also pass it: Flashpi::Client.new(api_key: "fp_test_...")
38
+ flashpi = Flashpi::Client.new
39
+
40
+ invoice = flashpi.invoices.create(
41
+ {
42
+ issuerId: "<the demo issuer id or yours>",
43
+ establishment: "001",
44
+ emissionPoint: "001",
45
+ customer: {
46
+ idType: "ruc",
47
+ id: "1760013210001",
48
+ name: "PRUEBAS SERVICIO DE RENTAS INTERNAS",
49
+ address: "Av. Amazonas y Villalengua, Quito"
50
+ },
51
+ items: [
52
+ {
53
+ code: "P-001",
54
+ description: "Test service",
55
+ quantity: "1",
56
+ unitPrice: "1.00",
57
+ taxes: [{ type: "vat", rate: "15" }]
58
+ }
59
+ ],
60
+ payments: [{ method: "cash", amount: "1.15" }],
61
+ options: { waitForAuthorization: true }
62
+ }
63
+ )
64
+
65
+ puts invoice[:status], invoice[:accessKey]
66
+ ```
67
+
68
+ Requests and answers are hashes with the API's own keys, in camelCase, as symbols
69
+ (`invoice[:accessKey]`), typed in `Flashpi::Types` (`Flashpi::Types::create_invoice`,
70
+ `Flashpi::Types::document`). Amounts and quantities are **strings** (`"1.15"`), never numbers, so
71
+ no decimals are lost. Write the body in braces, `create({ ... })`: without them, Ruby would take
72
+ it as the options of the call.
73
+
74
+ With the **demo issuer** you can issue test documents without uploading a certificate: its id is
75
+ on the console's Overview (up to 50 documents a day). See
76
+ [Sandbox and demo issuer](https://flashpi.com.ec/docs/en/sandbox/).
77
+
78
+ A client keeps its connections open for the next requests and can be shared between threads:
79
+ create one and reuse it (in Rails, for example, in an initializer). `flashpi.close` closes those
80
+ connections.
81
+
82
+ ## Issuing documents
83
+
84
+ | Document | Method |
85
+ | ------------------------------------------- | ------------------------------------------- |
86
+ | Invoice (factura) | `flashpi.invoices.create(body)` |
87
+ | Credit note (nota de crédito) | `flashpi.credit_notes.create(body)` |
88
+ | Debit note (nota de débito) | `flashpi.debit_notes.create(body)` |
89
+ | Purchase settlement (liquidación de compra) | `flashpi.purchase_settlements.create(body)` |
90
+ | Waybill (guía de remisión) | `flashpi.waybills.create(body)` |
91
+ | Withholding (comprobante de retención) | `flashpi.withholdings.create(body)` |
92
+
93
+ They all return the document. By default the answer comes right away with the status
94
+ `processing`, and the SRI's outcome arrives by webhook. With
95
+ `options: { waitForAuthorization: true }` the API waits for the SRI (15 s by default, up to 30 s
96
+ with `waitTimeoutSeconds`) and returns `authorized`, `rejected` or `failed`; if the SRI takes
97
+ longer, it returns `processing` or `pending_sri` and the document carries on.
98
+
99
+ ## Documents
100
+
101
+ ```ruby
102
+ document = flashpi.documents.get(invoice[:id])
103
+ puts document[:status]
104
+
105
+ xml = flashpi.documents.xml(invoice[:id]) # xml[:kind]: "authorized" once the SRI authorized it
106
+ pdf = flashpi.documents.pdf(invoice[:id]) # the RIDE, as bytes
107
+ File.binwrite("#{invoice[:accessKey]}.pdf", pdf)
108
+ puts xml[:xml] if xml[:kind] == "authorized"
109
+
110
+ flashpi.documents.send_email(invoice[:id]) # emails the RIDE and the XML to the buyer again
111
+ ```
112
+
113
+ ## Lists and pagination
114
+
115
+ A list call returns a page that is `Enumerable`: `each` walks every page, fetching them as you
116
+ go, and `first(10)` fetches only the pages it needs.
117
+
118
+ ```ruby
119
+ flashpi.documents.list({ status: "authorized", issueDateFrom: "2026-11-01" }).each do |document|
120
+ puts document[:accessKey]
121
+ end
122
+
123
+ page = flashpi.documents.list({ limit: 50 })
124
+ puts page.data.size # the documents of this page
125
+ page = page.next_page if page.has_next_page?
126
+ ```
127
+
128
+ The filters use the API's names (`issueDateFrom`, `issuerId`) and go in a hash in braces, before
129
+ the options of the call.
130
+
131
+ ## Errors
132
+
133
+ Every API error is a subclass of `Flashpi::APIError` by HTTP status. Branch on `code`, which is
134
+ stable and in English; `message` follows the requested language.
135
+
136
+ ```ruby
137
+ begin
138
+ flashpi.invoices.create(body)
139
+ rescue Flashpi::UnprocessableEntityError => e
140
+ puts e.code # "validation_failed", "total_mismatch", ...
141
+ e.errors.each { |field| puts "#{field[:field]}: #{field[:message]}" }
142
+ raise
143
+ rescue Flashpi::APIError => e
144
+ puts e.status, e.code, e.request_id # share request_id with support
145
+ raise
146
+ end
147
+ ```
148
+
149
+ | Class | Status | Examples of `code` |
150
+ | ----------------------------------- | ------ | ----------------------------------------------------------- |
151
+ | `Flashpi::BadRequestError` | 400 | `malformed_json`, `invalid_idempotency_key` |
152
+ | `Flashpi::AuthenticationError` | 401 | `authentication_required`, `invalid_api_key` |
153
+ | `Flashpi::PermissionDeniedError` | 403 | `key_read_only`, `key_scope_mismatch`, `ip_not_allowed` |
154
+ | `Flashpi::NotFoundError` | 404 | `not_found` |
155
+ | `Flashpi::ConflictError` | 409 | `sequential_already_used`, `ride_not_available` |
156
+ | `Flashpi::UnprocessableEntityError` | 422 | `validation_failed`, `total_mismatch` |
157
+ | `Flashpi::RateLimitError` | 429 | `demo_issuer_quota_exceeded`, `account_email_limit_reached` |
158
+ | `Flashpi::InternalServerError` | 5xx | `internal_error` |
159
+ | `Flashpi::APIConnectionError` | — | no answer (network); `APITimeoutError` when it timed out |
160
+
161
+ They all extend `Flashpi::Error`. Some errors carry extra data in `error.problem`, such as
162
+ `documentId`, `limit`, `resetsAt` or `rideStatus`. For messages in Spanish:
163
+ `Flashpi::Client.new(language: "es")`.
164
+
165
+ ## Retries and idempotency
166
+
167
+ The SDK retries network errors, timeouts, and 408, 429 and 5xx answers (when they ask to wait
168
+ 60 s or less; not 501 nor `demo_issuer_unavailable`) up to 2 times, with growing waits. Every POST
169
+ carries an `Idempotency-Key` that its retries repeat, so the API never issues twice:
170
+
171
+ - When a retry finds that the first attempt did create the document, the SDK returns that
172
+ document.
173
+ - When the first attempt is still running on the API (for example, the connection dropped while
174
+ it waited for the SRI), the SDK waits for it with the same key, up to the call's time limit,
175
+ without using up retries.
176
+
177
+ To use your own key (for example your order id), pass it to the call:
178
+
179
+ ```ruby
180
+ flashpi.invoices.create(body, idempotency_key: "order-#{order.id}")
181
+ ```
182
+
183
+ If issuing still fails, the error (a `Flashpi::Error`) carries the key it used in
184
+ `error.idempotency_key`. Trying again with `idempotency_key: error.idempotency_key` never issues
185
+ a second document: within 24 hours the API returns the first answer again; after that (or if the
186
+ API lost track of the first attempt), the call fails with a 422 `idempotency_key_reused` whose
187
+ `error.problem[:documentId]` is the existing document (read it with `flashpi.documents.get`).
188
+
189
+ ```ruby
190
+ begin
191
+ flashpi.invoices.create(body)
192
+ rescue Flashpi::Error => e
193
+ save_for_retry(body, e.idempotency_key) if e.idempotency_key # your retry queue
194
+ raise
195
+ end
196
+ ```
197
+
198
+ A call you interrupt from outside (with `Timeout.timeout`, or by stopping the process or the
199
+ thread) raises no `Flashpi::Error` and has no key: if you do that to issuing calls, pass your own
200
+ `idempotency_key`.
201
+
202
+ Daily limits (such as the demo issuer's quota) are not retried: they are raised as a
203
+ `Flashpi::RateLimitError` with `retry_after` in seconds.
204
+
205
+ ## Webhooks
206
+
207
+ Verify every webhook with the body **exactly as it arrived** (not parsed) and the endpoint's
208
+ `whsec_` secret. `Flashpi::Webhooks.unwrap` needs no API key (it is also `flashpi.webhooks`). It
209
+ takes the headers as a hash, a Rack `env` or Rails' `request.headers`.
210
+
211
+ With Rails:
212
+
213
+ ```ruby
214
+ class FlashpiWebhooksController < ApplicationController
215
+ skip_forgery_protection
216
+
217
+ def create
218
+ event = Flashpi::Webhooks.unwrap(request.raw_post, request.headers, ENV.fetch("FLASHPI_WEBHOOK_SECRET"))
219
+
220
+ case event[:type]
221
+ when "webhook_endpoint.verification"
222
+ # The endpoint's activation: answer the challenge.
223
+ return render(json: { challenge: event[:data][:challenge] })
224
+ when "invoice.authorized"
225
+ Rails.logger.info("Authorized #{event[:data][:accessKey]}") # event[:data] is the document
226
+ when "invoice.rejected"
227
+ Rails.logger.info("Rejected #{event[:data][:sriMessages]}")
228
+ end
229
+ head :no_content
230
+ rescue Flashpi::WebhookVerificationError
231
+ head :bad_request
232
+ end
233
+ end
234
+ ```
235
+
236
+ With Sinatra (or any Rack app, with its `env`):
237
+
238
+ ```ruby
239
+ require "sinatra"
240
+ require "flashpi"
241
+
242
+ post "/webhooks/flashpi" do
243
+ event = Flashpi::Webhooks.unwrap(request.body.read, request.env, ENV.fetch("FLASHPI_WEBHOOK_SECRET"))
244
+ if event[:type] == "webhook_endpoint.verification"
245
+ content_type :json
246
+ JSON.generate({ challenge: event[:data][:challenge] })
247
+ else
248
+ handle_event(event) # your code, like the branches of the Rails example
249
+ 204
250
+ end
251
+ rescue Flashpi::WebhookVerificationError
252
+ 400
253
+ end
254
+ ```
255
+
256
+ Store `event[:id]` and drop repeats: an event may arrive more than once. During a secret
257
+ rotation Flashpi signs with both the new and the previous secret, so either one works. If the
258
+ endpoint uses the `thin` mode (no `data`), read the event with `flashpi.events.get(event[:id])`.
259
+
260
+ ## Issuers and certificates
261
+
262
+ These operations need an account-wide key with read and write access.
263
+
264
+ An issuer starts from its electronic signature (.p12): Flashpi checks it, reads the RUC and asks
265
+ the SRI, and returns a `prefill` with what it found so you can build the issuer.
266
+
267
+ ```ruby
268
+ onboarding = flashpi.issuer_onboardings.create(
269
+ { file: File.binread("firma.p12"), password: ENV.fetch("P12_PASSWORD") }
270
+ )
271
+ while onboarding[:status] == "verifying"
272
+ sleep 2
273
+ onboarding = flashpi.issuer_onboardings.get(onboarding[:id])
274
+ end
275
+ unless onboarding[:status] == "ready"
276
+ rejection = onboarding[:rejection]
277
+ raise(rejection ? rejection[:message] : onboarding[:status])
278
+ end
279
+
280
+ prefill = onboarding[:prefill]
281
+ completed =
282
+ if onboarding[:existingIssuer]
283
+ # You already have that RUC: the certificate is added to that issuer (a renewal).
284
+ flashpi.issuer_onboardings.complete(onboarding[:id])
285
+ else
286
+ # A new RUC: you confirm the issuer's data, starting from the prefill.
287
+ tax_profile = prefill && prefill[:taxProfile]
288
+ rimpe_regime = tax_profile && tax_profile[:rimpeRegime]
289
+ flashpi.issuer_onboardings.complete(
290
+ onboarding[:id],
291
+ {
292
+ legalName: (prefill && prefill[:legalName]) || "MY COMPANY S.A.",
293
+ headOfficeAddress: (prefill && prefill[:headOfficeAddress]) || "Av. Amazonas N24-100, Quito",
294
+ taxProfile: {
295
+ keepsAccounting: tax_profile ? tax_profile[:keepsAccounting] == true : false,
296
+ # The RIMPE regime, if any ("general" is not one).
297
+ rimpeRegime: case rimpe_regime when "entrepreneur", "popular_business" then rimpe_regime end
298
+ # For a special taxpayer or a withholding agent, add their resolutions.
299
+ }
300
+ # When onboarding[:rucNeedsConfirmation] is true, send ruc: too.
301
+ }
302
+ )
303
+ end
304
+
305
+ flashpi.issuers.establishments.list(completed[:issuer][:id]).each do |establishment|
306
+ puts establishment[:code]
307
+ end
308
+ ```
309
+
310
+ The `file` can be the bytes of the .p12 (`File.binread("firma.p12")`) or a file opened in binary
311
+ mode (`File.open("firma.p12", "rb")`), up to 1 MiB.
312
+
313
+ To **renew an issuer's signature** for test and live mode at once, run the same flow with the
314
+ new .p12: Flashpi recognizes the RUC (`existingIssuer`) and `complete` with no data adds the
315
+ certificate. `flashpi.issuers.certificates.upload(issuer_id, { file: ..., password: ... })`,
316
+ instead, uploads a certificate for the mode of the key you use only (test or live).
317
+
318
+ ## Options
319
+
320
+ ```ruby
321
+ flashpi = Flashpi::Client.new(
322
+ api_key: ENV.fetch("FLASHPI_API_KEY"), # defaults to the FLASHPI_API_KEY variable
323
+ language: "es", # language of the messages: "en" (default) or "es"
324
+ timeout: 60, # seconds per attempt (at least the SRI wait + 15 s with waitForAuthorization)
325
+ max_retries: 2 # 0 turns retries off
326
+ )
327
+ ```
328
+
329
+ Every method also takes the options of that request as keyword arguments: `idempotency_key`,
330
+ `timeout`, `max_retries`, `language`, `correlation_id` (your own id, which Flashpi logs) and
331
+ `extra_headers`.
332
+
333
+ ```ruby
334
+ document = flashpi.documents.get(document_id, timeout: 10, language: "es", correlation_id: "order-42")
335
+ ```
336
+
337
+ To see the HTTP answer and the request id, call the method through `with_response`:
338
+
339
+ ```ruby
340
+ result = flashpi.documents.with_response.get(document_id)
341
+ puts result.data[:status], result.status, result.request_id
342
+ ```
343
+
344
+ The timeout is one deadline per attempt, from sending it to reading the whole answer: connecting,
345
+ the TLS handshake and every read and write count against it (only the DNS lookup can run past
346
+ it). Requests go out through net/http with the proxy of the environment variables
347
+ (`https_proxy` or `HTTPS_PROXY`, an `http://` URL, and `no_proxy`), read when the client is
348
+ created, and the SDK never follows redirects. In your tests, WebMock or VCR can answer in place of
349
+ the API.
350
+
351
+ ## Types
352
+
353
+ The gem ships its RBS signatures in `sig/`. With [Steep](https://github.com/soutaro/steep),
354
+ `rbs collection install` finds them through your `Gemfile.lock`, and Steep checks the names of
355
+ the fields, their possible values and the options of every call. The signatures need rbs 3.5 or
356
+ later (Steep 1.7 or later); Ruby 3.3 comes with rbs 3.4.
357
+
358
+ ```ruby
359
+ # @type var body: Flashpi::Types::create_invoice
360
+ body = {
361
+ issuerId: "<the issuer id>",
362
+ establishment: "001",
363
+ emissionPoint: "001",
364
+ customer: { idType: "final_consumer" },
365
+ items: [{ description: "Bread", quantity: "2", unitPrice: "0.50", taxes: [{ type: "vat", rate: "15" }] }],
366
+ payments: [{ method: "cash", amount: "1.15" }]
367
+ }
368
+ document = flashpi.invoices.create(body) # Flashpi::Types::document
369
+ puts document[:accessKey]
370
+ ```
371
+
372
+ ## Servers only
373
+
374
+ Your API key is a secret: use the SDK on your servers, never in code that runs on your users'
375
+ devices. `flashpi.inspect` shows the environment and the URL, never the key.
376
+
377
+ ## License
378
+
379
+ [MIT](https://github.com/flashpi1/flashpi-ruby/blob/main/LICENSE) © 2026 SIGNIADIGITAL S.A.