invoist 1.0.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: 4ce6eaba5adba41bdf885b3552bd5c731c669d47928ad916a716c2b9b1ad8a18
4
+ data.tar.gz: c1d4bebb9df206e47d0f09cacde4b497c8eef247dbe2b31dc29a3519ceca9281
5
+ SHA512:
6
+ metadata.gz: 945be9fe7ed66f181225c0e233da9299890bf33327d65a0449a2f869aa7c5cecda50d43165ab12cc1b2777739ab304cb722ecd3ed37507a9148ca952b0ee0f0b
7
+ data.tar.gz: 77a67ce410b0276f7f83908f4374771b8e56d45aaa3030ea5b310ae17429e915dcc2ce30161d64569e5bbce66a89f96ed94b7c3860aad84c4e9e295ea9d8d2ef
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `invoist` gem. It follows
4
+ [Semantic Versioning](https://semver.org), and shares its version number
5
+ with the other official Invoist SDKs.
6
+
7
+ ## 1.0.0 - 2026-11-01
8
+
9
+ The first release.
10
+
11
+ - `Invoist::Client` on Ruby's own Net::HTTP, with no other runtime gems.
12
+ - Business workspaces: `extract`, `extract_batch`, `extract_and_wait`,
13
+ `wait_for_extraction`, `list_documents`, `get_document`,
14
+ `get_document_file`, `list_review_queue`, `resolve_review_item`,
15
+ `list_categories`, `get_usage`, `get_stats`, and webhook endpoints
16
+ (`list_webhook_endpoints`, `create_webhook_endpoint`,
17
+ `delete_webhook_endpoint`).
18
+ - Households: `bills.list`, `bills.get`, `bills.get_file`, `bills.summary`,
19
+ `bills.stats`, `bills.mark_paid`, `bills.mark_unpaid` and
20
+ `held_emails.list`.
21
+ - Typed, immutable response objects generated from the API's OpenAPI
22
+ description, with RBS signatures in `sig/`.
23
+ - `Invoist::Error` with `status`, `code`, `request_id`, `body` and
24
+ predicates; automatic retries with `Retry-After` (up to 60 seconds) and
25
+ backoff.
26
+ - `Invoist::Webhook.verify` for the `X-Invoist-Signature` header, and the
27
+ event types as constants.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Invoist
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,191 @@
1
+ # Invoist for Ruby
2
+
3
+ The official Ruby library for the [Invoist API](https://invoi.st/docs). Send
4
+ Invoist an invoice, receipt or bill, and get its details back as data: who
5
+ sent it, the totals, tax, line items, the due date, and anything worth a
6
+ second look.
7
+
8
+ It uses only Ruby's standard library (Net::HTTP, JSON, OpenSSL), so it adds
9
+ no other gems to your app.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ bundle add invoist
15
+ ```
16
+
17
+ or `gem install invoist`. Ruby 3.3 or newer.
18
+
19
+ ## A business: read an invoice
20
+
21
+ ```ruby
22
+ require "invoist"
23
+
24
+ client = Invoist::Client.new(api_key: ENV.fetch("INVOIST_API_KEY"))
25
+ result = client.extract_and_wait("invoice.pdf")
26
+ puts result.extraction.vendor_name, result.extraction.total_cents, result.extraction.currency
27
+ ```
28
+
29
+ Amounts are whole numbers of the smallest unit: `total_cents: 48000` with
30
+ `currency: "GBP"` is £480.00. There are no floats, so nothing is rounded on
31
+ the way.
32
+
33
+ `extract` returns as soon as the file is accepted, while it is still being
34
+ read; `wait_for_extraction` (or the `extraction.completed` webhook) gives
35
+ you the result. A reading that failed is returned, not raised: check
36
+ `extraction.status` and `extraction.error_message`.
37
+
38
+ ```ruby
39
+ result = client.extract(File.open("receipt.jpg", "rb"), idempotency_key: "upload-1842")
40
+ extraction = client.wait_for_extraction(result.document.id, timeout: 60)
41
+
42
+ client.list_documents(flagged: true, limit: 20).each { |doc| puts doc.original_filename }
43
+ client.list_review_queue(status: "pending")
44
+ client.get_document_file(result.document.id).save("copy.jpg")
45
+ ```
46
+
47
+ A file can be a path, a `Pathname`, an open `File` or other IO, a file
48
+ uploaded to a Rails or Rack app, or bytes:
49
+ `Invoist::Upload.new(bytes, filename: "invoice.pdf")`.
50
+
51
+ ## A household: what is there to pay
52
+
53
+ ```ruby
54
+ client = Invoist::Client.new(api_key: ENV.fetch("INVOIST_API_KEY"))
55
+ client.bills.list(paid: false, month: "2026-10").each do |bill|
56
+ puts "#{bill.vendor_name}: #{bill.total_cents} #{bill.currency}, due #{bill.due_date}"
57
+ end
58
+ puts client.bills.summary.overdue
59
+ ```
60
+
61
+ `month` takes `"YYYY-MM"` or a `Date` (a `Time` counts as the day it is
62
+ where your program runs). There is also `bills.get(id)`,
63
+ `bills.get_file(id)`, `bills.stats(month:, sender:)`, `bills.mark_paid(id)`
64
+ and `bills.mark_unpaid(id)` (these two need a key that may mark bills paid),
65
+ and `held_emails.list` for emails waiting for someone to approve the sender.
66
+
67
+ ## Responses
68
+
69
+ Every method returns typed, immutable objects (`Invoist::Models::Bill`,
70
+ `Invoist::Models::Extraction`, …) with a reader for each field, named as the
71
+ API sends it. Fields the API leaves out are `nil`, and new fields the API
72
+ adds later are ignored until you upgrade. `to_h`, `to_json` and
73
+ `bill[:total_cents]` are there when you want plain data. Signatures for
74
+ every type are in `sig/` for Steep and other RBS tools.
75
+
76
+ ## Errors
77
+
78
+ Every failure is an `Invoist::Error`:
79
+
80
+ ```ruby
81
+ begin
82
+ client.bills.mark_paid(bill_id)
83
+ rescue Invoist::Error => e
84
+ e.status # 403 (0 when no response arrived)
85
+ e.code # "insufficient_scope": stable, branch on this
86
+ e.message # for people; the wording can change
87
+ e.request_id # quote this to support
88
+ e.body # the raw response body
89
+ e.auth_error? || e.permission_error? || e.not_found? || e.rate_limited? || e.retryable?
90
+ end
91
+ ```
92
+
93
+ `code` is `network_error` when the API couldn't be reached, `timeout` when
94
+ it didn't answer in time, and `unexpected_response` when something between
95
+ you and the API (a proxy, a gateway) answered instead.
96
+
97
+ ## Retries and timeouts
98
+
99
+ The client retries a request up to twice when it could succeed the second
100
+ time: no response, a timeout, `429 Too Many Requests`, or a `5xx`. It never
101
+ retries another `4xx`. It waits as long as the `Retry-After` header says,
102
+ otherwise 0.5s, then 1s, … with a little randomness, at most 8s. When
103
+ `Retry-After` asks for more than a minute, the call fails at once instead of
104
+ hanging your program (`e.retry_after` says how long). Uploads are
105
+ retried too: sending the same file twice gives back the same document.
106
+
107
+ ```ruby
108
+ Invoist::Client.new(
109
+ api_key: ENV.fetch("INVOIST_API_KEY"),
110
+ timeout: 30, # seconds to connect, and for each read and write (default 60)
111
+ max_retries: 5, # 0 turns retrying off (default 2)
112
+ base_url: "https://api.invoi.st"
113
+ )
114
+ ```
115
+
116
+ Pass `transport:` (anything with `call(request) -> response`, see
117
+ `Invoist::Transport`) to send requests through your own HTTP library or a
118
+ test double.
119
+
120
+ ## Webhooks
121
+
122
+ Check every delivery's `X-Invoist-Signature` before trusting it. Pass the
123
+ **raw request body**: parsing the JSON and turning it back into a string
124
+ changes it, and the check fails.
125
+
126
+ Rails:
127
+
128
+ ```ruby
129
+ class InvoistWebhooksController < ActionController::API
130
+ def create
131
+ event = Invoist::Webhook.verify(request.raw_post,
132
+ request.headers["X-Invoist-Signature"],
133
+ ENV.fetch("INVOIST_WEBHOOK_SECRET"))
134
+ if event.type == Invoist::Webhook::EXTRACTION_COMPLETED
135
+ ImportInvoiceJob.perform_later(event.data["document_id"])
136
+ end
137
+ head :ok
138
+ rescue Invoist::Error
139
+ head :bad_request
140
+ end
141
+ end
142
+ ```
143
+
144
+ Sinatra:
145
+
146
+ ```ruby
147
+ post "/webhooks/invoist" do
148
+ event = Invoist::Webhook.verify(request.body.read, request.env["HTTP_X_INVOIST_SIGNATURE"],
149
+ ENV.fetch("INVOIST_WEBHOOK_SECRET"))
150
+ logger.info("#{event.type}: #{event.data['document_id']}")
151
+ 200
152
+ rescue Invoist::Error
153
+ 400
154
+ end
155
+ ```
156
+
157
+ Signatures older than five minutes, or more than five minutes ahead, are
158
+ refused (`tolerance:` changes that; `0` turns it off). The event types are
159
+ constants: `Invoist::Webhook::DOCUMENT_CREATED`, `EXTRACTION_COMPLETED`,
160
+ `EXTRACTION_FAILED`, `REVIEW_ITEM_CREATED`, `REVIEW_ITEM_RESOLVED` and
161
+ `DOCUMENT_REQUEST_FULFILLED`. To manage endpoints from code:
162
+
163
+ ```ruby
164
+ endpoint = client.create_webhook_endpoint(url: "https://example.com/webhooks/invoist",
165
+ events: [Invoist::Webhook::EXTRACTION_COMPLETED])
166
+ endpoint.secret # shown only now: store it
167
+ client.list_webhook_endpoints.endpoints
168
+ client.delete_webhook_endpoint(endpoint.id)
169
+ ```
170
+
171
+ `Invoist::Webhook.sign(payload, secret)` makes a valid header, for testing
172
+ your own endpoint.
173
+
174
+ ## Development
175
+
176
+ ```sh
177
+ bundle install
178
+ bundle exec rake # rubocop, RBS validation and the tests
179
+ bundle exec rake generate # rebuild lib/invoist/models.rb from openapi.yaml (INVOIST_OPENAPI=path)
180
+ gem build invoist.gemspec
181
+ ```
182
+
183
+ Live tests run against a real API, read-only, when `INVOIST_API_KEY` (and
184
+ optionally `INVOIST_BASE_URL`) is set.
185
+
186
+ ## Links
187
+
188
+ - API documentation: https://invoi.st/docs
189
+ - Changelog: [CHANGELOG.md](CHANGELOG.md)
190
+ - Security: [SECURITY.md](SECURITY.md), report problems to security@invoi.st
191
+ - License: [MIT](LICENSE)
data/SECURITY.md ADDED
@@ -0,0 +1,10 @@
1
+ # Security
2
+
3
+ Please report a security problem in this library, or in the Invoist API, to
4
+ **security@invoi.st**, not in a public issue. Include what you found and how
5
+ to reproduce it. We answer within two working days and credit you, if you
6
+ like, when the fix ships.
7
+
8
+ Never paste an API key, an access token or a webhook secret into an issue.
9
+ If one has leaked, revoke it in Invoist straight away: a business under
10
+ Integrations › API, a household under Settings › AI assistants.
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoist
4
+ # A household's bills (Invoist Personal), reached as `client.bills`. These
5
+ # routes answer a household's key, which holds `bills:read`, and
6
+ # `bills:write` when the household lets the key mark bills paid.
7
+ #
8
+ # client.bills.list(paid: false).each { |bill| puts bill.vendor_name }
9
+ # client.bills.summary.overdue # => 2
10
+ class Bills
11
+ # @api private
12
+ def initialize(http)
13
+ @http = http
14
+ end
15
+
16
+ # Lists bills as the app does: unpaid ones soonest-due first, with a
17
+ # reminder or collection letter folded into its bill (see `letter`).
18
+ #
19
+ # @param paid [Boolean, nil] true for paid bills, false for bills still
20
+ # to pay, nil for both
21
+ # @param month [String, Date, Time, nil] `"YYYY-MM"`; by due date, or the
22
+ # bill's own date when it has none
23
+ # @param sender [String, nil] one sender, matched ignoring case and spacing
24
+ # @param q [String, nil] matches filename, sender, bill number or
25
+ # reference, in every month
26
+ # @param limit [Integer, nil] at most 200; the API's default is 50
27
+ # @param offset [Integer, nil]
28
+ # @return [Array<Models::Bill>]
29
+ # @raise [Error]
30
+ def list(paid: nil, month: nil, sender: nil, q: nil, limit: nil, offset: nil)
31
+ query = { paid: Params.boolean("paid", paid), month: Params.month("month", month), sender: sender, q: q,
32
+ limit: Params.integer("limit", limit), offset: Params.integer("offset", offset) }
33
+ Array(@http.json("GET", "/v1/bills", query: query)&.fetch("bills", nil)).map { |b| Models::Bill.from_h(b) }
34
+ end
35
+
36
+ # Everything about one bill: what was read from it, its open warnings,
37
+ # the household's record (paid, by whom, who pays) and its letters.
38
+ #
39
+ # @param id [String] the bill's id
40
+ # @return [Models::BillDetail]
41
+ # @raise [Error]
42
+ def get(id)
43
+ Models::BillDetail.from_h(@http.json("GET", "/v1/bills/#{Params.id(id)}"))
44
+ end
45
+
46
+ # Downloads the original bill, exactly as it was added.
47
+ #
48
+ # @param id [String]
49
+ # @return [Download]
50
+ # @raise [Error]
51
+ def get_file(id)
52
+ @http.binary("/v1/bills/#{Params.id(id)}/file")
53
+ end
54
+
55
+ # What there is to pay: unpaid and overdue counts, and what falls due in
56
+ # the next `days` days, per currency.
57
+ #
58
+ # @param today [String, Date, nil] the household's own date (`YYYY-MM-DD`),
59
+ # within two days of the server's; defaults to the server's
60
+ # @param days [Integer, nil] how far ahead "due soon" reaches; the API's
61
+ # default is 30
62
+ # @return [Models::BillsSummary]
63
+ # @raise [Error]
64
+ def summary(today: nil, days: nil)
65
+ query = { today: Params.date("today", today), days: Params.integer("days", days) }
66
+ Models::BillsSummary.from_h(@http.json("GET", "/v1/bills/summary", query: query))
67
+ end
68
+
69
+ # A month at a glance: totals billed and paid per currency, the twelve
70
+ # months up to it, the biggest senders, and every sender.
71
+ #
72
+ # @param month [String, Date, Time, nil] `"YYYY-MM"`; defaults to the current month
73
+ # @param sender [String, nil] narrows the totals and trend to one sender
74
+ # @return [Models::BillsStats]
75
+ # @raise [Error]
76
+ def stats(month: nil, sender: nil)
77
+ query = { month: Params.month("month", month), sender: sender }
78
+ Models::BillsStats.from_h(@http.json("GET", "/v1/bills/stats", query: query))
79
+ end
80
+
81
+ # Records a bill, and its letters, as paid by the key's creator. Needs
82
+ # `bills:write`.
83
+ #
84
+ # @param id [String]
85
+ # @return [Models::BillRecord] the household's record after the change
86
+ # @raise [Error]
87
+ def mark_paid(id)
88
+ record(@http.json("POST", "/v1/bills/#{Params.id(id)}/paid"))
89
+ end
90
+
91
+ # Marks a bill as not paid. Needs `bills:write`.
92
+ #
93
+ # @param id [String]
94
+ # @return [Models::BillRecord] the household's record after the change
95
+ # @raise [Error]
96
+ def mark_unpaid(id)
97
+ record(@http.json("DELETE", "/v1/bills/#{Params.id(id)}/paid"))
98
+ end
99
+
100
+ # @return [String]
101
+ def inspect
102
+ "#<#{self.class.name}>"
103
+ end
104
+
105
+ private
106
+
107
+ def record(body)
108
+ Models::BillRecord.from_h(body&.fetch("bill", nil))
109
+ end
110
+ end
111
+
112
+ # Emails forwarded to a household's address that wait for a person to
113
+ # approve their sender, or for a plan; reached as `client.held_emails`.
114
+ # Read-only: approving a sender happens in the app.
115
+ class HeldEmails
116
+ # @api private
117
+ def initialize(http)
118
+ @http = http
119
+ end
120
+
121
+ # @return [Array<Models::HeldEmail>] oldest first
122
+ # @raise [Error]
123
+ def list
124
+ Array(@http.json("GET", "/v1/held-emails")&.fetch("held_emails", nil)).map { |e| Models::HeldEmail.from_h(e) }
125
+ end
126
+
127
+ # @return [String]
128
+ def inspect
129
+ "#<#{self.class.name}>"
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoist
4
+ # The client for the Invoist API.
5
+ #
6
+ # A business workspace's key reads documents:
7
+ #
8
+ # client = Invoist::Client.new(api_key: ENV.fetch("INVOIST_API_KEY"))
9
+ # result = client.extract_and_wait("invoice.pdf")
10
+ # result.extraction.total_cents # => 48000
11
+ #
12
+ # A household's key reads bills, through {#bills} and {#held_emails}:
13
+ #
14
+ # client.bills.list(paid: false, month: "2026-10").each do |bill|
15
+ # puts "#{bill.vendor_name}: #{bill.total_cents} #{bill.currency} by #{bill.due_date}"
16
+ # end
17
+ #
18
+ # Every method raises {Invoist::Error} on failure and retries what is safe
19
+ # to retry (see {#initialize}). A client holds no connection state, so one
20
+ # instance can be shared between threads.
21
+ class Client
22
+ # Where requests go unless `base_url:` says otherwise.
23
+ DEFAULT_BASE_URL = "https://api.invoi.st"
24
+ # Seconds allowed to connect, and for each read and write.
25
+ DEFAULT_TIMEOUT = 60
26
+ # Retries after the first attempt.
27
+ DEFAULT_MAX_RETRIES = 2
28
+ # How long {#wait_for_extraction} waits, in seconds.
29
+ DEFAULT_WAIT_TIMEOUT = 120
30
+ # How often {#wait_for_extraction} asks, in seconds.
31
+ DEFAULT_POLL_INTERVAL = 2
32
+
33
+ # @return [Bills] a household's bills
34
+ attr_reader :bills
35
+ # @return [HeldEmails] emails a household's address is holding for approval
36
+ attr_reader :held_emails
37
+
38
+ # @param api_key [String] a key (`inv_sk_…`) or OAuth access token (`inv_at_…`)
39
+ # @param base_url [String] the API's address; trailing slashes are trimmed
40
+ # @param timeout [Numeric] seconds allowed to connect, and for each read and write
41
+ # @param max_retries [Integer] retries after the first attempt, for
42
+ # network failures, timeouts, 429 and 5xx (never another 4xx). Waits
43
+ # `Retry-After` when sent, otherwise 0.5s, 1s, 2s… with ±20% jitter, at
44
+ # most 8s. 0 turns retrying off.
45
+ # @param transport [#call, nil] sends a {Transport::Request} and returns a
46
+ # {Transport::Response}; defaults to {Transport::NetHTTP}
47
+ # @param sleeper [#call, nil] called with seconds to wait between retries
48
+ # and polls; defaults to `Kernel#sleep` (replace it in tests)
49
+ # @raise [ArgumentError] when the key is missing or an option is invalid
50
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
51
+ max_retries: DEFAULT_MAX_RETRIES, transport: nil, sleeper: nil)
52
+ validate!(api_key, timeout, max_retries)
53
+
54
+ @http = HTTP.new(api_key: api_key.to_s, base_url: base_url.to_s.sub(%r{/+\z}, ""), timeout: timeout,
55
+ max_retries: max_retries, transport: transport || Transport::NetHTTP.new,
56
+ sleeper: sleeper || Kernel.method(:sleep))
57
+ @bills = Bills.new(@http)
58
+ @held_emails = HeldEmails.new(@http)
59
+ end
60
+
61
+ # @return [String] the API's address, without a trailing slash
62
+ def base_url = @http.base_url
63
+
64
+ # @return [Numeric] seconds allowed to connect, and for each read and write
65
+ def timeout = @http.timeout
66
+
67
+ # @return [Integer] retries after the first attempt
68
+ def max_retries = @http.max_retries
69
+
70
+ # @return [String] never shows the key
71
+ def inspect
72
+ "#<#{self.class.name} base_url=#{base_url.inspect}>"
73
+ end
74
+
75
+ # -- extraction ------------------------------------------------------------
76
+
77
+ # Submits a document to be read. Returns as soon as it is accepted, with
78
+ # the extraction still `pending`; use {#wait_for_extraction}, or the
79
+ # `extraction.completed` webhook, for the result.
80
+ #
81
+ # Uploading bytes this workspace already has returns the existing
82
+ # document with `duplicate: true`, so retrying is safe. `idempotency_key`
83
+ # covers re-sending the same logical file with different bytes: every
84
+ # later call with the key replays the first answer, and reusing it with
85
+ # another file raises a 422 {Error}.
86
+ #
87
+ # @param file [Upload, String, Pathname, IO, Hash] see {Upload.wrap}
88
+ # @param idempotency_key [String, nil] at most 255 characters
89
+ # @return [Models::ExtractResult]
90
+ # @raise [Error]
91
+ def extract(file, idempotency_key: nil)
92
+ form = Multipart.new([["file", Upload.wrap(file)]])
93
+ headers = idempotency_key ? { "Idempotency-Key" => idempotency_key.to_s } : {}
94
+ Models::ExtractResult.from_h(@http.multipart("/v1/extract", form, headers: headers))
95
+ end
96
+
97
+ # Submits up to ten documents in one request. Each is handled as by
98
+ # {#extract}; one bad file doesn't fail the others, and `results` keeps
99
+ # the order the files were given.
100
+ #
101
+ # @param files [Array<Upload, String, Pathname, IO, Hash>]
102
+ # @return [Models::BatchResult]
103
+ # @raise [Error]
104
+ def extract_batch(files)
105
+ files = Array(files)
106
+ raise ArgumentError, "extract_batch needs at least one file" if files.empty?
107
+
108
+ form = Multipart.new(files.map { |file| ["files", Upload.wrap(file)] })
109
+ Models::BatchResult.from_h(@http.multipart("/v1/extract/batch", form))
110
+ end
111
+
112
+ # -- documents -------------------------------------------------------------
113
+
114
+ # @param limit [Integer, nil] at most 200; the API's default is 50
115
+ # @param offset [Integer, nil]
116
+ # @param status [String, nil] one of {Models::DocumentStatus::ALL}
117
+ # @param flagged [Boolean, nil] true for documents with open review items only
118
+ # @param q [String, nil] matches filename, vendor, invoice number or reference
119
+ # @param category [String, nil] a category id, or `"none"` for uncategorized
120
+ # @return [Array<Models::DocumentListItem>]
121
+ # @raise [Error]
122
+ def list_documents(limit: nil, offset: nil, status: nil, flagged: nil, q: nil, category: nil)
123
+ query = { limit: Params.integer("limit", limit), offset: Params.integer("offset", offset), status: status,
124
+ flagged: flagged ? Params.boolean("flagged", flagged) : nil, q: q, category: category }
125
+ list(@http.json("GET", "/v1/documents", query: query), "documents", Models::DocumentListItem)
126
+ end
127
+
128
+ # @param id [String] the document's id
129
+ # @return [Models::DocumentDetail] the document, its extraction (once one
130
+ # exists) and its review items
131
+ # @raise [Error]
132
+ def get_document(id)
133
+ Models::DocumentDetail.from_h(@http.json("GET", "/v1/documents/#{Params.id(id)}"))
134
+ end
135
+
136
+ # Downloads the original file, exactly as uploaded.
137
+ #
138
+ # @param id [String]
139
+ # @return [Download]
140
+ # @raise [Error]
141
+ def get_document_file(id)
142
+ @http.binary("/v1/documents/#{Params.id(id)}/file")
143
+ end
144
+
145
+ # Polls {#get_document} until the extraction is no longer `pending`, and
146
+ # returns it. A `failed` extraction is returned, not raised: it is an
147
+ # answer about the document (see its `error_message`).
148
+ #
149
+ # @param id [String] the document's id
150
+ # @param timeout [Numeric] seconds before giving up
151
+ # @param poll_interval [Numeric] seconds between polls
152
+ # @return [Models::Extraction]
153
+ # @raise [Error] code `extraction_timeout` (status 0) past the timeout,
154
+ # or whatever a poll raised
155
+ def wait_for_extraction(id, timeout: DEFAULT_WAIT_TIMEOUT, poll_interval: DEFAULT_POLL_INTERVAL)
156
+ deadline = monotonic + timeout
157
+ loop do
158
+ extraction = get_document(id).extraction
159
+ return extraction if extraction && extraction.status != Models::ExtractionStatus::PENDING
160
+
161
+ if monotonic + poll_interval > deadline
162
+ raise Error.new("The extraction of document #{id} did not finish within #{timeout}s",
163
+ status: 0, code: "extraction_timeout")
164
+ end
165
+ @http.sleep(poll_interval)
166
+ end
167
+ end
168
+
169
+ # Submits a document and waits for its extraction: {#extract} then
170
+ # {#wait_for_extraction}.
171
+ #
172
+ # @param file [Upload, String, Pathname, IO, Hash]
173
+ # @param timeout [Numeric] seconds to wait for the reading
174
+ # @param poll_interval [Numeric]
175
+ # @param idempotency_key [String, nil]
176
+ # @return [Models::ExtractResult] with the finished extraction
177
+ # @raise [Error]
178
+ def extract_and_wait(file, timeout: DEFAULT_WAIT_TIMEOUT, poll_interval: DEFAULT_POLL_INTERVAL,
179
+ idempotency_key: nil)
180
+ result = extract(file, idempotency_key: idempotency_key)
181
+ extraction = result.extraction
182
+ if extraction.nil? || extraction.status == Models::ExtractionStatus::PENDING
183
+ extraction = wait_for_extraction(result.document.id, timeout: timeout, poll_interval: poll_interval)
184
+ end
185
+ result.with(extraction: extraction)
186
+ end
187
+
188
+ # -- review queue ----------------------------------------------------------
189
+
190
+ # @param status [String, nil] one of {Models::ReviewStatus::ALL}; nil for all
191
+ # @return [Array<Models::ReviewQueueListItem>]
192
+ # @raise [Error]
193
+ def list_review_queue(status: nil)
194
+ list(@http.json("GET", "/v1/review-queue", query: { status: status }), "review_queue",
195
+ Models::ReviewQueueListItem)
196
+ end
197
+
198
+ # @param id [String] the review item's id
199
+ # @param status [String] `"resolved"` or `"dismissed"`
200
+ # @return [Models::ReviewQueueItem]
201
+ # @raise [Error]
202
+ def resolve_review_item(id, status: Models::ReviewStatus::RESOLVED)
203
+ body = @http.json("POST", "/v1/review-queue/#{Params.id(id)}/resolve", body: { status: status })
204
+ Models::ReviewQueueItem.from_h(body&.fetch("review_queue_item", nil))
205
+ end
206
+ alias resolve_review_queue_item resolve_review_item
207
+
208
+ # -- workspace -------------------------------------------------------------
209
+
210
+ # @return [Array<Models::Category>]
211
+ # @raise [Error]
212
+ def list_categories
213
+ list(@http.json("GET", "/v1/categories"), "categories", Models::Category)
214
+ end
215
+
216
+ # @return [Models::Usage] usage in the current billing period
217
+ # @raise [Error]
218
+ def get_usage # rubocop:disable Naming/AccessorMethodName
219
+ Models::Usage.from_h(@http.json("GET", "/v1/usage"))
220
+ end
221
+
222
+ # @return [Models::StatsOverview]
223
+ # @raise [Error]
224
+ def get_stats # rubocop:disable Naming/AccessorMethodName
225
+ Models::StatsOverview.from_h(@http.json("GET", "/v1/stats/overview"))
226
+ end
227
+
228
+ # -- webhook endpoints -----------------------------------------------------
229
+
230
+ # @return [Models::WebhookEndpointList] the endpoints and every event type
231
+ # @raise [Error]
232
+ def list_webhook_endpoints
233
+ Models::WebhookEndpointList.from_h(@http.json("GET", "/v1/webhook-endpoints"))
234
+ end
235
+
236
+ # Subscribes a URL to signed events. The answer carries the signing
237
+ # secret, which is never shown again: store it now.
238
+ #
239
+ # @param url [String] an HTTP or HTTPS receiver
240
+ # @param events [Array<String>, nil] e.g. `[Invoist::Webhook::EXTRACTION_COMPLETED]`;
241
+ # nil for everything
242
+ # @param description [String, nil]
243
+ # @return [Models::WebhookEndpointWithSecret]
244
+ # @raise [Error]
245
+ def create_webhook_endpoint(url:, events: nil, description: nil)
246
+ body = { url: url, events: events, description: description }.compact
247
+ response = @http.json("POST", "/v1/webhook-endpoints", body: body)
248
+ Models::WebhookEndpointWithSecret.from_h(response&.fetch("endpoint", nil))
249
+ end
250
+
251
+ # Unsubscribes an endpoint; deliveries to it stop at once.
252
+ #
253
+ # @param id [String]
254
+ # @return [nil]
255
+ # @raise [Error]
256
+ def delete_webhook_endpoint(id)
257
+ @http.json("DELETE", "/v1/webhook-endpoints/#{Params.id(id)}")
258
+ nil
259
+ end
260
+
261
+ private
262
+
263
+ def validate!(api_key, timeout, max_retries)
264
+ if api_key.nil? || api_key.to_s.strip.empty?
265
+ raise ArgumentError,
266
+ "An Invoist API key is required: Invoist::Client.new(api_key: ENV.fetch(\"INVOIST_API_KEY\"))"
267
+ end
268
+ unless timeout.is_a?(Numeric) && timeout.positive?
269
+ raise ArgumentError,
270
+ "timeout must be a positive number of seconds"
271
+ end
272
+ return if max_retries.is_a?(Integer) && !max_retries.negative?
273
+
274
+ raise ArgumentError, "max_retries must be a non-negative Integer"
275
+ end
276
+
277
+ def list(body, key, model)
278
+ Array(body&.fetch(key, nil)).map { |item| model.from_h(item) }
279
+ end
280
+
281
+ def monotonic
282
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
283
+ end
284
+ end
285
+ end