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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +191 -0
- data/SECURITY.md +10 -0
- data/lib/invoist/bills.rb +132 -0
- data/lib/invoist/client.rb +285 -0
- data/lib/invoist/download.rb +44 -0
- data/lib/invoist/error.rb +126 -0
- data/lib/invoist/http.rb +147 -0
- data/lib/invoist/model.rb +92 -0
- data/lib/invoist/models.rb +1598 -0
- data/lib/invoist/params.rb +62 -0
- data/lib/invoist/transport.rb +80 -0
- data/lib/invoist/upload.rb +148 -0
- data/lib/invoist/version.rb +6 -0
- data/lib/invoist/webhook.rb +155 -0
- data/lib/invoist.rb +30 -0
- data/sig/invoist/models.rbs +772 -0
- data/sig/invoist.rbs +273 -0
- metadata +68 -0
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
|