diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a995e41..99d40a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby_version: [2.7, 3.0, 3.1, 3.2, 3.3, 3.4] + ruby_version: ["3.0", "3.1", "3.2", "3.3", "3.4", "4.0"] env: CI: true steps: diff --git a/.gitignore b/.gitignore index 1183af1..0d4623f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ _yardoc doc/ .env -.idea/ \ No newline at end of file +.idea/ + +# Local-only OpenAPI reference (used for gap analysis, not shipped) +schema.json \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index d0c971b..34dfda7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,9 +1,12 @@ plugins: - rubocop-rspec + - rubocop-rake AllCops: + TargetRubyVersion: 3.0 DisplayCopNames: true NewCops: enable + SuggestExtensions: false Exclude: - bin/**/* - tmp/**/* @@ -61,7 +64,8 @@ Lint/RedundantDirGlobSort: Enabled: false Metrics/ParameterLists: - Max: 7 + Max: 8 + MaxOptionalParameters: 4 Metrics/BlockLength: Exclude: diff --git a/.ruby-version b/.ruby-version index 6a81b4c..47b322c 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.8 +3.4.1 diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000..151f664 --- /dev/null +++ b/.yardopts @@ -0,0 +1,10 @@ +--markup markdown +--readme README.md +--no-private +--protected +--output-dir docs/api +--exclude /spec/ +--exclude /bin/ +lib/**/*.rb +- +LICENSE diff --git a/Gemfile.lock b/Gemfile.lock index 570e5ef..97e30d8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,126 +1,123 @@ PATH remote: . specs: - veryfi (3.0.0) + veryfi (4.0.0) base64 (~> 0.1) - faraday (>= 1.7, < 3.0) + faraday (>= 2.14.1, < 3.0) openssl (>= 2.2, < 4.1) GEM remote: https://rubygems.org/ specs: - activesupport (6.1.7.8) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) - bigdecimal (3.1.8) - bundler-audit (0.9.1) - bundler (>= 1.2.0, < 3) + bigdecimal (4.1.2) + bundler-audit (0.9.3) + bundler (>= 1.2.0) thor (~> 1.0) coderay (1.1.3) - concurrent-ruby (1.3.3) - crack (1.0.0) + crack (1.0.1) bigdecimal rexml - diff-lcs (1.5.1) - docile (1.4.0) - faraday (2.8.1) - base64 - faraday-net_http (>= 2.0, < 3.1) - ruby2_keywords (>= 0.0.4) - faraday-net_http (3.0.2) - hashdiff (1.1.0) - i18n (1.14.5) - concurrent-ruby (~> 1.0) - json (2.18.0) + diff-lcs (1.6.2) + docile (1.4.1) + faraday (2.14.2) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.3) + net-http (~> 0.5) + hashdiff (1.2.1) + io-console (0.8.2) + json (2.19.5) language_server-protocol (3.17.0.5) lint_roller (1.1.0) + logger (1.7.0) method_source (1.1.0) - minitest (5.23.1) - openssl (4.0.0) - parallel (1.27.0) - parser (3.3.10.1) + net-http (0.9.1) + uri (>= 0.11.1) + openssl (4.0.2) + parallel (1.28.0) + parser (3.3.11.1) ast (~> 2.4.1) racc - prism (1.8.0) - pry (0.14.2) + prism (1.9.0) + pry (0.16.0) coderay (~> 1.1) method_source (~> 1.0) - public_suffix (5.0.5) + reline (>= 0.6.0) + public_suffix (5.1.1) racc (1.8.1) rainbow (3.1.1) - rake (13.2.1) - regexp_parser (2.11.3) + rake (13.4.2) + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) rexml (3.4.4) - rspec (3.13.0) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.0) + rspec-core (3.13.6) rspec-support (~> 3.13.0) - rspec-expectations (3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-its (1.3.0) + rspec-its (1.3.1) rspec-core (>= 3.0.0) rspec-expectations (>= 3.0.0) - rspec-mocks (3.13.1) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.1) - rubocop (1.82.1) + rspec-support (3.13.7) + rubocop (1.86.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) - parallel (~> 1.10) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.48.0, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) rubocop-rspec (3.9.0) lint_roller (~> 1.1) rubocop (~> 1.81) ruby-progressbar (1.13.0) - ruby2_keywords (0.0.5) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-badge (2.0.2) - simplecov-html (0.12.3) + simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - thor (1.4.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) + thor (1.5.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) - vcr (6.1.0) - webmock (3.23.1) + uri (1.1.1) + vcr (6.4.0) + webmock (3.26.2) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - zeitwerk (2.6.15) + yard (0.9.44) PLATFORMS - arm64-darwin-23 + arm64-darwin-25 ruby - x86_64-darwin-19 x86_64-linux DEPENDENCIES - activesupport (~> 6.0) bundler (~> 2.4) bundler-audit (~> 0.9) pry (~> 0.14) @@ -129,12 +126,14 @@ DEPENDENCIES rspec (~> 3.9) rspec-its (~> 1.3) rubocop (~> 1.65) + rubocop-rake (~> 0.6) rubocop-rspec (~> 3.0) simplecov (~> 0.21) simplecov-badge (~> 2.0) vcr (~> 6.0) veryfi! webmock (~> 3.14) + yard (~> 0.9) BUNDLED WITH 2.4.22 diff --git a/README.md b/README.md index 9ce425e..2165aa2 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,33 @@ - [Table of Contents](#table-of-contents) - [Example](#example) - [Installation](#installation) + - [Versioning & compatibility](#versioning--compatibility) - [Getting Started](#getting-started) - [Obtaining Client ID and user keys](#obtaining-client-id-and-user-keys) - [Ruby API Client Library](#ruby-api-client-library) + - [Configuring the client](#configuring-the-client) + - [Custom Faraday configuration](#custom-faraday-configuration) + - [Handling errors](#handling-errors) + - [Response objects](#response-objects) + - [Common parameters & defaults](#common-parameters--defaults) + - [Available APIs](#available-apis) + - [Documents](#documents) + - [Tags](#tags) + - [Document Tags](#document-tags) + - [Line Items](#line-items) + - [Tax Lines](#tax-lines) + - [PDF Split (Documents Set)](#pdf-split-documents-set) + - [Any Document (A-Docs)](#any-document-a-docs) + - [Bank Statements](#bank-statements) + - [Bank Statement Split](#bank-statement-split) + - [Business Cards](#business-cards) + - [Checks](#checks) + - [Classify](#classify) + - [W-2](#w-2) + - [W-2 Split](#w-2-split) + - [W-8 BEN-E](#w-8-ben-e) + - [W-9](#w-9) + - [Per-resource tags](#per-resource-tags) - [Need help?](#need-help) - [For Developers](#for-developers) - [Install](#install) @@ -225,13 +249,80 @@ This will produce the following response: ## Installation +Install the latest version of the gem: + ```bash gem install veryfi ``` -Or add to your Gemfile: +Or pin it in your `Gemfile`. The current major series is **`4.x`**, so the +recommended pessimistic constraint is: + +```ruby +gem 'veryfi', '~> 4.0' +``` + +That gives you all backwards-compatible improvements in the `4.x` line +(`>= 4.0, < 5.0`) without unexpectedly jumping to a future `5.0` that +might contain breaking changes. + +After editing your `Gemfile`, run: + +```bash +bundle install +``` + +Then in your Ruby code: + +```ruby +require 'veryfi' +``` + +## Versioning & compatibility + +`veryfi-ruby` follows [Semantic Versioning](https://semver.org/): + +- **MAJOR** (`4.x` → `5.x`): backwards-incompatible API changes. Read the + release notes before upgrading. +- **MINOR** (`4.0.x` → `4.1.0`): new endpoints or new methods, fully + backwards compatible. Safe to upgrade. +- **PATCH** (`4.0.0` → `4.0.1`): bug fixes only. Safe to upgrade. + +| Series | Status | Notes | +| ------- | ----------- | ------------------------------------------------------------------------------------ | +| `4.x` | **Current** | Full coverage of the Veryfi v8 partner API including A-Docs, bank statements, checks (with remittance + async), W-2/W-8/W-9, classify, PDF split, W-2 split, bank-statement split, tax lines, and per-resource tag management. Responses are lightweight `Veryfi::Resource` objects (Hash-compatible). | +| `3.x` | Maintenance | Documents, line items, tags, document tags only. | +| `<=2.x` | Unsupported | Please upgrade. | + +### Supported Ruby versions + +The minimum supported Ruby version is **3.0** (the floor of the Faraday +2.x line, which is the gem's only non-stdlib runtime dependency). CI +verifies the gem against: + +| Ruby | Upstream status | Notes | +| ------ | ---------------------------- | -------------------------------------- | +| `3.0` | End-of-life upstream | Floor — best-effort, no Ruby-side fixes | +| `3.1` | End-of-life upstream | Best-effort | +| `3.2` | Security maintenance | | +| `3.3` | Normal maintenance | | +| `3.4` | Normal maintenance | | +| `4.0` | Current stable | | + +Ruby 2.7 and earlier are not supported. If you need to stay on Ruby 2.7 +pin the `veryfi` 3.x release line, which targeted that vintage of Ruby. + +The gem talks to Veryfi API version **`v8`** by default. You can override +this at construction time if you are working against a different version: + ```ruby -gem 'veryfi', '~> 0.1' +Veryfi::Client.new( + client_id: 'your_client_id', + client_secret: 'your_client_secret', + username: 'your_username', + api_key: 'your_api_key', + api_version: 'v8' # default +) ``` ## Getting Started @@ -244,6 +335,443 @@ If you don't have an account with Veryfi, please go ahead and register here: [ht The **veryfi-ruby** gem can be used to communicate with Veryfi API. All available functionality is described [in docs](https://veryfi.github.io/veryfi-ruby/) +## Configuring the client + +The most explicit way is to instantiate {`Veryfi::Client`} directly: + +```ruby +client = Veryfi::Client.new( + client_id: ENV.fetch('VERYFI_CLIENT_ID'), + client_secret: ENV.fetch('VERYFI_CLIENT_SECRET'), + username: ENV.fetch('VERYFI_USERNAME'), + api_key: ENV.fetch('VERYFI_API_KEY') +) +``` + +For apps that want a single process-wide client (common in Rails / Sidekiq +setups), the `Veryfi.configure` / `Veryfi.client` shortcuts are provided: + +```ruby +# config/initializers/veryfi.rb +Veryfi.configure do |c| + c.client_id = ENV.fetch('VERYFI_CLIENT_ID') + c.client_secret = ENV.fetch('VERYFI_CLIENT_SECRET') + c.username = ENV.fetch('VERYFI_USERNAME') + c.api_key = ENV.fetch('VERYFI_API_KEY') +end + +# anywhere in your app +Veryfi.client.document.process(file_path: './receipt.jpg') +``` + +`Veryfi.client` is memoized; if you re-`configure` the SDK it's rebuilt +automatically on the next access. Call `Veryfi.reset!` in tests to drop +the memoized client and configuration. + +### Custom Faraday configuration + +The underlying HTTP connection is a plain `Faraday::Connection`. Pass a +`faraday:` block to add middleware — retries, logging, persistent +connections, anything Faraday supports: + +```ruby +client = Veryfi::Client.new( + client_id: '…', + client_secret: '…', + username: '…', + api_key: '…', + faraday: ->(conn) { + # automatic exponential backoff on retryable statuses + conn.request :retry, + max: 3, + interval: 0.5, + backoff_factor: 2, + retry_statuses: [429, 502, 503, 504] + + # log every request / response + conn.response :logger, Rails.logger if defined?(Rails) + + # keep TCP connections open between API calls + conn.adapter :net_http_persistent + } +) +``` + +(`faraday-retry` and `faraday-net_http_persistent` are not bundled with +this gem — install whichever extensions you need.) + +## Handling errors + +Every error raised by the SDK descends from `Veryfi::Error::VeryfiError`, +so a catch-all rescue is always valid: + +```ruby +begin + client.document.process(file_path: './receipt.jpg') +rescue Veryfi::Error::VeryfiError => e + Rails.logger.error("Veryfi call failed (status=#{e.status}): #{e.message}") + raise +end +``` + +When you want to react differently per HTTP status, rescue one of the +specific subclasses. Order matters — list narrower classes before broader +ones: + +| HTTP status | Exception class | Typical reason | +| ------------------ | --------------------------------------- | ------------------------------------------- | +| `400 Bad Request` | `Veryfi::Error::BadRequest` | Malformed payload or failed validation | +| `401 Unauthorized` | `Veryfi::Error::Unauthorized` | Bad / missing / expired credentials | +| `403 Forbidden` | `Veryfi::Error::AccessLimitReached` | Credentials lack permission for this action | +| `404 Not Found` | `Veryfi::Error::NotFound` | Resource id does not exist | +| `408 Request Timeout` | `Veryfi::Error::RequestTimeout` | Request timed out before Veryfi responded | +| `409 Conflict` | `Veryfi::Error::Conflict` | Conflicts with current resource state | +| `415 Unsupported Media` | `Veryfi::Error::UnsupportedMediaType` | File type not supported by the endpoint | +| `429 Too Many Requests` | `Veryfi::Error::TooManyRequests` | Rate-limited; back off and retry | +| Other 4xx | `Veryfi::Error::ClientError` | Generic 4xx | +| Any 5xx | `Veryfi::Error::ServerError` | Server-side error; retry with backoff | + +```ruby +begin + client.document.process(file_path: path) +rescue Veryfi::Error::Unauthorized then refresh_credentials! +rescue Veryfi::Error::TooManyRequests then back_off +rescue Veryfi::Error::ServerError then schedule_retry +rescue Veryfi::Error::VeryfiError then notify_ops +end +``` + +Each error exposes `#status` (Integer), `#response` (parsed body as a +`Veryfi::Resource`) and `#message` (the pretty-printed JSON body, falling +back to `""` when the response is empty). + +## Response objects + +Every API call returns a `Veryfi::Resource`. A `Resource` is a tiny +subclass of `Hash`, so anything that already treats the response as a +hash keeps working unchanged — no migration required when upgrading +from earlier versions of this gem: + +```ruby +response = client.document.get(44_691_518) + +response["id"] # => 44691518 +response[:id] # => 44691518 (symbol keys work too) +response.dig("vendor", "name") # => "East Repair" +response.is_a?(Hash) # => true +JSON.pretty_generate(response) # works as you'd expect +``` + +In addition, every key is exposed as a method, recursively: + +```ruby +response.id # => 44691518 +response.vendor.name # => "East Repair" +response.line_items.first.description # => "Brake cables" +response.line_items.map(&:total) # => [100, 30, 15] +response.is_duplicate? # => true (boolean predicate sugar) +``` + +Nested objects become `Resource`s and arrays of objects become arrays +of `Resource`s automatically. Leaf values (strings, numbers, booleans, +`nil`) pass through untouched. Unknown keys raise `NoMethodError`, so +typos surface immediately instead of silently returning `nil`. + +If you need a plain `Hash` (e.g. to hand off to a serializer that +inspects the exact class), call `#to_h`, which recursively unwraps: + +```ruby +response.to_h.class # => Hash +response.to_h["vendor"].class # => Hash +``` + +## Common parameters & defaults + +Most of the "process" methods (`process`, `process_url`, `process_async`, +`process_url_async`, `process_with_remittance`, …) accept the same set +of optional keys. **Omitting a key is exactly equivalent to passing its +default value** — both produce the same outbound request. Only pass a +key when you want a non-default value or want to make the intent +explicit in your own code. + +| Key | Required? | Default | Meaning | +| ----------------------- | ------------------------------------------ | --------------------------------------------- | ------- | +| `file_path` | Yes for `process` / `process_async` etc. | — | Local path to the file to upload. Read and base64-encoded for you. | +| `file_url` | Yes for `process_url` (or `file_urls`) | — | Publicly accessible URL to a single file. | +| `file_urls` | Alternative to `file_url` | — | Array of publicly accessible URLs treated as one logical document. | +| `file_name` | No | basename of `file_path` / `file_url` | Display name sent to Veryfi. Useful when `file_path` is a tempfile with an ugly name. | +| `categories` | No | Veryfi's default list (see `Veryfi::Api::Document::CATEGORIES`) for receipts/invoices; `[]` for split endpoints | Restrict Veryfi's categorization output to this set of strings. | +| `tags` | No | `nil` (no tags) | Array of tag names to attach to the resulting document. | +| `auto_delete` | No | `false` | When `true`, Veryfi deletes the document from its storage immediately after extraction. Use when you don't need Veryfi to keep the file (e.g. you store it yourself). | +| `boost_mode` | No | `false` | When `true`, Veryfi skips data-enrichment steps (vendor lookup, logo, category enrichment, etc.). Processing is **faster** but the response has **less** extracted/enriched data. Useful for high-throughput pipelines that don't need the extras. | +| `async` | No | `false` | When `true`, the request returns immediately (with a processing-status payload) and Veryfi continues extraction in the background. **Prefer the dedicated `.process_async` / `.process_url_async` methods** where available — they hit the canonical async endpoint instead of piggybacking on the sync one. | +| `external_id` | No | `nil` | Your own identifier for the document. Echoed back in the response and searchable via `client.document.all(external_id: …)`. | +| `max_pages_to_process` | No | `nil` (= all pages) | Hard cap on the number of pages Veryfi reads, starting from page 1. Useful for very long PDFs when you only care about the first few pages. | +| `bounding_boxes` | No | `false` | When `true`, the response includes `bounding_box` / `bounding_region` metadata for each extracted field. | +| `confidence_details` | No | `false` | When `true`, the response includes per-field `score` and `ocr_score` values. | + +So all four of the following calls are equivalent and produce the same +request payload — pick whichever style your team prefers: + +```ruby +# All defaults explicit +client.document.process( + file_path: './receipt.jpg', + auto_delete: false, + boost_mode: false, + async: false, + external_id: nil, + max_pages_to_process: nil, + tags: nil, + categories: Veryfi::Api::Document::CATEGORIES +) + +# Defaults omitted (recommended — least noise) +client.document.process(file_path: './receipt.jpg') + +# Only the non-defaults +client.document.process( + file_path: './receipt.jpg', + auto_delete: true, + tags: ['expense'] +) + +# Same call, mixing styles +client.document.process( + file_path: './receipt.jpg', + boost_mode: false, # explicit default — purely for readability + external_id: '123456789' +) +``` + +If you ever need to know the default list of categories at runtime, +it lives at `Veryfi::Api::Document::CATEGORIES`. + +## Available APIs + +The `Veryfi::Client` exposes the full Veryfi API surface through namespaced sub-objects. All sub-objects share the same underlying authenticated `Veryfi::Request`, so a single client instance is enough. + +```ruby +client = Veryfi::Client.new( + client_id: 'your_client_id', + client_secret: 'your_client_secret', + username: 'your_username', + api_key: 'your_api_key' +) +``` + +### Documents + +```ruby +client.document.all # GET /partner/documents/ +client.document.get(document_id) # GET /partner/documents/:id +client.document.process(file_path: 'receipt.jpg') # POST /partner/documents/ (upload) +client.document.process_url(file_url: 'https://...') # POST /partner/documents/ (url) +client.document.process_bulk(%w[https://... https://...]) # POST /partner/documents/bulk/ +client.document.update(document_id, notes: 'edited') # PUT /partner/documents/:id +client.document.delete(document_id) # DELETE /partner/documents/:id +``` + +### Tags + +```ruby +client.tag.all # GET /partner/tags/ +client.tag.delete(tag_id) # DELETE /partner/tags/:id +``` + +### Document Tags + +```ruby +client.document_tag.all(document_id) # GET /partner/documents/:id/tags/ +client.document_tag.add(document_id, name: 'priority') # PUT /partner/documents/:id/tags/ +client.document_tag.add_multiple(document_id, %w[a b c]) # POST /partner/documents/:id/tags/ +client.document_tag.replace(document_id, %w[a b c]) # PUT /partner/documents/:id/ +client.document_tag.delete(document_id, tag_id) # DELETE /partner/documents/:id/tags/:tag_id +client.document_tag.delete_all(document_id) # DELETE /partner/documents/:id/tags/ +``` + +### Line Items + +```ruby +client.line_item.all(document_id) # GET /partner/documents/:id/line-items/ +client.line_item.get(document_id, line_item_id) # GET /partner/documents/:id/line-items/:line_id +client.line_item.create(document_id, description: 'Foo') # POST /partner/documents/:id/line-items/ +client.line_item.update(document_id, line_item_id, discount: 0.9) # PUT /partner/documents/:id/line-items/:line_id +client.line_item.delete(document_id, line_item_id) # DELETE /partner/documents/:id/line-items/:line_id +client.line_item.delete_all(document_id) # DELETE /partner/documents/:id/line-items +``` + +### Tax Lines + +```ruby +client.tax_line.all(document_id) # GET /partner/documents/:id/tax-lines +client.tax_line.get(document_id, tax_line_id) # GET /partner/documents/:id/tax-lines/:tax_id +client.tax_line.create(document_id, name: 'Sales Tax', rate: 6.25) # POST /partner/documents/:id/tax-lines +client.tax_line.update(document_id, tax_line_id, rate: 7.0) # PUT /partner/documents/:id/tax-lines/:tax_id +client.tax_line.delete(document_id, tax_line_id) # DELETE /partner/documents/:id/tax-lines/:tax_id +``` + +### PDF Split (Documents Set) + +Split a multi-page PDF into multiple processed documents. + +```ruby +client.pdf_split.all # GET /partner/documents-set/ +client.pdf_split.get(documents_set_id) # GET /partner/documents-set/:id +client.pdf_split.process(file_path: 'multi.pdf') # POST /partner/documents-set/ (upload) +client.pdf_split.process_url(file_url: 'https://...') # POST /partner/documents-set/ (url) +``` + +### Any Document (A-Docs) + +Process arbitrary document types using a Veryfi blueprint. + +```ruby +client.any_document.all # GET /partner/any-documents/ +client.any_document.get(document_id) # GET /partner/any-documents/:id +client.any_document.process( + blueprint_name: 'us_w2_2022', + file_path: 'doc.pdf' +) # POST /partner/any-documents/ (upload) +client.any_document.process_url( + blueprint_name: 'us_w2_2022', + file_url: 'https://...' +) # POST /partner/any-documents/ (url) +client.any_document.process_async( + blueprint_name: 'us_w2_2022', + file_path: 'doc.pdf' +) # POST /partner/any-documents/async (upload) +client.any_document.process_url_async( + blueprint_name: 'us_w2_2022', + file_url: 'https://...' +) # POST /partner/any-documents/async (url) +client.any_document.update(document_id, notes: 'edited') # PUT /partner/any-documents/:id +client.any_document.delete(document_id) # DELETE /partner/any-documents/:id +``` + +### Bank Statements + +```ruby +client.bank_statement.all # GET /partner/bank-statements/ +client.bank_statement.get(document_id) # GET /partner/bank-statements/:id +client.bank_statement.process(file_path: 'statement.pdf') # POST /partner/bank-statements/ (upload) +client.bank_statement.process_url(file_url: 'https://...') # POST /partner/bank-statements/ (url) +client.bank_statement.process_async(file_path: 'statement.pdf') # POST /partner/bank-statements/async (upload) +client.bank_statement.process_url_async(file_url: 'https://...') # POST /partner/bank-statements/async (url) +client.bank_statement.update(document_id, notes: 'edited') # PUT /partner/bank-statements/:id +client.bank_statement.delete(document_id) # DELETE /partner/bank-statements/:id +``` + +### Bank Statement Split + +Split a multi-statement file into individually processed bank statements. + +```ruby +client.bank_statement_split.all # GET /partner/bank-statements-set/ +client.bank_statement_split.get(id) # GET /partner/bank-statements-set/:id +client.bank_statement_split.process(file_path: 'multi.pdf') # POST /partner/bank-statements-set/ (upload) +client.bank_statement_split.process_url(file_urls: ['https://...']) # POST /partner/bank-statements-set/ (url) +``` + +### Business Cards + +```ruby +client.business_card.all # GET /partner/business-cards/ +client.business_card.get(document_id) # GET /partner/business-cards/:id +client.business_card.process(file_path: 'card.jpg') # POST /partner/business-cards/ (upload) +client.business_card.process_url(file_url: 'https://...') # POST /partner/business-cards/ (url) +client.business_card.update(document_id, company: 'Globex') # PUT /partner/business-cards/:id +client.business_card.delete(document_id) # DELETE /partner/business-cards/:id +``` + +### Checks + +```ruby +client.check.all # GET /partner/checks/ +client.check.get(document_id) # GET /partner/checks/:id +client.check.process(file_path: 'check.jpg') # POST /partner/checks/ (upload) +client.check.process_url(file_url: 'https://...') # POST /partner/checks/ (url) +client.check.process_with_remittance(file_path: 'check.jpg') # POST /partner/check-with-document/ (upload) +client.check.process_with_remittance_url(file_url: 'https://...') # POST /partner/check-with-document/ (url) +client.check.process_async(file_path: 'check.jpg') # POST /partner/checks/async (upload) +client.check.process_url_async(file_url: 'https://...') # POST /partner/checks/async (url) +client.check.update(document_id, notes: 'edited') # PUT /partner/checks/:id +client.check.delete(document_id) # DELETE /partner/checks/:id +``` + +### Classify + +Predict the document type of a file. + +```ruby +client.classify.process( + file_path: 'unknown.jpg', + document_types: %w[invoice receipt w2] +) # POST /partner/classify/ (upload) +client.classify.process_url( + file_url: 'https://...', + document_types: %w[invoice receipt w2] +) # POST /partner/classify/ (url) +``` + +### W-2 + +```ruby +client.w2.all # GET /partner/w2s/ +client.w2.get(document_id) # GET /partner/w2s/:id +client.w2.process(file_path: 'w2.pdf') # POST /partner/w2s/ (upload) +client.w2.process_url(file_url: 'https://...') # POST /partner/w2s/ (url) +client.w2.update(document_id, notes: 'edited') # PUT /partner/w2s/:id +client.w2.delete(document_id) # DELETE /partner/w2s/:id +``` + +### W-2 Split + +Split a file containing multiple W-2 forms into individually processed W-2s. + +```ruby +client.w2_split.all # GET /partner/w2s-set/ +client.w2_split.get(w2s_set_id) # GET /partner/w2s-set/:id +client.w2_split.process(file_path: 'multi_w2.pdf') # POST /partner/w2s-set/ (upload) +client.w2_split.process_url(file_urls: ['https://...']) # POST /partner/w2s-set/ (url) +``` + +### W-8 BEN-E + +```ruby +client.w8.all # GET /partner/w-8ben-e/ +client.w8.get(document_id) # GET /partner/w-8ben-e/:id +client.w8.process(file_path: 'w8.pdf') # POST /partner/w-8ben-e/ (upload) +client.w8.process_url(file_url: 'https://...') # POST /partner/w-8ben-e/ (url) +client.w8.update(document_id, notes: 'edited') # PUT /partner/w-8ben-e/:id +client.w8.delete(document_id) # DELETE /partner/w-8ben-e/:id +``` + +### W-9 + +```ruby +client.w9.all # GET /partner/w9s/ +client.w9.get(document_id) # GET /partner/w9s/:id +client.w9.process(file_path: 'w9.pdf') # POST /partner/w9s/ (upload) +client.w9.process_url(file_url: 'https://...') # POST /partner/w9s/ (url) +client.w9.update(document_id, notes: 'edited') # PUT /partner/w9s/:id +client.w9.delete(document_id) # DELETE /partner/w9s/:id +``` + +### Per-resource tags + +Every processed-document resource (`any_document`, `bank_statement`, `business_card`, `check`, `w2`, `w8`, `w9`) exposes the same tag-management methods directly on the resource, mirroring the per-document tag endpoints: + +```ruby +client.check.tags(check_id) # GET /partner/checks/:id/tags +client.check.add_tag(check_id, name: 'priority') # PUT /partner/checks/:id/tags (single) +client.check.add_tags(check_id, %w[priority urgent]) # POST /partner/checks/:id/tags (multiple) +client.check.delete_tag(check_id, tag_id) # DELETE /partner/checks/:id/tags/:tag_id +client.check.delete_tags(check_id) # DELETE /partner/checks/:id/tags (all) +``` + +The same five methods are available on `client.any_document`, `client.bank_statement`, `client.business_card`, `client.w2`, `client.w8`, and `client.w9`. + ## Need help? If you run into any issue or need help installing or using the library, please contact support@veryfi.com. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..5ec7126 --- /dev/null +++ b/Rakefile @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" + +require "rspec/core/rake_task" +RSpec::Core::RakeTask.new(:spec) + +require "rubocop/rake_task" +RuboCop::RakeTask.new(:rubocop) + +begin + require "yard" + YARD::Rake::YardocTask.new(:yard) +rescue LoadError + # yard is not required to run the test suite — silently skip if missing +end + +desc "Run the full quality + test suite" +task ci: %i[rubocop spec] + +task default: :ci diff --git a/lib/veryfi.rb b/lib/veryfi.rb index 10f0a57..f3cc9b3 100644 --- a/lib/veryfi.rb +++ b/lib/veryfi.rb @@ -4,14 +4,78 @@ module Veryfi autoload :VERSION, "veryfi/version" autoload :Signature, "veryfi/signature" autoload :Request, "veryfi/request" + autoload :Resource, "veryfi/resource" autoload :Error, "veryfi/error" + autoload :Configuration, "veryfi/configuration" module Api + autoload :FilePayload, "veryfi/api/file_payload" + autoload :TagOperations, "veryfi/api/tag_operations" + autoload :Document, "veryfi/api/document" autoload :LineItem, "veryfi/api/line_item" + autoload :TaxLine, "veryfi/api/tax_line" autoload :Tag, "veryfi/api/tag" autoload :DocumentTag, "veryfi/api/document_tag" + + autoload :AnyDocument, "veryfi/api/any_document" + autoload :BankStatement, "veryfi/api/bank_statement" + autoload :BankStatementSplit, "veryfi/api/bank_statement_split" + autoload :BusinessCard, "veryfi/api/business_card" + autoload :Check, "veryfi/api/check" + autoload :Classify, "veryfi/api/classify" + autoload :PdfSplit, "veryfi/api/pdf_split" + autoload :W2, "veryfi/api/w2" + autoload :W2Split, "veryfi/api/w2_split" + autoload :W8, "veryfi/api/w8" + autoload :W9, "veryfi/api/w9" end autoload :Client, "veryfi/client" + + class << self + # Process-wide configuration, used by {Veryfi.client} as defaults when + # constructing the shared client. You can still call + # `Veryfi::Client.new(...)` directly to bypass this entirely. + # + # @example + # Veryfi.configure do |c| + # c.client_id = ENV.fetch("VERYFI_CLIENT_ID") + # c.client_secret = ENV.fetch("VERYFI_CLIENT_SECRET") + # c.username = ENV.fetch("VERYFI_USERNAME") + # c.api_key = ENV.fetch("VERYFI_API_KEY") + # end + # + # Veryfi.client.document.process(file_path: "./receipt.jpg") + # + # @yieldparam config [Veryfi::Configuration] + # @return [Veryfi::Configuration] + def configure + yield(configuration) if block_given? + configuration + end + + # @return [Veryfi::Configuration] the global configuration object. + def configuration + @_configuration ||= Veryfi::Configuration.new + end + + # Process-wide memoized {Veryfi::Client} built from {.configuration}. + # Resets if you re-{.configure} the SDK after first use. + # + # @return [Veryfi::Client] + def client + @_client = nil if @_last_configuration_hash && @_last_configuration_hash != configuration.to_h + @_last_configuration_hash = configuration.to_h + @_client ||= Veryfi::Client.new(**configuration.to_h.compact) + end + + # Drop the memoized {.client} and {.configuration}. Mostly useful in tests. + # @return [void] + def reset! + @_configuration = nil + @_client = nil + @_last_configuration_hash = nil + end + end end diff --git a/lib/veryfi/api/any_document.rb b/lib/veryfi/api/any_document.rb new file mode 100644 index 0000000..c42a7f8 --- /dev/null +++ b/lib/veryfi/api/any_document.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # Any Document (a-doc) endpoints (`/partner/any-documents/`). + # + # An A-Doc is a custom document type, defined by a *blueprint* you + # configure in Veryfi. Every "process" call must therefore include a + # `:blueprint_name`. + # + # @see https://docs.veryfi.com/api/anydocs/ + class AnyDocument + include FilePayload + include TagOperations + + ENDPOINT = "/partner/any-documents/" + ASYNC_ENDPOINT = "/partner/any-documents/async" + + attr_reader :request + + def initialize(request) + @request = request + end + + # List previously processed A-Docs. + # + # @param params [Hash] optional query-string parameters + # @option params [String] :created_date__gt "YYYY-MM-DD HH:MM:SS" — strictly after + # @option params [String] :created_date__gte after or equal + # @option params [String] :created_date__lt strictly before + # @option params [String] :created_date__lte before or equal + # @option params [Integer] :page (1) + # @option params [Integer] :page_size (50) + # @return [Veryfi::Resource] `{ "documents" => [...] }` + def all(params = {}) + request.get(ENDPOINT, params) + end + + # Fetch a single A-Doc by id. + # + # @param id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def get(id, params = {}) + request.get("#{ENDPOINT}#{id}/", params) + end + + # Upload a file and extract its fields using the given blueprint. + # + # @param raw_params [Hash] + # @option raw_params [String] :blueprint_name **required.** Blueprint id to apply. + # @option raw_params [String] :file_path **required.** Local path to the file. + # @option raw_params [String] :file_name (basename of `:file_path`) + # @return [Veryfi::Resource] + def process(raw_params) + params = raw_params.transform_keys(&:to_sym) + blueprint_name = params.delete(:blueprint_name) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + payload = file_payload(file_path, file_name).merge(blueprint_name: blueprint_name).merge(params) + + request.post(ENDPOINT, payload) + end + + # Process an A-Doc from a public URL. + # + # @param raw_params [Hash] + # @option raw_params [String] :blueprint_name **required.** + # @option raw_params [String] :file_url **required** (single URL). + # @option raw_params [String] :file_name (basename of `:file_url`) + # @return [Veryfi::Resource] + def process_url(raw_params) + params = raw_params.transform_keys(&:to_sym) + params[:file_name] ||= File.basename(params[:file_url]) if params[:file_url] + + request.post(ENDPOINT, params) + end + + # Same as {#process} but asynchronous — returns immediately with a + # `"status": "processing"` payload while Veryfi runs the extraction + # in the background. Use a webhook (or poll {#get}) to learn when the + # final result is ready. + # + # @see https://docs.veryfi.com/api/anydocs/process-a-doc-async/ + def process_async(raw_params) + params = raw_params.transform_keys(&:to_sym) + blueprint_name = params.delete(:blueprint_name) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + payload = file_payload(file_path, file_name).merge(blueprint_name: blueprint_name).merge(params) + + request.post(ASYNC_ENDPOINT, payload) + end + + # URL variant of {#process_async}. + def process_url_async(raw_params) + params = raw_params.transform_keys(&:to_sym) + params[:file_name] ||= File.basename(params[:file_url]) if params[:file_url] + + request.post(ASYNC_ENDPOINT, params) + end + + # Update writable fields on a processed A-Doc. + # + # @param id [Integer] + # @param params [Hash] writable fields + # @return [Veryfi::Resource] + def update(id, params) + request.put("#{ENDPOINT}#{id}/", params) + end + + # Delete an A-Doc. + # + # @param id [Integer] + # @return [Veryfi::Resource] + def delete(id) + request.delete("#{ENDPOINT}#{id}/") + end + end + end +end diff --git a/lib/veryfi/api/bank_statement.rb b/lib/veryfi/api/bank_statement.rb new file mode 100644 index 0000000..9d6471a --- /dev/null +++ b/lib/veryfi/api/bank_statement.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # Bank Statements endpoints (`/partner/bank-statements/`). + # + # @see https://docs.veryfi.com/api/bank-statements/ + class BankStatement + include FilePayload + include TagOperations + + ENDPOINT = "/partner/bank-statements/" + ASYNC_ENDPOINT = "/partner/bank-statements/async" + + attr_reader :request + + def initialize(request) + @request = request + end + + # List previously processed bank statements. + # + # @param params [Hash] optional query-string parameters + # @option params [String] :created_date__gt "YYYY-MM-DD HH:MM:SS" — strictly after + # @option params [String] :created_date__gte after or equal + # @option params [String] :created_date__lt strictly before + # @option params [String] :created_date__lte before or equal + # @option params [Integer] :page (1) + # @option params [Integer] :page_size (50) + # @return [Veryfi::Resource] `{ "documents" => [...] }` + def all(params = {}) + request.get(ENDPOINT, params) + end + + # Fetch a single bank statement by id. + # + # @param id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def get(id, params = {}) + request.get("#{ENDPOINT}#{id}/", params) + end + + # Upload a file and extract bank-statement data. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** Local path. + # @option raw_params [String] :file_name (basename of `:file_path`) + # @option raw_params [Array] :categories (`nil`) Optional categories used to classify + # transactions, e.g. `["Transfer", "Credit Card Payments"]`. + # @return [Veryfi::Resource] + def process(raw_params) + params = raw_params.transform_keys(&:to_sym) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + payload = file_payload(file_path, file_name).merge(params) + + request.post(ENDPOINT, payload) + end + + # URL variant of {#process}. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_url **required.** Publicly accessible URL. + # @option raw_params [String] :file_name (basename of `:file_url`) + # @option raw_params [Array] :categories (`nil`) + # @return [Veryfi::Resource] + def process_url(raw_params) + params = raw_params.transform_keys(&:to_sym) + params[:file_name] ||= File.basename(params[:file_url]) if params[:file_url] + + request.post(ENDPOINT, params) + end + + # Async variant of {#process} — returns immediately while Veryfi + # extracts the data in the background. + def process_async(raw_params) + params = raw_params.transform_keys(&:to_sym) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + payload = file_payload(file_path, file_name).merge(params) + + request.post(ASYNC_ENDPOINT, payload) + end + + # Async variant of {#process_url}. + def process_url_async(raw_params) + params = raw_params.transform_keys(&:to_sym) + params[:file_name] ||= File.basename(params[:file_url]) if params[:file_url] + + request.post(ASYNC_ENDPOINT, params) + end + + # Update writable fields on a processed bank statement. + # + # @param id [Integer] + # @param params [Hash] + # @return [Veryfi::Resource] + def update(id, params) + request.put("#{ENDPOINT}#{id}/", params) + end + + # Delete a bank statement. + # + # @param id [Integer] + # @return [Veryfi::Resource] + def delete(id) + request.delete("#{ENDPOINT}#{id}/") + end + end + end +end diff --git a/lib/veryfi/api/bank_statement_split.rb b/lib/veryfi/api/bank_statement_split.rb new file mode 100644 index 0000000..0e140a4 --- /dev/null +++ b/lib/veryfi/api/bank_statement_split.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # Bank-statement splitting endpoints (`/partner/bank-statements-set/`). + # + # Use these when you have a single file containing multiple bank + # statements (e.g. an annual archive PDF). Veryfi will split the file + # and process each statement separately; you receive a collection that + # references the individual {BankStatement} ids it produced. + class BankStatementSplit + include FilePayload + + ENDPOINT = "/partner/bank-statements-set/" + + attr_reader :request + + def initialize(request) + @request = request + end + + # List previously processed bank-statement sets. + # + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def all(params = {}) + request.get(ENDPOINT, params) + end + + # Fetch a single bank-statement set by id. + # + # @param id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def get(id, params = {}) + request.get("#{ENDPOINT}#{id}", params) + end + + # Upload a multi-statement file and split-and-process it. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** Local path. + # @option raw_params [String] :file_name (basename of `:file_path`) + # @return [Veryfi::Resource] + def process(raw_params) + params = raw_params.transform_keys(&:to_sym) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + payload = file_payload(file_path, file_name).merge(params) + + request.post(ENDPOINT, payload) + end + + # URL variant of {#process}. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_url single URL + # @option raw_params [Array] :file_urls list of URLs (alternative to `:file_url`) + # @return [Veryfi::Resource] + def process_url(raw_params) + request.post(ENDPOINT, raw_params.transform_keys(&:to_sym)) + end + end + end +end diff --git a/lib/veryfi/api/business_card.rb b/lib/veryfi/api/business_card.rb new file mode 100644 index 0000000..3110735 --- /dev/null +++ b/lib/veryfi/api/business_card.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # Business Cards endpoints (`/partner/business-cards/`). + # + # @see https://docs.veryfi.com/api/business-cards/ + class BusinessCard + include FilePayload + include TagOperations + + ENDPOINT = "/partner/business-cards/" + + attr_reader :request + + def initialize(request) + @request = request + end + + # List previously processed business cards. + # + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] `{ "documents" => [...] }` + def all(params = {}) + request.get(ENDPOINT, params) + end + + # Fetch a single business card by id. + # + # @param id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def get(id, params = {}) + request.get("#{ENDPOINT}#{id}/", params) + end + + # Upload an image of a business card and extract its contact fields. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** Local path. + # @option raw_params [String] :file_name (basename of `:file_path`) + # @return [Veryfi::Resource] + def process(raw_params) + params = raw_params.transform_keys(&:to_sym) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + payload = file_payload(file_path, file_name).merge(params) + + request.post(ENDPOINT, payload) + end + + # URL variant of {#process}. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_url **required.** + # @option raw_params [String] :file_name (basename of `:file_url`) + # @return [Veryfi::Resource] + def process_url(raw_params) + params = raw_params.transform_keys(&:to_sym) + params[:file_name] ||= File.basename(params[:file_url]) if params[:file_url] + + request.post(ENDPOINT, params) + end + + # Update writable fields on a processed business card. + # + # @param id [Integer] + # @param params [Hash] writable fields + # @return [Veryfi::Resource] + def update(id, params) + request.put("#{ENDPOINT}#{id}/", params) + end + + # Delete a business card. + # + # @param id [Integer] + # @return [Veryfi::Resource] + def delete(id) + request.delete("#{ENDPOINT}#{id}/") + end + end + end +end diff --git a/lib/veryfi/api/check.rb b/lib/veryfi/api/check.rb new file mode 100644 index 0000000..133dddc --- /dev/null +++ b/lib/veryfi/api/check.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # Checks endpoints (`/partner/checks/` and + # `/partner/check-with-document/` for combined check + remittance + # extraction). + # + # @see https://docs.veryfi.com/api/checks/ + class Check + include FilePayload + include TagOperations + + ENDPOINT = "/partner/checks/" + REMITTANCE_ENDPOINT = "/partner/check-with-document/" + ASYNC_ENDPOINT = "/partner/checks/async" + + attr_reader :request + + def initialize(request) + @request = request + end + + # List previously processed checks. + # + # @param params [Hash] optional query-string parameters + # @option params [String] :created_date__gt "YYYY-MM-DD HH:MM:SS" — strictly after + # @option params [String] :created_date__gte after or equal + # @option params [String] :created_date__lt strictly before + # @option params [String] :created_date__lte before or equal + # @option params [Integer] :page (1) + # @option params [Integer] :page_size (50) + # @return [Veryfi::Resource] `{ "documents" => [...] }` + def all(params = {}) + request.get(ENDPOINT, params) + end + + # Fetch a single check by id. + # + # @param id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def get(id, params = {}) + request.get("#{ENDPOINT}#{id}/", params) + end + + # Upload a check image and extract its fields. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** Local path. + # @option raw_params [String] :file_name (basename of `:file_path`) + # @return [Veryfi::Resource] + def process(raw_params) + request.post(ENDPOINT, build_file_payload(raw_params)) + end + + # URL variant of {#process}. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_url single URL + # @option raw_params [Array] :file_urls list of URLs (alternative to `:file_url`) + # @return [Veryfi::Resource] + def process_url(raw_params) + request.post(ENDPOINT, raw_params.transform_keys(&:to_sym)) + end + + # Process a check together with its remittance/stub document. + # Veryfi will OCR both halves and return a single combined response. + # + # @see https://docs.veryfi.com/api/checks/process-a-check-with-remittance/ + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** + # @option raw_params [String] :file_name (basename of `:file_path`) + # @return [Veryfi::Resource] + def process_with_remittance(raw_params) + request.post(REMITTANCE_ENDPOINT, build_file_payload(raw_params)) + end + + # URL variant of {#process_with_remittance}. + def process_with_remittance_url(raw_params) + request.post(REMITTANCE_ENDPOINT, raw_params.transform_keys(&:to_sym)) + end + + # Async variant of {#process} — returns immediately while Veryfi + # processes the check in the background. + def process_async(raw_params) + request.post(ASYNC_ENDPOINT, build_file_payload(raw_params)) + end + + # Async variant of {#process_url}. + def process_url_async(raw_params) + request.post(ASYNC_ENDPOINT, raw_params.transform_keys(&:to_sym)) + end + + # Update writable fields on a processed check. + # + # @example + # client.check.update(check_id, notes: "needs review") + # + # @param id [Integer] + # @param params [Hash] + # @return [Veryfi::Resource] + def update(id, params) + request.put("#{ENDPOINT}#{id}/", params) + end + + # Delete a check. + # + # @param id [Integer] + # @return [Veryfi::Resource] + def delete(id) + request.delete("#{ENDPOINT}#{id}/") + end + + private + + def build_file_payload(raw_params) + params = raw_params.transform_keys(&:to_sym) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + file_payload(file_path, file_name).merge(params) + end + end + end +end diff --git a/lib/veryfi/api/classify.rb b/lib/veryfi/api/classify.rb new file mode 100644 index 0000000..96adc04 --- /dev/null +++ b/lib/veryfi/api/classify.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # Document classification endpoint (`/partner/classify/`). + # + # Given a file and an optional set of candidate document types, + # returns Veryfi's best guess at what kind of document it is (invoice, + # receipt, w-2, …) along with confidence scores. + # + # @see https://docs.veryfi.com/api/classify/classify-a-document/ + class Classify + include FilePayload + + ENDPOINT = "/partner/classify/" + + attr_reader :request + + def initialize(request) + @request = request + end + + # Upload a local file for classification. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** Local path. + # @option raw_params [String] :file_name (basename of `:file_path`) + # @option raw_params [Array] :document_types (`nil` = use Veryfi's full default set) + # Restrict the classifier to these document types. + # @return [Veryfi::Resource] e.g. `{ "document_type" => "invoice", "score" => 0.98, ... }` + def process(raw_params) + params = raw_params.transform_keys(&:to_sym) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + payload = file_payload(file_path, file_name).merge(params) + + request.post(ENDPOINT, payload) + end + + # URL variant of {#process}. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_url single URL + # @option raw_params [Array] :file_urls list of URLs (alternative) + # @option raw_params [Array] :document_types (`nil`) + # @return [Veryfi::Resource] + def process_url(raw_params) + request.post(ENDPOINT, raw_params.transform_keys(&:to_sym)) + end + end + end +end diff --git a/lib/veryfi/api/document.rb b/lib/veryfi/api/document.rb index edc3d3e..736a774 100644 --- a/lib/veryfi/api/document.rb +++ b/lib/veryfi/api/document.rb @@ -4,7 +4,14 @@ module Veryfi module Api + # Receipts & invoices endpoints (`/partner/documents/`). + # + # @see https://docs.veryfi.com/api/receipts-invoices/ class Document + # Default categories sent with `process` / `process_url` when the + # caller does not supply their own `:categories` list. Veryfi will + # bucket the document into one of these values for the `category` + # field on the response. CATEGORIES = [ "Advertising & Marketing", "Automotive", @@ -29,10 +36,63 @@ def initialize(request) @request = request end + # List previously processed documents. + # + # @see https://docs.veryfi.com/api/receipts-invoices/search-documents/ + # + # @param params [Hash] query-string parameters (all optional) + # @option params [String] :q free-text search query + # @option params [String] :external_id filter by your own document id + # @option params [String] :tag filter by tag name + # @option params [String] :created__gt "YYYY-MM-DD HH:MM:SS" — strictly after + # @option params [String] :created__gte "YYYY-MM-DD HH:MM:SS" — after or equal + # @option params [String] :created__lt "YYYY-MM-DD HH:MM:SS" — strictly before + # @option params [String] :created__lte "YYYY-MM-DD HH:MM:SS" — before or equal + # @option params [Integer] :page 1-indexed page number (default 1) + # @option params [Integer] :page_size items per page (default 50) + # + # @return [Veryfi::Resource] `{ "documents" => [...] }` def all(params = {}) request.get("/partner/documents/", params) end + # Upload a local file and extract its data. + # + # Required: + # * `:file_path` — path on disk to the file to process. + # + # All other keys below are optional. **Omitting a key is equivalent to + # passing its default value** — both produce the same API call. Pass a + # key explicitly only when you want a non-default value or when you + # want to make the intent explicit in your code. + # + # @see https://docs.veryfi.com/api/receipts-invoices/process-a-document/ + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** Local path to the file. + # @option raw_params [String] :file_name (basename of `:file_path`, extension stripped) + # Display name sent to Veryfi. + # @option raw_params [Array] :categories ({CATEGORIES}) + # Restrict Veryfi's categorization to this set. + # @option raw_params [Array] :tags (`nil`) + # Tags to attach to the resulting document. + # @option raw_params [Boolean] :auto_delete (`false`) + # If true, delete from Veryfi storage right after extraction. + # @option raw_params [Boolean] :boost_mode (`false`) + # Skip data enrichment for faster, less-accurate processing. + # @option raw_params [Boolean] :async (`false`) + # Return immediately; the document keeps processing server-side. + # Prefer the dedicated async endpoint where available. + # @option raw_params [String] :external_id (`nil`) + # Your own identifier to associate with the document. + # @option raw_params [Integer] :max_pages_to_process (`nil` = all pages) + # Cap pages read, starting from page 1. + # @option raw_params [Hash] :bounding_boxes (`false`) + # Return bounding-box info for extracted fields. + # @option raw_params [Hash] :confidence_details (`false`) + # Return confidence score details. + # + # @return [Veryfi::Resource] Extracted document data. def process(raw_params) params = setup_create_params(raw_params) @@ -48,20 +108,77 @@ def process(raw_params) request.post("/partner/documents/", payload) end + # Process a document from a public URL. + # + # Either `:file_url` (single) or `:file_urls` (multiple, processed as + # one logical document) must be present. All other params behave the + # same as in {#process}. + # + # @see https://docs.veryfi.com/api/receipts-invoices/process-a-document/ + # + # @param raw_params [Hash] + # @option raw_params [String] :file_url publicly accessible URL to a single file + # @option raw_params [Array] :file_urls list of publicly accessible URLs + # @option raw_params [Array] :categories ({CATEGORIES}) + # @option raw_params [Array] :tags (`nil`) + # @option raw_params [Boolean] :auto_delete (`false`) + # @option raw_params [Boolean] :boost_mode (`false`) + # @option raw_params [Boolean] :async (`false`) + # @option raw_params [String] :external_id (`nil`) + # @option raw_params [Integer] :max_pages_to_process (`nil` = all pages) + # + # @return [Veryfi::Resource] def process_url(raw_params) params = setup_create_params(raw_params) request.post("/partner/documents/", params) end + # Bulk-process many documents from URLs in a single call. + # + # @see https://docs.veryfi.com/api/receipts-invoices/bulk-process-multiple-documents/ + # @note This endpoint must be enabled for your account. Contact support@veryfi.com. + # + # @param file_urls [Array] publicly accessible URLs + # @return [Veryfi::Resource] `{ "document_ids" => [...] }` + def process_bulk(file_urls) + request.post("/partner/documents/bulk/", file_urls: file_urls) + end + + # Fetch a single document by id. + # + # @see https://docs.veryfi.com/api/receipts-invoices/get-a-document/ + # + # @param id [Integer] document id + # @param params [Hash] query-string parameters (all optional) + # @option params [Boolean] :bounding_boxes (`false`) Include bounding boxes in the response. + # @option params [Boolean] :confidence_details (`false`) Include per-field confidence scores. + # + # @return [Veryfi::Resource] def get(id, params = {}) request.get("/partner/documents/#{id}", params) end + # Update writable fields on a previously processed document. + # + # @example + # client.document.update(44_691_518, date: "2021-01-01", notes: "look what I did") + # + # @see https://docs.veryfi.com/api/receipts-invoices/update-a-document/ + # + # @param id [Integer] document id + # @param params [Hash] any writable fields you want to change + # @return [Veryfi::Resource] the updated document def update(id, params) request.put("/partner/documents/#{id}", params) end + # Delete a document. + # + # @see https://docs.veryfi.com/api/receipts-invoices/delete-a-document/ + # + # @param id [Integer] + # @return [Veryfi::Resource] `{ "status" => "ok", "message" => "..." }` def delete(id) request.delete("/partner/documents/#{id}") end diff --git a/lib/veryfi/api/document_tag.rb b/lib/veryfi/api/document_tag.rb index 254f1f2..848428c 100644 --- a/lib/veryfi/api/document_tag.rb +++ b/lib/veryfi/api/document_tag.rb @@ -2,6 +2,9 @@ module Veryfi module Api + # Tags on a specific document (`/partner/documents/{id}/tags/`). + # + # @see https://docs.veryfi.com/api/receipts-invoices/get-document-tags/ class DocumentTag attr_reader :request @@ -9,20 +12,60 @@ def initialize(request) @request = request end + # List tags on a document. + # + # @param document_id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Array] the `"tags"` array from the response def all(document_id, params = {}) response = request.get("/partner/documents/#{document_id}/tags/", params) response["tags"] end + # Add a single tag to a document. (PUT semantics — creates the tag if + # it does not exist, otherwise links the existing tag.) + # + # @param document_id [Integer] + # @param params [Hash] + # @option params [String] :name **required.** Tag name. + # @return [Veryfi::Resource] the linked tag def add(document_id, params) request.put("/partner/documents/#{document_id}/tags/", params) end + # Add many tags to a document in a single call. + # + # @param document_id [Integer] + # @param tags [Array] tag names + # @return [Veryfi::Resource] `{ "tags" => [...] }` + def add_multiple(document_id, tags) + request.post("/partner/documents/#{document_id}/tags/", tags: tags) + end + + # Replace the entire tag list on a document (PUT on the parent + # resource with a `tags:` array). + # + # @param document_id [Integer] + # @param tags [Array] new full list of tag names + # @return [Veryfi::Resource] the updated document + def replace(document_id, tags) + request.put("/partner/documents/#{document_id}/", tags: tags) + end + + # Unlink every tag from a document. + # + # @param document_id [Integer] + # @return [Veryfi::Resource] def delete_all(document_id) request.delete("/partner/documents/#{document_id}/tags/") end + # Unlink a single tag from a document. + # + # @param document_id [Integer] + # @param id [Integer] tag id + # @return [Veryfi::Resource] def delete(document_id, id) request.delete("/partner/documents/#{document_id}/tags/#{id}") end diff --git a/lib/veryfi/api/file_payload.rb b/lib/veryfi/api/file_payload.rb new file mode 100644 index 0000000..896293d --- /dev/null +++ b/lib/veryfi/api/file_payload.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "base64" + +module Veryfi + module Api + # Mix-in providing a small helper to turn a local file path into the + # `{ file_name:, file_data: }` payload that all of Veryfi's "process" + # endpoints expect. + module FilePayload + private + + def file_payload(file_path, file_name = nil) + encoded = Base64.encode64(File.read(file_path)).gsub("\n", "") + + { + file_name: file_name || File.basename(file_path), + file_data: encoded + } + end + end + end +end diff --git a/lib/veryfi/api/line_item.rb b/lib/veryfi/api/line_item.rb index d050deb..4dc3228 100644 --- a/lib/veryfi/api/line_item.rb +++ b/lib/veryfi/api/line_item.rb @@ -2,6 +2,9 @@ module Veryfi module Api + # Line items on a processed document (`/partner/documents/{id}/line-items/`). + # + # @see https://docs.veryfi.com/api/receipts-invoices/get-document-line-items/ class LineItem attr_reader :request @@ -9,26 +12,78 @@ def initialize(request) @request = request end + # List all line items on a document. + # + # @param document_id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Array] line items (the `"line_items"` array from the response) def all(document_id, params = {}) response = request.get("/partner/documents/#{document_id}/line-items/", params) response["line_items"] end + # Add a line item to an existing document. + # + # @see https://docs.veryfi.com/api/receipts-invoices/create-a-line-item/ + # + # @param document_id [Integer] + # @param params [Hash] line-item body. Common fields: + # @option params [String] :description **required.** Free-text description. + # @option params [Numeric] :total **required.** Line total. + # @option params [Numeric] :quantity (`1`) + # @option params [Numeric] :price Unit price. + # @option params [Numeric] :tax Tax amount. + # @option params [Numeric] :tax_rate Tax rate (%). + # @option params [Numeric] :discount Discount amount. + # @option params [String] :sku SKU / product code. + # @option params [String] :type "product" / "service" / "fuel" / ... + # @option params [String] :unit_of_measure + # @option params [Integer] :order Display order (0-indexed). + # @return [Veryfi::Resource] the created line item def create(document_id, params) request.post("/partner/documents/#{document_id}/line-items/", params) end + # Fetch a single line item. + # + # @param document_id [Integer] + # @param id [Integer] line item id + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] def get(document_id, id, params = {}) request.get("/partner/documents/#{document_id}/line-items/#{id}", params) end + # Update a line item. + # + # @see https://docs.veryfi.com/api/receipts-invoices/update-a-line-item/ + # + # @param document_id [Integer] + # @param id [Integer] + # @param params [Hash] writable fields you want to change + # @return [Veryfi::Resource] the updated line item def update(document_id, id, params) request.put("/partner/documents/#{document_id}/line-items/#{id}", params) end + # Delete a single line item. + # + # @param document_id [Integer] + # @param id [Integer] + # @return [Veryfi::Resource] `{ "status" => "ok", "message" => "..." }` def delete(document_id, id) request.delete("/partner/documents/#{document_id}/line-items/#{id}") end + + # Delete every line item on a document. + # + # @see https://docs.veryfi.com/api/receipts-invoices/delete-all-document-line-items/ + # + # @param document_id [Integer] + # @return [Veryfi::Resource] + def delete_all(document_id) + request.delete("/partner/documents/#{document_id}/line-items") + end end end end diff --git a/lib/veryfi/api/pdf_split.rb b/lib/veryfi/api/pdf_split.rb new file mode 100644 index 0000000..24282d2 --- /dev/null +++ b/lib/veryfi/api/pdf_split.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # PDF splitting endpoints (`/partner/documents-set/`). + # + # Use these when you have a single PDF containing multiple receipts / + # invoices. Veryfi will split it and process each page as its own + # {Document}; you receive a collection that references the individual + # document ids it produced. + # + # @see https://docs.veryfi.com/api/receipts-invoices/split-and-process-a-pdf/ + class PdfSplit + include FilePayload + + ENDPOINT = "/partner/documents-set/" + + attr_reader :request + + def initialize(request) + @request = request + end + + # List previously processed document sets. + # + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def all(params = {}) + request.get(ENDPOINT, params) + end + + # Fetch a single document set by id. + # + # @param id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def get(id, params = {}) + request.get("#{ENDPOINT}#{id}", params) + end + + # Upload a multi-document PDF and split-and-process it. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** Local path. + # @option raw_params [String] :file_name (basename of `:file_path`) + # @option raw_params [Array] :categories (`[]`) Restrict categorization to these values. + # @return [Veryfi::Resource] + def process(raw_params) + params = raw_params.transform_keys(&:to_sym) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + params[:categories] ||= [] + + payload = file_payload(file_path, file_name).merge(params) + + request.post(ENDPOINT, payload) + end + + # URL variant of {#process}. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_url single URL + # @option raw_params [Array] :file_urls list of URLs (alternative to `:file_url`) + # @option raw_params [Array] :categories (`[]`) + # @option raw_params [Integer] :max_pages_to_process (`nil`) + # @return [Veryfi::Resource] + def process_url(raw_params) + params = raw_params.transform_keys(&:to_sym) + params[:categories] ||= [] + + request.post(ENDPOINT, params) + end + end + end +end diff --git a/lib/veryfi/api/tag.rb b/lib/veryfi/api/tag.rb index f61ff22..94ed61f 100644 --- a/lib/veryfi/api/tag.rb +++ b/lib/veryfi/api/tag.rb @@ -2,6 +2,12 @@ module Veryfi module Api + # Global tag catalog (`/partner/tags/`). + # + # Note: This endpoint is not present in every Veryfi API version. For + # managing tags on a specific document use {Veryfi::Api::DocumentTag} + # (or the `.tags` / `.add_tag` / `.add_tags` / `.delete_tag` methods + # on each processed-document resource — see {TagOperations}). class Tag attr_reader :request @@ -9,12 +15,20 @@ def initialize(request) @request = request end + # List every tag known to the account. + # + # @param params [Hash] optional query-string parameters + # @return [Array] the `"tags"` array from the response def all(params = {}) response = request.get("/partner/tags/", params) response["tags"] end + # Delete a tag by id. + # + # @param id [Integer] + # @return [Veryfi::Resource] def delete(id) request.delete("/partner/tags/#{id}") end diff --git a/lib/veryfi/api/tag_operations.rb b/lib/veryfi/api/tag_operations.rb new file mode 100644 index 0000000..d87cd0b --- /dev/null +++ b/lib/veryfi/api/tag_operations.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # Mix-in providing the standard per-resource tag endpoints used by + # processed-document resources (Any Document, Bank Statement, Business + # Card, Check, W-2, W-8, W-9). Each host must define an `ENDPOINT` + # constant pointing at the resource collection (e.g. `"/partner/checks/"`). + # + # When included, the host class exposes: + # + # - `#tags(id, params = {})` → GET `…/{id}/tags` + # - `#add_tag(id, params)` → PUT `…/{id}/tags` (single) + # - `#add_tags(id, tags)` → POST `…/{id}/tags` (multiple) + # - `#delete_tag(id, tag_id)` → DELETE `…/{id}/tags/{tag_id}` + # - `#delete_tags(id)` → DELETE `…/{id}/tags` (all) + module TagOperations + # List tags on the resource. + # + # @param id [Integer] resource id + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] `{ "tags" => [...] }` + def tags(id, params = {}) + request.get("#{self.class::ENDPOINT}#{id}/tags", params) + end + + # Add (or link) a single tag. + # + # @param id [Integer] resource id + # @param params [Hash] e.g. `{ name: "priority" }` + # @return [Veryfi::Resource] the linked tag + def add_tag(id, params) + request.put("#{self.class::ENDPOINT}#{id}/tags", params) + end + + # Add many tags in a single call. + # + # @param id [Integer] resource id + # @param tags [Array] tag names + # @return [Veryfi::Resource] `{ "tags" => [...] }` + def add_tags(id, tags) + request.post("#{self.class::ENDPOINT}#{id}/tags", tags: tags) + end + + # Unlink a single tag from the resource. + # + # @param id [Integer] resource id + # @param tag_id [Integer] + # @return [Veryfi::Resource] + def delete_tag(id, tag_id) + request.delete("#{self.class::ENDPOINT}#{id}/tags/#{tag_id}") + end + + # Unlink every tag from the resource. + # + # @param id [Integer] resource id + # @return [Veryfi::Resource] + def delete_tags(id) + request.delete("#{self.class::ENDPOINT}#{id}/tags") + end + end + end +end diff --git a/lib/veryfi/api/tax_line.rb b/lib/veryfi/api/tax_line.rb new file mode 100644 index 0000000..922921f --- /dev/null +++ b/lib/veryfi/api/tax_line.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # Tax lines on a processed document + # (`/partner/documents/{id}/tax-lines`). + # + # Most documents have at most a handful of tax lines (e.g. one for state + # sales tax, one for local). This namespace lets you list, add, edit + # and remove them on demand. + class TaxLine + attr_reader :request + + def initialize(request) + @request = request + end + + # List the tax lines for a document. + # + # @param document_id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] `{ "tax_lines" => [...] }` + def all(document_id, params = {}) + request.get("/partner/documents/#{document_id}/tax-lines", params) + end + + # Create a tax line on a document. + # + # @param document_id [Integer] + # @param params [Hash] + # @option params [String] :name **required.** e.g. "Sales Tax". + # @option params [Numeric] :rate Tax rate (%). + # @option params [Numeric] :base Taxable base amount. + # @option params [Numeric] :total Tax amount. + # @option params [Integer] :order Display order (0-indexed). + # @return [Veryfi::Resource] the created tax line + def create(document_id, params) + request.post("/partner/documents/#{document_id}/tax-lines", params) + end + + # Fetch a single tax line. + # + # @param document_id [Integer] + # @param id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def get(document_id, id, params = {}) + request.get("/partner/documents/#{document_id}/tax-lines/#{id}", params) + end + + # Update a tax line. + # + # @param document_id [Integer] + # @param id [Integer] + # @param params [Hash] writable fields you want to change + # @return [Veryfi::Resource] + def update(document_id, id, params) + request.put("/partner/documents/#{document_id}/tax-lines/#{id}", params) + end + + # Delete a tax line. + # + # @param document_id [Integer] + # @param id [Integer] + # @return [Veryfi::Resource] + def delete(document_id, id) + request.delete("/partner/documents/#{document_id}/tax-lines/#{id}") + end + end + end +end diff --git a/lib/veryfi/api/w2.rb b/lib/veryfi/api/w2.rb new file mode 100644 index 0000000..bf4a355 --- /dev/null +++ b/lib/veryfi/api/w2.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # W-2 endpoints (`/partner/w2s/`). + # + # @see https://docs.veryfi.com/api/w2s/ + class W2 + include FilePayload + include TagOperations + + ENDPOINT = "/partner/w2s/" + + attr_reader :request + + def initialize(request) + @request = request + end + + # List previously processed W-2 documents. + # + # @param params [Hash] optional query-string parameters + # @option params [String] :created_date__gt "YYYY-MM-DD HH:MM:SS" — strictly after + # @option params [String] :created_date__gte after or equal + # @option params [String] :created_date__lt strictly before + # @option params [String] :created_date__lte before or equal + # @option params [Integer] :page (1) + # @option params [Integer] :page_size (50) + # @return [Veryfi::Resource] `{ "documents" => [...] }` + def all(params = {}) + request.get(ENDPOINT, params) + end + + # Fetch a single W-2 by id. + # + # @param id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def get(id, params = {}) + request.get("#{ENDPOINT}#{id}/", params) + end + + # Upload a W-2 file and extract its fields. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** Local path. + # @option raw_params [String] :file_name (basename of `:file_path`) + # @return [Veryfi::Resource] + def process(raw_params) + params = raw_params.transform_keys(&:to_sym) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + payload = file_payload(file_path, file_name).merge(params) + + request.post(ENDPOINT, payload) + end + + # URL variant of {#process}. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_url **required.** + # @option raw_params [String] :file_name (basename of `:file_url`) + # @return [Veryfi::Resource] + def process_url(raw_params) + params = raw_params.transform_keys(&:to_sym) + params[:file_name] ||= File.basename(params[:file_url]) if params[:file_url] + + request.post(ENDPOINT, params) + end + + # Update writable fields on a processed W-2. + # + # @param id [Integer] + # @param params [Hash] + # @return [Veryfi::Resource] + def update(id, params) + request.put("#{ENDPOINT}#{id}/", params) + end + + # Delete a W-2. + # + # @param id [Integer] + # @return [Veryfi::Resource] + def delete(id) + request.delete("#{ENDPOINT}#{id}/") + end + end + end +end diff --git a/lib/veryfi/api/w2_split.rb b/lib/veryfi/api/w2_split.rb new file mode 100644 index 0000000..731e3bc --- /dev/null +++ b/lib/veryfi/api/w2_split.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # W-2 splitting endpoints (`/partner/w2s-set/`). + # + # Use these when you have a single file containing multiple W-2 forms. + # Veryfi will split it and process each W-2 separately; you receive a + # collection that references the individual {W2} ids it produced. + # + # @see https://docs.veryfi.com/api/split-and-process-a-w-2/ + class W2Split + include FilePayload + + ENDPOINT = "/partner/w2s-set/" + + attr_reader :request + + def initialize(request) + @request = request + end + + # List previously processed W-2 sets. + # + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def all(params = {}) + request.get(ENDPOINT, params) + end + + # Fetch a single W-2 set by id. + # + # @param id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def get(id, params = {}) + request.get("#{ENDPOINT}#{id}/", params) + end + + # Upload a multi-W-2 file and split-and-process it. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** Local path. + # @option raw_params [String] :file_name (basename of `:file_path`) + # @return [Veryfi::Resource] + def process(raw_params) + params = raw_params.transform_keys(&:to_sym) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + payload = file_payload(file_path, file_name).merge(params) + + request.post(ENDPOINT, payload) + end + + # URL variant of {#process}. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_url single URL + # @option raw_params [Array] :file_urls list of URLs (alternative) + # @option raw_params [Integer] :max_pages_to_process (`nil` = all pages) + # @return [Veryfi::Resource] + def process_url(raw_params) + request.post(ENDPOINT, raw_params.transform_keys(&:to_sym)) + end + end + end +end diff --git a/lib/veryfi/api/w8.rb b/lib/veryfi/api/w8.rb new file mode 100644 index 0000000..46228d6 --- /dev/null +++ b/lib/veryfi/api/w8.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # W-8 BEN-E endpoints (`/partner/w-8ben-e/`). + # + # @see https://docs.veryfi.com/api/w-8ben-e/ + class W8 + include FilePayload + include TagOperations + + ENDPOINT = "/partner/w-8ben-e/" + + attr_reader :request + + def initialize(request) + @request = request + end + + # List previously processed W-8 BEN-E documents. + # + # @param params [Hash] optional query-string parameters + # @option params [String] :created_date__gt "YYYY-MM-DD HH:MM:SS" — strictly after + # @option params [String] :created_date__gte after or equal + # @option params [String] :created_date__lt strictly before + # @option params [String] :created_date__lte before or equal + # @option params [Integer] :page (1) + # @option params [Integer] :page_size (50) + # @return [Veryfi::Resource] `{ "documents" => [...] }` + def all(params = {}) + request.get(ENDPOINT, params) + end + + # Fetch a single W-8 by id. + # + # @param id [Integer] + # @param params [Hash] optional query-string parameters + # @return [Veryfi::Resource] + def get(id, params = {}) + request.get("#{ENDPOINT}#{id}/", params) + end + + # Upload a W-8 file and extract its fields. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** Local path. + # @option raw_params [String] :file_name (basename of `:file_path`) + # @return [Veryfi::Resource] + def process(raw_params) + params = raw_params.transform_keys(&:to_sym) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + payload = file_payload(file_path, file_name).merge(params) + + request.post(ENDPOINT, payload) + end + + # URL variant of {#process}. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_url **required.** + # @option raw_params [String] :file_name (basename of `:file_url`) + # @return [Veryfi::Resource] + def process_url(raw_params) + params = raw_params.transform_keys(&:to_sym) + params[:file_name] ||= File.basename(params[:file_url]) if params[:file_url] + + request.post(ENDPOINT, params) + end + + # Update writable fields on a processed W-8. + # + # @param id [Integer] + # @param params [Hash] + # @return [Veryfi::Resource] + def update(id, params) + request.put("#{ENDPOINT}#{id}/", params) + end + + # Delete a W-8. + # + # @param id [Integer] + # @return [Veryfi::Resource] + def delete(id) + request.delete("#{ENDPOINT}#{id}/") + end + end + end +end diff --git a/lib/veryfi/api/w9.rb b/lib/veryfi/api/w9.rb new file mode 100644 index 0000000..73844ee --- /dev/null +++ b/lib/veryfi/api/w9.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Veryfi + module Api + # W-9 endpoints (`/partner/w9s/`). + # + # @see https://docs.veryfi.com/api/w9s/ + class W9 + include FilePayload + include TagOperations + + ENDPOINT = "/partner/w9s/" + + attr_reader :request + + def initialize(request) + @request = request + end + + # List previously processed W-9 documents. + # + # @param params [Hash] optional query-string parameters + # @option params [String] :created_date__gt "YYYY-MM-DD HH:MM:SS" — strictly after + # @option params [String] :created_date__gte after or equal + # @option params [String] :created_date__lt strictly before + # @option params [String] :created_date__lte before or equal + # @option params [Integer] :page (1) + # @option params [Integer] :page_size (50) + # @return [Veryfi::Resource] `{ "documents" => [...] }` + def all(params = {}) + request.get(ENDPOINT, params) + end + + # Fetch a single W-9 by id. + # + # @param id [Integer] + # @param params [Hash] optional query-string parameters + # @option params [Boolean] :bounding_boxes (`false`) Include bounding-box info. + # @option params [Boolean] :confidence_details (`false`) Include per-field confidence scores. + # @return [Veryfi::Resource] + def get(id, params = {}) + request.get("#{ENDPOINT}#{id}/", params) + end + + # Upload a W-9 file and extract its fields. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_path **required.** Local path. + # @option raw_params [String] :file_name (basename of `:file_path`) + # @return [Veryfi::Resource] + def process(raw_params) + params = raw_params.transform_keys(&:to_sym) + file_path = params.delete(:file_path) + file_name = params.delete(:file_name) + + payload = file_payload(file_path, file_name).merge(params) + + request.post(ENDPOINT, payload) + end + + # URL variant of {#process}. + # + # @param raw_params [Hash] + # @option raw_params [String] :file_url **required.** + # @option raw_params [String] :file_name (basename of `:file_url`) + # @return [Veryfi::Resource] + def process_url(raw_params) + params = raw_params.transform_keys(&:to_sym) + params[:file_name] ||= File.basename(params[:file_url]) if params[:file_url] + + request.post(ENDPOINT, params) + end + + # Update writable fields on a processed W-9. + # + # @param id [Integer] + # @param params [Hash] + # @return [Veryfi::Resource] + def update(id, params) + request.put("#{ENDPOINT}#{id}/", params) + end + + # Delete a W-9. + # + # @param id [Integer] + # @return [Veryfi::Resource] + def delete(id) + request.delete("#{ENDPOINT}#{id}/") + end + end + end +end diff --git a/lib/veryfi/client.rb b/lib/veryfi/client.rb index cfea8ad..a53cb81 100644 --- a/lib/veryfi/client.rb +++ b/lib/veryfi/client.rb @@ -1,7 +1,46 @@ # frozen_string_literal: true module Veryfi + # The user-facing entry point. + # + # @example Basic usage + # client = Veryfi::Client.new( + # client_id: ENV["VERYFI_CLIENT_ID"], + # client_secret: ENV["VERYFI_CLIENT_SECRET"], + # username: ENV["VERYFI_USERNAME"], + # api_key: ENV["VERYFI_API_KEY"] + # ) + # client.document.process(file_path: "./receipt.jpg") + # + # @example Custom Faraday configuration (persistent connections + retries) + # client = Veryfi::Client.new( + # client_id: "…", + # client_secret: "…", + # username: "…", + # api_key: "…", + # faraday: ->(conn) { + # conn.request :retry, max: 3, interval: 0.5, backoff_factor: 2, + # retry_statuses: [429, 502, 503, 504] + # conn.response :logger, Rails.logger if defined?(Rails) + # conn.adapter :net_http_persistent + # } + # ) class Client + # DSL: declare an API namespace as a lazily-memoized reader. + # + # @example + # api_namespace :document, Veryfi::Api::Document + # + # @param name [Symbol] + # @param klass [Class] API class accepting a `Veryfi::Request` in its constructor + # @return [void] + def self.api_namespace(name, klass) + ivar = :"@_#{name}" + define_method(name) do + instance_variable_get(ivar) || instance_variable_set(ivar, klass.new(request)) + end + end + attr_reader :request def initialize( @@ -11,26 +50,31 @@ def initialize( api_key:, base_url: "https://api.veryfi.com/api/", api_version: "v8", - timeout: 20 + timeout: 20, + faraday: nil ) - @request = Veryfi::Request.new(client_id, client_secret, username, api_key, base_url, api_version, timeout) + @request = Veryfi::Request.new( + client_id, client_secret, username, api_key, + base_url, api_version, timeout, faraday + ) end - def document - @_document ||= Veryfi::Api::Document.new(request) - end - - def line_item - @_line_item ||= Veryfi::Api::LineItem.new(request) - end - - def tag - @_tag ||= Veryfi::Api::Tag.new(request) - end - - def document_tag - @_document_tag ||= Veryfi::Api::DocumentTag.new(request) - end + api_namespace :document, Veryfi::Api::Document + api_namespace :line_item, Veryfi::Api::LineItem + api_namespace :tax_line, Veryfi::Api::TaxLine + api_namespace :tag, Veryfi::Api::Tag + api_namespace :document_tag, Veryfi::Api::DocumentTag + api_namespace :any_document, Veryfi::Api::AnyDocument + api_namespace :bank_statement, Veryfi::Api::BankStatement + api_namespace :bank_statement_split, Veryfi::Api::BankStatementSplit + api_namespace :business_card, Veryfi::Api::BusinessCard + api_namespace :check, Veryfi::Api::Check + api_namespace :classify, Veryfi::Api::Classify + api_namespace :pdf_split, Veryfi::Api::PdfSplit + api_namespace :w2, Veryfi::Api::W2 + api_namespace :w2_split, Veryfi::Api::W2Split + api_namespace :w8, Veryfi::Api::W8 + api_namespace :w9, Veryfi::Api::W9 def api_url request.api_url diff --git a/lib/veryfi/configuration.rb b/lib/veryfi/configuration.rb new file mode 100644 index 0000000..fe60074 --- /dev/null +++ b/lib/veryfi/configuration.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Veryfi + # Process-wide settings used by {Veryfi.client} to build the shared + # singleton client. Mirrors the keyword arguments of + # {Veryfi::Client#initialize}; defaults match the client's defaults. + class Configuration + ATTRS = %i[ + client_id client_secret username api_key + base_url api_version timeout faraday + ].freeze + + attr_accessor(*ATTRS) + + def initialize + @base_url = "https://api.veryfi.com/api/" + @api_version = "v8" + @timeout = 20 + end + + # @return [Hash] the configuration as a keyword-arg-ready Hash. Keys + # with `nil` values are still included; {Veryfi.client} calls + # `.compact` before passing it to {Veryfi::Client#initialize}. + def to_h + ATTRS.to_h { |attr| [attr, public_send(attr)] } + end + end +end diff --git a/lib/veryfi/error.rb b/lib/veryfi/error.rb index 7bf39bc..6c2c817 100644 --- a/lib/veryfi/error.rb +++ b/lib/veryfi/error.rb @@ -3,30 +3,116 @@ require "json" module Veryfi + # Namespace + factory for every error raised by this SDK. + # + # All errors inherit from {VeryfiError}, so callers that only need to + # know "something went wrong with Veryfi" can keep using: + # + # begin + # client.document.process(file_path: path) + # rescue Veryfi::Error::VeryfiError => e + # # … + # end + # + # Callers that want to react differently per HTTP status can rescue a + # more specific subclass: + # + # begin + # client.document.process(file_path: path) + # rescue Veryfi::Error::Unauthorized then refresh_credentials! + # rescue Veryfi::Error::TooManyRequests then back_off + # rescue Veryfi::Error::ServerError then schedule_retry + # rescue Veryfi::Error::VeryfiError then log_and_raise + # end class Error - def self.from_response(status, response) - if response.empty? - VeryfiError.new(format("%d", code: status)) - else - VeryfiError.new(format("%d, %s", code: status, message: response["error"]), response) - end - end - + # Base class for every Veryfi SDK error. + # + # `#message` returns the pretty-printed JSON error payload when one is + # available, otherwise the formatted `""` / `", "` + # string. The `#status` and `#response` accessors give callers + # programmatic access to the same information. class VeryfiError < StandardError - attr_reader :message + attr_reader :message, :status, :response - def initialize(message = "An error occurred", response = {}) - @message = if response.empty? + def initialize(message = "An error occurred", response = {}, status = nil) + @status = status + @response = response + @message = if response.nil? || response.empty? message else JSON.pretty_generate(response) end - super(message) + super(@message) end def to_s message end end + + # 400 — request was malformed or failed server-side validation. + class BadRequest < VeryfiError; end + # 401 — credentials are missing, invalid, or expired. + class Unauthorized < VeryfiError; end + # 403 — credentials are valid but lack permission for this resource. + class AccessLimitReached < VeryfiError; end + # 404 — the resource id does not exist (or has been deleted). + class NotFound < VeryfiError; end + # 408 — the request timed out before Veryfi could respond. + class RequestTimeout < VeryfiError; end + # 409 — request conflicts with current resource state. + class Conflict < VeryfiError; end + # 415 — uploaded file type is not supported by the endpoint. + class UnsupportedMediaType < VeryfiError; end + # 429 — you've hit a rate limit. Back off and retry. + class TooManyRequests < VeryfiError; end + # Catch-all for any other 4xx the server returns. + class ClientError < VeryfiError; end + # 5xx — Veryfi reported an internal error. Retrying with backoff is usually safe. + class ServerError < VeryfiError; end + + STATUS_MAP = { + 400 => BadRequest, + 401 => Unauthorized, + 403 => AccessLimitReached, + 404 => NotFound, + 408 => RequestTimeout, + 409 => Conflict, + 415 => UnsupportedMediaType, + 429 => TooManyRequests + }.freeze + private_constant :STATUS_MAP + + # Build the right error subclass for the given HTTP status + response + # body. Always returns an instance of {VeryfiError} or one of its + # subclasses; never raises. + # + # @param status [Integer] HTTP status code + # @param response [Hash, Veryfi::Resource, nil] parsed JSON body + # @return [VeryfiError] + def self.from_response(status, response) + klass = error_class_for(status) + message = format_message(status, response) + + klass.new(message, response, status) + end + + def self.error_class_for(status) + return STATUS_MAP[status] if STATUS_MAP.key?(status) + return ServerError if status.between?(500, 599) + return ClientError if status.between?(400, 499) + + VeryfiError + end + private_class_method :error_class_for + + def self.format_message(status, response) + if response.nil? || response.empty? + format("%d", code: status) + else + format("%d, %s", code: status, message: response["error"]) + end + end + private_class_method :format_message end end diff --git a/lib/veryfi/request.rb b/lib/veryfi/request.rb index 8201c52..9b0efed 100644 --- a/lib/veryfi/request.rb +++ b/lib/veryfi/request.rb @@ -5,8 +5,16 @@ require "json" module Veryfi + # Low-level HTTP layer used by every API class. You typically don't need + # to interact with this directly — go through {Veryfi::Client} instead. + # + # Custom Faraday configuration (adapter, retries, logging, persistent + # connections, …) can be supplied via the `faraday:` block when + # constructing the client. The block receives the `Faraday::Connection` + # before it's frozen, so you can attach any middleware you want. class Request - attr_reader :client_id, :client_secret, :username, :api_key, :base_url, :api_version, :timeout + attr_reader :client_id, :client_secret, :username, :api_key, + :base_url, :api_version, :timeout, :faraday_block VERBS_WITH_BODIES = %i[post put].freeze @@ -17,7 +25,8 @@ def initialize( api_key, base_url, api_version, - timeout + timeout, + faraday_block = nil ) @client_id = client_id @client_secret = client_secret @@ -26,6 +35,7 @@ def initialize( @base_url = base_url @api_version = api_version @timeout = timeout + @faraday_block = faraday_block end def get(path, params = {}) @@ -68,6 +78,7 @@ def make_request(http_verb, path, params = {}) def conn @_conn ||= Faraday.new do |conn| conn.options.timeout = timeout + faraday_block&.call(conn) end end @@ -84,18 +95,18 @@ def generate_headers(params = {}) signature = generate_signature(params, timestamp) default_headers.merge( - "X-Veryfi-Request-Timestamp": timestamp, - "X-Veryfi-Request-Signature": signature + "X-Veryfi-Request-Timestamp" => timestamp, + "X-Veryfi-Request-Signature" => signature ) end def default_headers { - "User-Agent": "Ruby Veryfi-Ruby/#{Veryfi::VERSION}", - Accept: "application/json", - "Content-Type": "application/json", - "Client-Id": client_id, - Authorization: "apikey #{username}:#{api_key}" + "User-Agent" => "Ruby Veryfi-Ruby/#{Veryfi::VERSION}", + "Accept" => "application/json", + "Content-Type" => "application/json", + "Client-Id" => client_id, + "Authorization" => "apikey #{username}:#{api_key}" } end @@ -104,9 +115,9 @@ def generate_signature(params, timestamp) end def process_response(response) - return {} if response.body.empty? + return Veryfi::Resource.new if response.body.empty? - JSON.parse(response.body) + Veryfi::Resource.wrap(JSON.parse(response.body)) end end end diff --git a/lib/veryfi/resource.rb b/lib/veryfi/resource.rb new file mode 100644 index 0000000..47329c5 --- /dev/null +++ b/lib/veryfi/resource.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Veryfi + # A lightweight, dependency-free wrapper around an API response payload. + # + # `Resource` inherits from `Hash`, so anything that already treats the + # response as a hash keeps working unchanged: + # + # response["id"] # => 44691518 + # response.dig("vendor", "name") + # response.is_a?(Hash) # => true + # JSON.pretty_generate(response) + # + # In addition, every key is also accessible as a method, recursively: + # + # response.id # => 44691518 + # response.vendor.name # => "East Repair" + # response.line_items.first.description + # response.is_duplicate? # => truthiness of self["is_duplicate"] + # + # Nested hashes are wrapped into Resources, and arrays of hashes become + # arrays of Resources. Other values (strings, numbers, booleans, nil) + # pass through untouched. Both string (`"id"`) and symbol (`:id`) keys + # work transparently. + class Resource < ::Hash + # Wrap any value coming back from the API. Hashes become Resources, + # arrays are mapped recursively, and everything else passes through. + # + # @param value [Object] raw value from `JSON.parse` + # @return [Object] wrapped value + def self.wrap(value) + return value if value.is_a?(Resource) + + case value + when ::Hash then new(value) + when ::Array then value.map { |v| wrap(v) } + else value + end + end + + def initialize(hash = {}) + super() + hash.each_pair { |key, value| self[key.to_s] = Resource.wrap(value) } + end + + def [](key) + super(key.to_s) + end + + # rubocop:disable Style/ArgumentsForwarding -- keep explicit forwarding for clarity and Ruby 3.0 portability + def fetch(key, *args, &block) + super(key.to_s, *args, &block) + end + # rubocop:enable Style/ArgumentsForwarding + + def key?(key) + super(key.to_s) + end + alias has_key? key? + alias include? key? + alias member? key? + + # Returns a plain (unwrapped) `Hash` representation, recursively. + # Useful when you need to hand the data off to something that explicitly + # expects a plain Hash (e.g. some serializers). + # + # @return [Hash] + def to_h + each_with_object({}) do |(key, value), memo| + memo[key] = unwrap(value) + end + end + alias to_hash to_h + + def respond_to_missing?(name, include_private = false) + string_name = name.to_s.chomp("?") + key?(string_name) || super + end + + def method_missing(name, *args, &block) + string_name = name.to_s + bare_name = string_name.chomp("?") + + if args.empty? && block.nil? && key?(bare_name) + value = self[bare_name] + string_name.end_with?("?") ? !value.nil? && value != false : value + else + super + end + end + + private + + def unwrap(value) + case value + when Resource then value.to_h + when ::Array then value.map { |v| v.is_a?(Resource) ? v.to_h : v } + else value + end + end + end +end diff --git a/lib/veryfi/version.rb b/lib/veryfi/version.rb index 00516f4..162f275 100644 --- a/lib/veryfi/version.rb +++ b/lib/veryfi/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Veryfi - VERSION = "3.0.0" + VERSION = "4.0.0" end diff --git a/spec/fixtures/receipt.jpg b/spec/fixtures/files/receipt.jpg similarity index 100% rename from spec/fixtures/receipt.jpg rename to spec/fixtures/files/receipt.jpg diff --git a/spec/fixtures/receipt_base64.txt b/spec/fixtures/files/receipt_base64.txt similarity index 100% rename from spec/fixtures/receipt_base64.txt rename to spec/fixtures/files/receipt_base64.txt diff --git a/spec/fixtures/responses/any_documents/list.json b/spec/fixtures/responses/any_documents/list.json new file mode 100644 index 0000000..7412dad --- /dev/null +++ b/spec/fixtures/responses/any_documents/list.json @@ -0,0 +1,22 @@ +{ + "documents": [ + { + "id": 71012001, + "blueprint_name": "us_w2_2022", + "created_date": "2024-01-04 10:11:12", + "data": { + "employee_name": "John Doe", + "employer_name": "Acme Inc" + } + }, + { + "id": 71012002, + "blueprint_name": "us_w2_2022", + "created_date": "2024-01-05 12:13:14", + "data": { + "employee_name": "Jane Roe", + "employer_name": "Acme Inc" + } + } + ] +} diff --git a/spec/fixtures/responses/bank_statements/list.json b/spec/fixtures/responses/bank_statements/list.json new file mode 100644 index 0000000..8d6a94c --- /dev/null +++ b/spec/fixtures/responses/bank_statements/list.json @@ -0,0 +1,24 @@ +{ + "documents": [ + { + "id": 88001001, + "account_holder_name": "John Doe", + "account_number": "0001234567", + "bank_name": "Veryfi Bank", + "created_date": "2024-02-01 10:00:00", + "ending_balance": 12345.67, + "starting_balance": 10000.0, + "statement_date": "2024-01-31" + }, + { + "id": 88001002, + "account_holder_name": "Jane Roe", + "account_number": "0007654321", + "bank_name": "Veryfi Bank", + "created_date": "2024-02-02 10:00:00", + "ending_balance": 543.21, + "starting_balance": 1000.0, + "statement_date": "2024-01-31" + } + ] +} diff --git a/spec/fixtures/responses/bank_statements_set/list.json b/spec/fixtures/responses/bank_statements_set/list.json new file mode 100644 index 0000000..8908b13 --- /dev/null +++ b/spec/fixtures/responses/bank_statements_set/list.json @@ -0,0 +1,19 @@ +{ + "documents": [ + { + "id": 89010001, + "documents": [ + { "id": 89010101 }, + { "id": 89010102 } + ], + "created_date": "2024-11-01 10:00:00" + }, + { + "id": 89010002, + "documents": [ + { "id": 89010201 } + ], + "created_date": "2024-11-02 10:00:00" + } + ] +} diff --git a/spec/fixtures/responses/business_cards/list.json b/spec/fixtures/responses/business_cards/list.json new file mode 100644 index 0000000..4cd9647 --- /dev/null +++ b/spec/fixtures/responses/business_cards/list.json @@ -0,0 +1,20 @@ +{ + "documents": [ + { + "id": 99002001, + "name": "John Doe", + "company": "Acme Inc", + "email": "john@acme.test", + "phone_number": "+1 555 0101", + "created_date": "2024-03-10 09:00:00" + }, + { + "id": 99002002, + "name": "Jane Roe", + "company": "Acme Inc", + "email": "jane@acme.test", + "phone_number": "+1 555 0102", + "created_date": "2024-03-11 09:00:00" + } + ] +} diff --git a/spec/fixtures/responses/checks/list.json b/spec/fixtures/responses/checks/list.json new file mode 100644 index 0000000..68e1f3b --- /dev/null +++ b/spec/fixtures/responses/checks/list.json @@ -0,0 +1,22 @@ +{ + "documents": [ + { + "id": 55003001, + "amount": 1234.56, + "check_number": "0001", + "date": "2024-04-01", + "payer": "John Doe", + "payee": "Acme Inc", + "created_date": "2024-04-02 10:00:00" + }, + { + "id": 55003002, + "amount": 789.01, + "check_number": "0002", + "date": "2024-04-02", + "payer": "Jane Roe", + "payee": "Acme Inc", + "created_date": "2024-04-03 10:00:00" + } + ] +} diff --git a/spec/fixtures/responses/checks/with_remittance.json b/spec/fixtures/responses/checks/with_remittance.json new file mode 100644 index 0000000..73d3f13 --- /dev/null +++ b/spec/fixtures/responses/checks/with_remittance.json @@ -0,0 +1,17 @@ +{ + "id": 55009001, + "check": { + "id": 55009101, + "amount": 2500.0, + "check_number": "0010", + "date": "2024-10-01", + "payer": "John Doe", + "payee": "Acme Inc" + }, + "remittance": { + "id": 55009201, + "invoice_number": "INV-2024-01", + "total": 2500.0 + }, + "created_date": "2024-10-02 09:30:00" +} diff --git a/spec/fixtures/responses/classify/result.json b/spec/fixtures/responses/classify/result.json new file mode 100644 index 0000000..f33cf92 --- /dev/null +++ b/spec/fixtures/responses/classify/result.json @@ -0,0 +1,9 @@ +{ + "document_type": "invoice", + "score": 0.987, + "predictions": [ + { "document_type": "invoice", "score": 0.987 }, + { "document_type": "receipt", "score": 0.011 }, + { "document_type": "w2", "score": 0.001 } + ] +} diff --git a/spec/fixtures/line_items.json b/spec/fixtures/responses/documents/line_items.json similarity index 100% rename from spec/fixtures/line_items.json rename to spec/fixtures/responses/documents/line_items.json diff --git a/spec/fixtures/documents.json b/spec/fixtures/responses/documents/list.json similarity index 100% rename from spec/fixtures/documents.json rename to spec/fixtures/responses/documents/list.json diff --git a/spec/fixtures/responses/documents/tax_lines.json b/spec/fixtures/responses/documents/tax_lines.json new file mode 100644 index 0000000..329494d --- /dev/null +++ b/spec/fixtures/responses/documents/tax_lines.json @@ -0,0 +1,20 @@ +{ + "tax_lines": [ + { + "id": 12009001, + "name": "Sales Tax", + "rate": 6.25, + "base": 145.0, + "total": 9.06, + "order": 0 + }, + { + "id": 12009002, + "name": "Local Tax", + "rate": 1.5, + "base": 145.0, + "total": 2.18, + "order": 1 + } + ] +} diff --git a/spec/fixtures/responses/documents_set/list.json b/spec/fixtures/responses/documents_set/list.json new file mode 100644 index 0000000..58cc583 --- /dev/null +++ b/spec/fixtures/responses/documents_set/list.json @@ -0,0 +1,19 @@ +{ + "documents": [ + { + "id": 66004001, + "documents": [ + { "id": 66004101 }, + { "id": 66004102 } + ], + "created_date": "2024-05-01 12:00:00" + }, + { + "id": 66004002, + "documents": [ + { "id": 66004201 } + ], + "created_date": "2024-05-02 12:00:00" + } + ] +} diff --git a/spec/fixtures/tags.json b/spec/fixtures/responses/tags/list.json similarity index 100% rename from spec/fixtures/tags.json rename to spec/fixtures/responses/tags/list.json diff --git a/spec/fixtures/responses/w2s/list.json b/spec/fixtures/responses/w2s/list.json new file mode 100644 index 0000000..63a82c2 --- /dev/null +++ b/spec/fixtures/responses/w2s/list.json @@ -0,0 +1,22 @@ +{ + "documents": [ + { + "id": 77005001, + "employee_name": "John Doe", + "employer_name": "Acme Inc", + "year": 2023, + "wages_tips_other_compensation": 50000.0, + "federal_income_tax_withheld": 5000.0, + "created_date": "2024-06-01 10:00:00" + }, + { + "id": 77005002, + "employee_name": "Jane Roe", + "employer_name": "Acme Inc", + "year": 2023, + "wages_tips_other_compensation": 60000.0, + "federal_income_tax_withheld": 6000.0, + "created_date": "2024-06-02 10:00:00" + } + ] +} diff --git a/spec/fixtures/responses/w2s_set/list.json b/spec/fixtures/responses/w2s_set/list.json new file mode 100644 index 0000000..d90bde7 --- /dev/null +++ b/spec/fixtures/responses/w2s_set/list.json @@ -0,0 +1,19 @@ +{ + "documents": [ + { + "id": 78006001, + "documents": [ + { "id": 78006101 }, + { "id": 78006102 } + ], + "created_date": "2024-07-01 12:00:00" + }, + { + "id": 78006002, + "documents": [ + { "id": 78006201 } + ], + "created_date": "2024-07-02 12:00:00" + } + ] +} diff --git a/spec/fixtures/responses/w8s/list.json b/spec/fixtures/responses/w8s/list.json new file mode 100644 index 0000000..492b400 --- /dev/null +++ b/spec/fixtures/responses/w8s/list.json @@ -0,0 +1,18 @@ +{ + "documents": [ + { + "id": 79007001, + "name_of_organization": "Acme Ltd", + "country_of_incorporation": "Cayman Islands", + "chapter_3_status": "Corporation", + "created_date": "2024-08-01 10:00:00" + }, + { + "id": 79007002, + "name_of_organization": "Globex S.A.", + "country_of_incorporation": "Switzerland", + "chapter_3_status": "Corporation", + "created_date": "2024-08-02 10:00:00" + } + ] +} diff --git a/spec/fixtures/responses/w9s/list.json b/spec/fixtures/responses/w9s/list.json new file mode 100644 index 0000000..274ef61 --- /dev/null +++ b/spec/fixtures/responses/w9s/list.json @@ -0,0 +1,20 @@ +{ + "documents": [ + { + "id": 80008001, + "name": "John Doe", + "business_name": "Acme Inc", + "federal_tax_classification": "Individual/sole proprietor", + "tax_id": "12-3456789", + "created_date": "2024-09-01 10:00:00" + }, + { + "id": 80008002, + "name": "Jane Roe", + "business_name": "Globex Corp", + "federal_tax_classification": "C Corporation", + "tax_id": "98-7654321", + "created_date": "2024-09-02 10:00:00" + } + ] +} diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index dd788e8..141d6f3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -18,10 +18,12 @@ SimpleCov::Formatter::BadgeFormatter.timestamp = true # call SimpleCov::Formatter::BadgeFormatter after the normal HTMLFormatter - SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[ - SimpleCov::Formatter::HTMLFormatter, - SimpleCov::Formatter::BadgeFormatter - ] + SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new( + [ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::BadgeFormatter + ] + ) end end diff --git a/spec/support/shared_contexts/with_veryfi_client.rb b/spec/support/shared_contexts/with_veryfi_client.rb index 3e0d1ea..83ebd72 100644 --- a/spec/support/shared_contexts/with_veryfi_client.rb +++ b/spec/support/shared_contexts/with_veryfi_client.rb @@ -9,4 +9,25 @@ api_key: "123456" ) end + + # Path to a JSON response fixture, e.g. + # response_fixture("documents/list") → spec/fixtures/responses/documents/list.json + def response_fixture(name) + "spec/fixtures/responses/#{name}.json" + end + + # Read a JSON response fixture from disk as a String. + def response_fixture_body(name) + File.read(response_fixture(name)) + end + + # Absolute path to a binary/raw asset fixture, e.g. + # file_fixture_path("receipt.jpg") → /…/spec/fixtures/files/receipt.jpg + def file_fixture_path(name) + File.expand_path("spec/fixtures/files/#{name}", Dir.pwd) + end + + def receipt_file_data + File.read(file_fixture_path("receipt_base64.txt")).gsub("\n", "") + end end diff --git a/spec/support/shared_examples/tag_operations.rb b/spec/support/shared_examples/tag_operations.rb new file mode 100644 index 0000000..a38010e --- /dev/null +++ b/spec/support/shared_examples/tag_operations.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Shared examples for resources that include Veryfi::Api::TagOperations. +# +# The host spec must define: +# - `subject_namespace` — symbol, e.g. :check (so we call client.check.add_tag) +# - `tags_endpoint` — full URL prefix up to and including `.../{id}/tags`, +# e.g. "https://api.veryfi.com/api/v8/partner/checks/123/tags" +# - `resource_id` — id of the parent resource (integer) +shared_examples "a resource with tag operations" do + let(:tags_fixture) { response_fixture_body("tags/list") } + let(:tag) { JSON.parse(tags_fixture)["tags"][0] } + + describe ".tags(id)" do + before { stub_request(:get, tags_endpoint).to_return(body: tags_fixture) } + + it "fetches tags for the resource" do + response = client.public_send(subject_namespace).tags(resource_id) + + expect(response["tags"][0]["name"]).to eq("foo") + end + end + + describe ".add_tag(id, params)" do + before { stub_request(:put, tags_endpoint).to_return(body: tag.to_json) } + + it "adds a single tag (PUT)" do + response = client.public_send(subject_namespace).add_tag(resource_id, name: "foo") + + expect(response["name"]).to eq("foo") + end + end + + describe ".add_tags(id, tags)" do + before { stub_request(:post, tags_endpoint).to_return(body: tags_fixture) } + + it "adds multiple tags in one call (POST)" do + expect_any_instance_of(Veryfi::Request).to receive(:post) + .with(anything, tags: %w[foo bar baz]).and_call_original + + response = client.public_send(subject_namespace).add_tags(resource_id, %w[foo bar baz]) + + expect(response["tags"].length).to eq(3) + end + end + + describe ".delete_tag(id, tag_id)" do + before do + stub_request(:delete, "#{tags_endpoint}/75788890").to_return( + body: { status: "ok", message: "Tag has been removed" }.to_json + ) + end + + it "deletes a single tag" do + response = client.public_send(subject_namespace).delete_tag(resource_id, 75_788_890) + + expect(response["message"]).to eq("Tag has been removed") + end + end + + describe ".delete_tags(id)" do + before do + stub_request(:delete, tags_endpoint).to_return( + body: { status: "ok", message: "All tags removed" }.to_json + ) + end + + it "deletes all tags for the resource" do + response = client.public_send(subject_namespace).delete_tags(resource_id) + + expect(response["message"]).to eq("All tags removed") + end + end +end diff --git a/spec/support/shared_examples/wrapped_response.rb b/spec/support/shared_examples/wrapped_response.rb new file mode 100644 index 0000000..6a65cd4 --- /dev/null +++ b/spec/support/shared_examples/wrapped_response.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Shared example used by every resource spec to demonstrate that the +# response coming out of a real `Veryfi::Request` round-trip is a +# `Veryfi::Resource` and supports BOTH access styles equivalently. +# +# Usage: +# +# it_behaves_like "a Veryfi::Resource response" do +# let(:resource_call) { -> { client.document.get(document_id) } } +# let(:attribute_under_test) { :id } +# let(:expected_value) { 44_691_518 } +# end +RSpec.shared_examples "a Veryfi::Resource response" do + let(:wrapped_response) { resource_call.call } + + it "is a Veryfi::Resource (Hash-compatible subclass)" do + expect(wrapped_response).to be_a(Veryfi::Resource) + expect(wrapped_response).to be_a(Hash) + end + + it "returns the same value via response[\"#{attribute_under_test}\"] and via response.#{attribute_under_test}" do + bracket = wrapped_response[attribute_under_test.to_s] + symbol = wrapped_response[attribute_under_test.to_sym] + method = wrapped_response.public_send(attribute_under_test) + + expect(bracket).to eq(expected_value) + expect(symbol).to eq(expected_value) + expect(method).to eq(expected_value) + end +end diff --git a/spec/veryfi/any_document_spec.rb b/spec/veryfi/any_document_spec.rb new file mode 100644 index 0000000..b1546b5 --- /dev/null +++ b/spec/veryfi/any_document_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "AnyDocument API" do + include_context :with_veryfi_client + + let(:any_documents_fixture) { response_fixture_body("any_documents/list") } + let(:any_documents) { JSON.parse(any_documents_fixture)["documents"] } + let(:document_id) { 71_012_001 } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/any-documents" } + + describe "any_document.all" do + before do + stub_request(:get, "#{base_url}/").to_return(body: any_documents_fixture) + end + + it "fetches the list" do + response = client.any_document.all + + expect(response["documents"][0]["id"]).to eq(document_id) + end + end + + describe "any_document.get(id)" do + before do + stub_request(:get, "#{base_url}/#{document_id}/").to_return(body: any_documents[0].to_json) + end + + it "fetches an a-doc by id" do + response = client.any_document.get(document_id) + + expect(response["id"]).to eq(document_id) + end + end + + describe "any_document.process(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: any_documents[0].to_json) + end + + let(:process_params) do + { + blueprint_name: "us_w2_2022", + file_path: file_fixture_path("receipt.jpg") + } + end + + it "uploads a file and POSTs to /any-documents/ with blueprint_name" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/any-documents/", + file_name: "receipt.jpg", + file_data: receipt_file_data, + blueprint_name: "us_w2_2022" + ).and_call_original + + response = client.any_document.process(process_params) + + expect(response["id"]).to eq(document_id) + end + end + + describe "any_document.process_url(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: any_documents[0].to_json) + end + + let(:url_params) do + { blueprint_name: "us_w2_2022", file_url: "https://cdn.example.com/doc.pdf" } + end + + it "defaults file_name from file_url and forwards blueprint_name" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/any-documents/", + url_params.merge(file_name: "doc.pdf") + ).and_call_original + + response = client.any_document.process_url(url_params) + + expect(response["id"]).to eq(document_id) + end + end + + describe "any_document.update(id, params)" do + before do + stub_request(:put, "#{base_url}/#{document_id}/").to_return( + body: any_documents[0].merge(notes: "edited").to_json + ) + end + + it "updates an a-doc" do + response = client.any_document.update(document_id, notes: "edited") + + expect(response["notes"]).to eq("edited") + end + end + + describe "any_document.delete(id)" do + before do + stub_request(:delete, "#{base_url}/#{document_id}/").to_return( + body: { status: "ok", message: "Any document has been deleted" }.to_json + ) + end + + it "deletes an a-doc" do + response = client.any_document.delete(document_id) + + expect(response["message"]).to eq("Any document has been deleted") + end + end + + describe "any_document.process_async(params)" do + before do + stub_request(:post, "https://api.veryfi.com/api/v8/partner/any-documents/async").to_return( + body: { id: document_id, status: "processing" }.to_json + ) + end + + let(:expected_payload) do + { + file_name: "receipt.jpg", file_data: receipt_file_data, blueprint_name: "us_w2_2022" + } + end + + it "POSTs uploaded file to /any-documents/async with blueprint_name" do + expect_any_instance_of(Veryfi::Request).to receive(:post) + .with("/partner/any-documents/async", expected_payload).and_call_original + + response = client.any_document.process_async( + blueprint_name: "us_w2_2022", file_path: file_fixture_path("receipt.jpg") + ) + + expect(response["status"]).to eq("processing") + end + end + + describe "any_document.process_url_async(params)" do + before do + stub_request(:post, "https://api.veryfi.com/api/v8/partner/any-documents/async").to_return( + body: { id: document_id, status: "processing" }.to_json + ) + end + + let(:url_params) do + { blueprint_name: "us_w2_2022", file_url: "https://cdn.example.com/doc.pdf" } + end + + it "POSTs file_url to /any-documents/async" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/any-documents/async", + url_params.merge(file_name: "doc.pdf") + ).and_call_original + + response = client.any_document.process_url_async(url_params) + + expect(response["status"]).to eq("processing") + end + end + + it_behaves_like "a resource with tag operations" do + let(:subject_namespace) { :any_document } + let(:resource_id) { 71_012_001 } + let(:tags_endpoint) { "https://api.veryfi.com/api/v8/partner/any-documents/#{resource_id}/tags" } + end +end diff --git a/spec/veryfi/bank_statement_spec.rb b/spec/veryfi/bank_statement_spec.rb new file mode 100644 index 0000000..dd44d45 --- /dev/null +++ b/spec/veryfi/bank_statement_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "BankStatement API" do + include_context :with_veryfi_client + + let(:bank_statements_fixture) { response_fixture_body("bank_statements/list") } + let(:bank_statements) { JSON.parse(bank_statements_fixture)["documents"] } + let(:document_id) { 88_001_001 } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/bank-statements" } + + describe "bank_statement.all" do + before do + stub_request(:get, "#{base_url}/").to_return(body: bank_statements_fixture) + end + + it "fetches the list" do + response = client.bank_statement.all + + expect(response["documents"][0]["id"]).to eq(document_id) + end + end + + describe "bank_statement.get(id)" do + before do + stub_request(:get, "#{base_url}/#{document_id}/").to_return(body: bank_statements[0].to_json) + end + + it "fetches a bank statement by id" do + response = client.bank_statement.get(document_id) + + expect(response["id"]).to eq(document_id) + end + end + + describe "bank_statement.process(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: bank_statements[0].to_json) + end + + let(:process_params) do + { + file_path: file_fixture_path("receipt.jpg"), + categories: %w[Transfer Payroll] + } + end + + it "uploads a file and POSTs to /bank-statements/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/bank-statements/", + file_name: "receipt.jpg", + file_data: receipt_file_data, + categories: %w[Transfer Payroll] + ).and_call_original + + response = client.bank_statement.process(process_params) + + expect(response["id"]).to eq(document_id) + end + end + + describe "bank_statement.process_url(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: bank_statements[0].to_json) + end + + let(:process_params) do + { + file_url: "https://cdn.example.com/statement.pdf", + categories: %w[Transfer Payroll] + } + end + + it "defaults file_name from file_url when missing and POSTs to /bank-statements/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/bank-statements/", + file_url: "https://cdn.example.com/statement.pdf", + categories: %w[Transfer Payroll], + file_name: "statement.pdf" + ).and_call_original + + response = client.bank_statement.process_url(process_params) + + expect(response["id"]).to eq(document_id) + end + end + + describe "bank_statement.update(id, params)" do + before do + stub_request(:put, "#{base_url}/#{document_id}/").to_return( + body: bank_statements[0].merge(notes: "edited").to_json + ) + end + + it "updates a bank statement" do + response = client.bank_statement.update(document_id, notes: "edited") + + expect(response["notes"]).to eq("edited") + end + end + + describe "bank_statement.delete(id)" do + before do + stub_request(:delete, "#{base_url}/#{document_id}/").to_return( + body: { status: "ok", message: "Bank statement has been deleted" }.to_json + ) + end + + it "deletes a bank statement" do + response = client.bank_statement.delete(document_id) + + expect(response["message"]).to eq("Bank statement has been deleted") + end + end + + describe "bank_statement.process_async(params)" do + before do + stub_request(:post, "https://api.veryfi.com/api/v8/partner/bank-statements/async").to_return( + body: { id: document_id, status: "processing" }.to_json + ) + end + + it "POSTs uploaded file to /bank-statements/async" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/bank-statements/async", + file_name: "receipt.jpg", + file_data: receipt_file_data + ).and_call_original + + response = client.bank_statement.process_async(file_path: file_fixture_path("receipt.jpg")) + + expect(response["status"]).to eq("processing") + end + end + + describe "bank_statement.process_url_async(params)" do + before do + stub_request(:post, "https://api.veryfi.com/api/v8/partner/bank-statements/async").to_return( + body: { id: document_id, status: "processing" }.to_json + ) + end + + it "POSTs file_url to /bank-statements/async" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/bank-statements/async", + file_url: "https://cdn.example.com/statement.pdf", + file_name: "statement.pdf" + ).and_call_original + + response = client.bank_statement.process_url_async(file_url: "https://cdn.example.com/statement.pdf") + + expect(response["status"]).to eq("processing") + end + end + + it_behaves_like "a resource with tag operations" do + let(:subject_namespace) { :bank_statement } + let(:resource_id) { 88_001_001 } + let(:tags_endpoint) { "https://api.veryfi.com/api/v8/partner/bank-statements/#{resource_id}/tags" } + end +end diff --git a/spec/veryfi/bank_statement_split_spec.rb b/spec/veryfi/bank_statement_split_spec.rb new file mode 100644 index 0000000..7632d13 --- /dev/null +++ b/spec/veryfi/bank_statement_split_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "BankStatementSplit API" do + include_context :with_veryfi_client + + let(:set_fixture) { response_fixture_body("bank_statements_set/list") } + let(:set_documents) { JSON.parse(set_fixture)["documents"] } + let(:document_id) { 89_010_001 } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/bank-statements-set" } + + describe "bank_statement_split.all" do + before do + stub_request(:get, "#{base_url}/").to_return(body: set_fixture) + end + + it "fetches the list" do + response = client.bank_statement_split.all + + expect(response["documents"][0]["id"]).to eq(document_id) + end + end + + describe "bank_statement_split.get(id)" do + before do + stub_request(:get, "#{base_url}/#{document_id}").to_return(body: set_documents[0].to_json) + end + + it "fetches a bank-statements-set by id" do + response = client.bank_statement_split.get(document_id) + + expect(response["id"]).to eq(document_id) + expect(response["documents"].length).to eq(2) + end + end + + describe "bank_statement_split.process(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: set_documents[0].to_json) + end + + it "uploads a file and POSTs to /bank-statements-set/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/bank-statements-set/", + file_name: "receipt.jpg", + file_data: receipt_file_data + ).and_call_original + + response = client.bank_statement_split.process(file_path: file_fixture_path("receipt.jpg")) + + expect(response["id"]).to eq(document_id) + end + end + + describe "bank_statement_split.process_url(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: set_documents[0].to_json) + end + + it "POSTs file_urls to /bank-statements-set/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/bank-statements-set/", + file_urls: %w[https://cdn.example.com/a.pdf https://cdn.example.com/b.pdf] + ).and_call_original + + response = client.bank_statement_split.process_url( + file_urls: %w[https://cdn.example.com/a.pdf https://cdn.example.com/b.pdf] + ) + + expect(response["id"]).to eq(document_id) + end + end +end diff --git a/spec/veryfi/business_card_spec.rb b/spec/veryfi/business_card_spec.rb new file mode 100644 index 0000000..042a8c5 --- /dev/null +++ b/spec/veryfi/business_card_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "BusinessCard API" do + include_context :with_veryfi_client + + let(:business_cards_fixture) { response_fixture_body("business_cards/list") } + let(:business_cards) { JSON.parse(business_cards_fixture)["documents"] } + let(:document_id) { 99_002_001 } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/business-cards" } + + describe "business_card.all" do + before do + stub_request(:get, "#{base_url}/").to_return(body: business_cards_fixture) + end + + it "fetches the list" do + response = client.business_card.all + + expect(response["documents"][0]["id"]).to eq(document_id) + end + end + + describe "business_card.get(id)" do + before do + stub_request(:get, "#{base_url}/#{document_id}/").to_return(body: business_cards[0].to_json) + end + + it "fetches a business card by id" do + response = client.business_card.get(document_id) + + expect(response["id"]).to eq(document_id) + end + end + + describe "business_card.process(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: business_cards[0].to_json) + end + + let(:process_params) do + { + file_path: file_fixture_path("receipt.jpg") + } + end + + it "uploads a file and POSTs to /business-cards/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/business-cards/", + file_name: "receipt.jpg", + file_data: receipt_file_data + ).and_call_original + + response = client.business_card.process(process_params) + + expect(response["id"]).to eq(document_id) + end + end + + describe "business_card.process_url(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: business_cards[0].to_json) + end + + it "defaults file_name from file_url when missing" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/business-cards/", + file_url: "https://cdn.example.com/card.jpg", + file_name: "card.jpg" + ).and_call_original + + response = client.business_card.process_url(file_url: "https://cdn.example.com/card.jpg") + + expect(response["id"]).to eq(document_id) + end + + it "respects an explicit file_name" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/business-cards/", + file_url: "https://cdn.example.com/card.jpg", + file_name: "custom.jpg" + ).and_call_original + + response = client.business_card.process_url( + file_url: "https://cdn.example.com/card.jpg", + file_name: "custom.jpg" + ) + + expect(response["id"]).to eq(document_id) + end + end + + describe "business_card.update(id, params)" do + before do + stub_request(:put, "#{base_url}/#{document_id}/").to_return( + body: business_cards[0].merge(company: "Globex").to_json + ) + end + + it "updates a business card" do + response = client.business_card.update(document_id, company: "Globex") + + expect(response["company"]).to eq("Globex") + end + end + + describe "business_card.delete(id)" do + before do + stub_request(:delete, "#{base_url}/#{document_id}/").to_return( + body: { status: "ok", message: "Business card has been deleted" }.to_json + ) + end + + it "deletes a business card" do + response = client.business_card.delete(document_id) + + expect(response["message"]).to eq("Business card has been deleted") + end + end + + it_behaves_like "a resource with tag operations" do + let(:subject_namespace) { :business_card } + let(:resource_id) { 99_002_001 } + let(:tags_endpoint) { "https://api.veryfi.com/api/v8/partner/business-cards/#{resource_id}/tags" } + end +end diff --git a/spec/veryfi/check_spec.rb b/spec/veryfi/check_spec.rb new file mode 100644 index 0000000..a15f8a2 --- /dev/null +++ b/spec/veryfi/check_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Check API" do + include_context :with_veryfi_client + + let(:checks_fixture) { response_fixture_body("checks/list") } + let(:checks) { JSON.parse(checks_fixture)["documents"] } + let(:document_id) { 55_003_001 } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/checks" } + + describe "check.all" do + before do + stub_request(:get, "#{base_url}/").to_return(body: checks_fixture) + end + + it "fetches the list" do + response = client.check.all + + expect(response["documents"][0]["id"]).to eq(document_id) + end + end + + describe "check.get(id)" do + before do + stub_request(:get, "#{base_url}/#{document_id}/").to_return(body: checks[0].to_json) + end + + it "fetches a check by id" do + response = client.check.get(document_id) + + expect(response["id"]).to eq(document_id) + end + end + + describe "check.process(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: checks[0].to_json) + end + + it "uploads a file and POSTs to /checks/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/checks/", + file_name: "receipt.jpg", + file_data: receipt_file_data + ).and_call_original + + response = client.check.process(file_path: file_fixture_path("receipt.jpg")) + + expect(response["id"]).to eq(document_id) + end + end + + describe "check.process_url(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: checks[0].to_json) + end + + it "POSTs the file_url payload" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/checks/", + file_url: "https://cdn.example.com/check.jpg" + ).and_call_original + + response = client.check.process_url(file_url: "https://cdn.example.com/check.jpg") + + expect(response["id"]).to eq(document_id) + end + end + + describe "check.process_with_remittance(params)" do + before do + stub_request(:post, "https://api.veryfi.com/api/v8/partner/check-with-document/").to_return( + body: response_fixture_body("checks/with_remittance") + ) + end + + it "uploads a file and POSTs to /check-with-document/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/check-with-document/", + file_name: "receipt.jpg", + file_data: receipt_file_data + ).and_call_original + + response = client.check.process_with_remittance(file_path: file_fixture_path("receipt.jpg")) + + expect(response["id"]).to eq(55_009_001) + expect(response["check"]["check_number"]).to eq("0010") + end + end + + describe "check.process_with_remittance_url(params)" do + before do + stub_request(:post, "https://api.veryfi.com/api/v8/partner/check-with-document/").to_return( + body: response_fixture_body("checks/with_remittance") + ) + end + + it "POSTs the file_url payload to /check-with-document/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/check-with-document/", + file_url: "https://cdn.example.com/check.jpg" + ).and_call_original + + response = client.check.process_with_remittance_url(file_url: "https://cdn.example.com/check.jpg") + + expect(response["id"]).to eq(55_009_001) + end + end + + describe "check.update(id, params)" do + before do + stub_request(:put, "#{base_url}/#{document_id}/").to_return( + body: checks[0].merge(notes: "edited").to_json + ) + end + + it "PUTs the update payload" do + response = client.check.update(document_id, notes: "edited") + + expect(response["notes"]).to eq("edited") + end + end + + describe "check.delete(id)" do + before do + stub_request(:delete, "#{base_url}/#{document_id}/").to_return( + body: { status: "ok", message: "Check has been deleted" }.to_json + ) + end + + it "deletes a check" do + response = client.check.delete(document_id) + + expect(response["message"]).to eq("Check has been deleted") + end + end + + describe "check.process_async(params)" do + before do + stub_request(:post, "https://api.veryfi.com/api/v8/partner/checks/async").to_return( + body: { id: document_id, status: "processing" }.to_json + ) + end + + it "POSTs uploaded file to /checks/async" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/checks/async", + file_name: "receipt.jpg", + file_data: receipt_file_data + ).and_call_original + + response = client.check.process_async(file_path: file_fixture_path("receipt.jpg")) + + expect(response["status"]).to eq("processing") + end + end + + describe "check.process_url_async(params)" do + before do + stub_request(:post, "https://api.veryfi.com/api/v8/partner/checks/async").to_return( + body: { id: document_id, status: "processing" }.to_json + ) + end + + it "POSTs file_url to /checks/async" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/checks/async", + file_url: "https://cdn.example.com/check.jpg" + ).and_call_original + + response = client.check.process_url_async(file_url: "https://cdn.example.com/check.jpg") + + expect(response["status"]).to eq("processing") + end + end + + it_behaves_like "a resource with tag operations" do + let(:subject_namespace) { :check } + let(:resource_id) { 55_003_001 } + let(:tags_endpoint) { "https://api.veryfi.com/api/v8/partner/checks/#{resource_id}/tags" } + end +end diff --git a/spec/veryfi/classify_spec.rb b/spec/veryfi/classify_spec.rb new file mode 100644 index 0000000..33da062 --- /dev/null +++ b/spec/veryfi/classify_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Classify API" do + include_context :with_veryfi_client + + let(:classification_fixture) { response_fixture_body("classify/result") } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/classify/" } + let(:document_types) { %w[invoice receipt] } + + describe "classify.process(params)" do + before do + stub_request(:post, base_url).to_return(body: classification_fixture) + end + + let(:expected_payload) do + { file_name: "receipt.jpg", file_data: receipt_file_data, document_types: document_types } + end + + it "uploads a file and POSTs to /classify/" do + expect_any_instance_of(Veryfi::Request).to receive(:post) + .with("/partner/classify/", expected_payload).and_call_original + + response = client.classify.process( + file_path: file_fixture_path("receipt.jpg"), document_types: document_types + ) + + expect(response["document_type"]).to eq("invoice") + end + end + + describe "classify.process_url(params)" do + before do + stub_request(:post, base_url).to_return(body: classification_fixture) + end + + let(:url_payload) do + { file_url: "https://cdn.example.com/receipt.jpg", document_types: document_types } + end + + it "POSTs file_url (and optional document_types) to /classify/" do + expect_any_instance_of(Veryfi::Request).to receive(:post) + .with("/partner/classify/", url_payload).and_call_original + + response = client.classify.process_url(url_payload) + + expect(response["document_type"]).to eq("invoice") + end + end +end diff --git a/spec/veryfi/document_spec.rb b/spec/veryfi/document_spec.rb index c24d4b6..f27dc4f 100644 --- a/spec/veryfi/document_spec.rb +++ b/spec/veryfi/document_spec.rb @@ -5,7 +5,7 @@ RSpec.describe "Document API" do include_context :with_veryfi_client - let(:documents_fixture) { File.read("spec/fixtures/documents.json") } + let(:documents_fixture) { response_fixture_body("documents/list") } let(:documents) { JSON.parse(documents_fixture)["documents"] } it { expect(client.api_url).to eq "https://api.veryfi.com/api/v8" } @@ -22,6 +22,14 @@ expect(response["documents"][0]["id"]).to eq(44_691_518) end + + it "returns a Veryfi::Resource supporting attribute-style access" do + response = client.document.all + + expect(response).to be_a(Veryfi::Resource) + expect(response.documents.first.id).to eq(44_691_518) + expect(response.documents.first.vendor.name).to be_a(String) + end end describe "document.process(id, params)" do @@ -33,7 +41,7 @@ let(:document_params) do { - file_path: Dir.pwd + "/spec/fixtures/receipt.jpg", + file_path: file_fixture_path("receipt.jpg"), auto_delete: true, boost_mode: true, async: false, @@ -49,9 +57,7 @@ } end - let(:expected_file_data) do - File.read("spec/fixtures/receipt_base64.txt").gsub("\n", "") - end + let(:expected_file_data) { receipt_file_data } let(:expected_document_params) do { @@ -160,4 +166,30 @@ expect(response["message"]).to eq("Document has been deleted") end end + + describe "document.process_bulk(file_urls)" do + before do + stub_request(:post, "https://api.veryfi.com/api/v8/partner/documents/bulk/").to_return( + body: { document_ids: [1, 2] }.to_json + ) + end + + let(:file_urls) do + %w[ + https://cdn.example.com/receipt1.jpg + https://cdn.example.com/receipt2.jpg + ] + end + + it "submits the urls in a single bulk request" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/documents/bulk/", + file_urls: file_urls + ).and_call_original + + response = client.document.process_bulk(file_urls) + + expect(response["document_ids"]).to eq([1, 2]) + end + end end diff --git a/spec/veryfi/document_tag_spec.rb b/spec/veryfi/document_tag_spec.rb index 63f179b..ac51afa 100644 --- a/spec/veryfi/document_tag_spec.rb +++ b/spec/veryfi/document_tag_spec.rb @@ -5,7 +5,7 @@ RSpec.describe "Document Tag API" do include_context :with_veryfi_client - let(:tags_fixture) { File.read("spec/fixtures/tags.json") } + let(:tags_fixture) { response_fixture_body("tags/list") } let(:tags) { JSON.parse(tags_fixture)["tags"] } let(:document_id) { 38_947_300 } @@ -66,4 +66,40 @@ expect(response["message"]).to eq("Tag has been removed from document") end end + + describe "document_tag.add_multiple(document_id, tags)" do + before do + stub_request(:post, "#{base_url}/tags/").to_return(body: tags_fixture) + end + + it "can add multiple tags to a document in one call" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/documents/#{document_id}/tags/", + tags: %w[foo bar baz] + ).and_call_original + + response = client.document_tag.add_multiple(document_id, %w[foo bar baz]) + + expect(response["tags"].length).to eq(3) + end + end + + describe "document_tag.replace(document_id, tags)" do + before do + stub_request(:put, "https://api.veryfi.com/api/v8/partner/documents/#{document_id}/").to_return( + body: { id: document_id, tags: tags }.to_json + ) + end + + it "replaces the tags collection on the document" do + expect_any_instance_of(Veryfi::Request).to receive(:put).with( + "/partner/documents/#{document_id}/", + tags: %w[foo bar baz] + ).and_call_original + + response = client.document_tag.replace(document_id, %w[foo bar baz]) + + expect(response["tags"].length).to eq(3) + end + end end diff --git a/spec/veryfi/error_spec.rb b/spec/veryfi/error_spec.rb new file mode 100644 index 0000000..76adbd4 --- /dev/null +++ b/spec/veryfi/error_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Veryfi::Error do + describe ".from_response" do + { + 400 => Veryfi::Error::BadRequest, + 401 => Veryfi::Error::Unauthorized, + 403 => Veryfi::Error::AccessLimitReached, + 404 => Veryfi::Error::NotFound, + 408 => Veryfi::Error::RequestTimeout, + 409 => Veryfi::Error::Conflict, + 415 => Veryfi::Error::UnsupportedMediaType, + 429 => Veryfi::Error::TooManyRequests + }.each do |status, klass| + it "maps HTTP #{status} → #{klass}" do + error = described_class.from_response(status, { "error" => "boom" }) + + expect(error).to be_a(klass) + expect(error).to be_a(Veryfi::Error::VeryfiError) + expect(error.status).to eq(status) + end + end + + it "maps other 4xx statuses to ClientError" do + error = described_class.from_response(418, { "error" => "I'm a teapot" }) + + expect(error).to be_a(Veryfi::Error::ClientError) + expect(error).to be_a(Veryfi::Error::VeryfiError) + end + + it "maps 5xx statuses to ServerError" do + error = described_class.from_response(503, { "error" => "unavailable" }) + + expect(error).to be_a(Veryfi::Error::ServerError) + expect(error).to be_a(Veryfi::Error::VeryfiError) + end + + it "exposes the parsed response and status" do + response = { "error" => "boom", "code" => 400 } + error = described_class.from_response(400, response) + + expect(error.response).to eq(response) + expect(error.status).to eq(400) + end + + it "pretty-prints the response in #message and #to_s" do + error = described_class.from_response(400, { "code" => 400, "error" => "Bad" }) + + expected = <<~TEXT.chomp + { + "code": 400, + "error": "Bad" + } + TEXT + expect(error.message).to eq(expected) + expect(error.to_s).to eq(expected) + end + + it "propagates the message to StandardError#message (super) — regression for super(message) bug" do + error = described_class.from_response(400, { "error" => "oops" }) + + expect(StandardError.instance_method(:message).bind(error).call).to eq(error.message) + end + + it "falls back to status string when response is empty" do + error = described_class.from_response(501, {}) + + expect(error).to be_a(Veryfi::Error::ServerError) + expect(error.message).to eq("501") + expect(error.response).to eq({}) + end + + it "falls back to the base VeryfiError for non-4xx/5xx statuses" do + error = described_class.from_response(302, { "error" => "redirected" }) + + expect(error.class).to eq(Veryfi::Error::VeryfiError) + expect(error.status).to eq(302) + end + end +end diff --git a/spec/veryfi/line_item_spec.rb b/spec/veryfi/line_item_spec.rb index 27261a8..5d45ce6 100644 --- a/spec/veryfi/line_item_spec.rb +++ b/spec/veryfi/line_item_spec.rb @@ -5,7 +5,7 @@ RSpec.describe "LineItem API" do include_context :with_veryfi_client - let(:line_items_fixture) { File.read("spec/fixtures/line_items.json") } + let(:line_items_fixture) { response_fixture_body("documents/line_items") } let(:line_items) { JSON.parse(line_items_fixture)["line_items"] } let(:document_id) { 38_947_300 } @@ -109,4 +109,18 @@ expect(response["message"]).to eq("Line item has been deleted") end end + + describe "line_item.delete_all(document_id)" do + before do + stub_request(:delete, "#{base_url}/line-items").to_return( + body: { status: "ok", message: "All line items have been deleted" }.to_json + ) + end + + it "can delete all line items for a document" do + response = client.line_item.delete_all(document_id) + + expect(response["message"]).to eq("All line items have been deleted") + end + end end diff --git a/spec/veryfi/pdf_split_spec.rb b/spec/veryfi/pdf_split_spec.rb new file mode 100644 index 0000000..97bf5e5 --- /dev/null +++ b/spec/veryfi/pdf_split_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "PdfSplit API" do + include_context :with_veryfi_client + + let(:documents_set_fixture) { response_fixture_body("documents_set/list") } + let(:documents_set) { JSON.parse(documents_set_fixture)["documents"] } + let(:document_id) { 66_004_001 } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/documents-set" } + + describe "pdf_split.all" do + before do + stub_request(:get, "#{base_url}/").to_return(body: documents_set_fixture) + end + + it "fetches the list" do + response = client.pdf_split.all + + expect(response["documents"][0]["id"]).to eq(document_id) + end + end + + describe "pdf_split.get(id)" do + before do + stub_request(:get, "#{base_url}/#{document_id}").to_return(body: documents_set[0].to_json) + end + + it "fetches the documents extracted from a PDF" do + response = client.pdf_split.get(document_id) + + expect(response["id"]).to eq(document_id) + expect(response["documents"].length).to eq(2) + end + end + + describe "pdf_split.process(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: documents_set[0].to_json) + end + + it "uploads a file and POSTs to /documents-set/ (defaulting categories to [])" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/documents-set/", + file_name: "receipt.jpg", + file_data: receipt_file_data, + categories: [] + ).and_call_original + + response = client.pdf_split.process(file_path: file_fixture_path("receipt.jpg")) + + expect(response["id"]).to eq(document_id) + end + end + + describe "pdf_split.process_url(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: documents_set[0].to_json) + end + + it "POSTs file_url to /documents-set/ (defaulting categories to [])" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/documents-set/", + file_url: "https://cdn.example.com/multi.pdf", + categories: [] + ).and_call_original + + response = client.pdf_split.process_url(file_url: "https://cdn.example.com/multi.pdf") + + expect(response["id"]).to eq(document_id) + end + end +end diff --git a/spec/veryfi/request_spec.rb b/spec/veryfi/request_spec.rb index 9cd725c..77345e2 100644 --- a/spec/veryfi/request_spec.rb +++ b/spec/veryfi/request_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "spec_helper" -require "active_support/core_ext/string/strip" RSpec.describe Veryfi::Request do include_context :with_veryfi_client @@ -34,7 +33,7 @@ end let(:expected_error) do - <<-TEXT.strip_heredoc.chomp + <<~TEXT.chomp { "code": 400, "error": "Bad Request" @@ -71,7 +70,7 @@ end let(:expected_error) do - <<-TEXT.strip_heredoc.chomp + <<~TEXT.chomp { "code": 400, "error": "Bad Request", diff --git a/spec/veryfi/resource_spec.rb b/spec/veryfi/resource_spec.rb new file mode 100644 index 0000000..aa76b00 --- /dev/null +++ b/spec/veryfi/resource_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "spec_helper" +require "json" + +RSpec.describe Veryfi::Resource do + subject(:resource) { described_class.new(raw) } + + let(:raw) do + { + "id" => 44_691_518, + "is_duplicate" => true, + "vendor" => { + "name" => "East Repair", + "address" => "1912 Harvest Lane" + }, + "line_items" => [ + { "id" => 101, "description" => "Brake cables", "total" => 100 }, + { "id" => 102, "description" => "Pedal arms", "total" => 30 } + ], + "tags" => [], + "notes" => nil + } + end + + describe "Hash compatibility (no breakage for existing callers)" do + it "is a Hash" do + expect(resource).to be_a(Hash) + end + + it "supports string-key access exactly like a Hash" do + expect(resource["id"]).to eq(44_691_518) + expect(resource["vendor"]["name"]).to eq("East Repair") + end + + it "supports symbol-key access transparently" do + expect(resource[:id]).to eq(44_691_518) + expect(resource.fetch(:vendor).fetch(:name)).to eq("East Repair") + end + + it "supports dig with both string and symbol keys" do + expect(resource.dig("vendor", "name")).to eq("East Repair") + end + + it "supports key? / include? / member? / has_key? on both string and symbol" do + expect(resource.key?("id")).to be(true) + expect(resource.key?(:id)).to be(true) + expect(resource.include?(:id)).to be(true) + end + + it "compares equal to an equivalent plain Hash" do + expect(resource).to eq(raw) + end + + it "round-trips through JSON unchanged" do + expect(JSON.parse(JSON.generate(resource))).to eq(raw) + end + + it "iterates like a Hash via each_pair" do + keys = [] + resource.each_pair { |k, _v| keys << k } + expect(keys).to match_array(raw.keys) + end + end + + describe "attribute-style access" do + it "exposes every key as a reader method" do + expect(resource.id).to eq(44_691_518) + expect(resource.notes).to be_nil + end + + it "recursively wraps nested hashes" do + expect(resource.vendor).to be_a(described_class) + expect(resource.vendor.name).to eq("East Repair") + end + + it "wraps each element of an array of hashes" do + expect(resource.line_items).to all(be_a(described_class)) + expect(resource.line_items.first.description).to eq("Brake cables") + expect(resource.line_items.map(&:total)).to eq([100, 30]) + end + + it "treats `name?` style as a truthiness predicate" do + expect(resource.is_duplicate?).to be(true) + expect(resource.notes?).to be(false) + end + + it "passes leaf values through unwrapped" do + expect(resource.id).to be_a(Integer) + expect(resource.tags).to eq([]) + end + + it "raises NoMethodError for unknown keys (unlike OpenStruct returning nil)" do + expect { resource.bogus_field }.to raise_error(NoMethodError) + end + + it "respond_to? agrees with method_missing" do + expect(resource).to respond_to(:id) + expect(resource).to respond_to(:is_duplicate?) + expect(resource).not_to respond_to(:bogus_field) + end + end + + describe ".wrap" do + it "returns Resource for a Hash" do + expect(described_class.wrap("id" => 1)).to be_a(described_class) + end + + it "returns an Array of Resources for an Array of Hashes" do + result = described_class.wrap([{ "id" => 1 }, { "id" => 2 }]) + expect(result).to all(be_a(described_class)) + expect(result.map(&:id)).to eq([1, 2]) + end + + it "passes scalars through" do + expect(described_class.wrap(42)).to eq(42) + expect(described_class.wrap("hello")).to eq("hello") + expect(described_class.wrap(nil)).to be_nil + end + + it "does not double-wrap an existing Resource" do + r = described_class.new("id" => 1) + expect(described_class.wrap(r)).to equal(r) + end + end + + describe "#to_h" do + it "returns a plain Hash, recursively unwrapping" do + plain = resource.to_h + expect(plain).to be_an_instance_of(Hash) + expect(plain["vendor"]).to be_an_instance_of(Hash) + expect(plain["line_items"].first).to be_an_instance_of(Hash) + end + end +end diff --git a/spec/veryfi/resource_wrapping_integration_spec.rb b/spec/veryfi/resource_wrapping_integration_spec.rb new file mode 100644 index 0000000..dfef369 --- /dev/null +++ b/spec/veryfi/resource_wrapping_integration_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "spec_helper" + +# End-to-end verification that EVERY API namespace returns responses +# wrapped in `Veryfi::Resource` and exposes them through both: +# * Hash-style access: response["id"], response[:id], response.dig(...) +# * attribute-style access: response.id +# +# The unit behaviour of `Veryfi::Resource` is already covered in detail by +# `spec/veryfi/resource_spec.rb`. The point of this file is to prove that +# the wrapping actually happens through the real `Request#process_response` +# code path for every resource class, so a future refactor in `Request` +# can't silently regress one endpoint while leaving the others working. + +module ResourceWrappingFixtures + FIRST_DOC = ->(body) { JSON.parse(body)["documents"].first.to_json } + + # rubocop:disable Layout/LineLength, Layout/ExtraSpacing + CASES = [ + { resource: :document, method: :get, args: [44_691_518], verb: :get, path: "/partner/documents/44691518", fixture: "documents/list", response_transformer: FIRST_DOC, attribute: :id, expected: 44_691_518 }, + { resource: :line_item, method: :all, args: [12_345], verb: :get, path: "/partner/documents/12345/line-items/", fixture: "documents/line_items", attribute: :id, expected: 101_170_751, returns: :array }, + { resource: :tax_line, method: :all, args: [12_345], verb: :get, path: "/partner/documents/12345/tax-lines", fixture: "documents/tax_lines", attribute: :tax_lines, expected: :array }, + { resource: :tag, method: :all, args: [], verb: :get, path: "/partner/tags/", fixture: "tags/list", attribute: :id, expected: 75_788_890, returns: :array }, + { resource: :any_document, method: :get, args: [71_012_001], verb: :get, path: "/partner/any-documents/71012001/", fixture: "any_documents/list", response_transformer: FIRST_DOC, attribute: :id, expected: 71_012_001 }, + { resource: :bank_statement, method: :get, args: [88_001_001], verb: :get, path: "/partner/bank-statements/88001001/", fixture: "bank_statements/list", response_transformer: FIRST_DOC, attribute: :id, expected: 88_001_001 }, + { resource: :bank_statement_split, method: :get, args: [89_010_001], verb: :get, path: "/partner/bank-statements-set/89010001", fixture: "bank_statements_set/list", response_transformer: FIRST_DOC, attribute: :id, expected: 89_010_001 }, + { resource: :business_card, method: :get, args: [99_002_001], verb: :get, path: "/partner/business-cards/99002001/", fixture: "business_cards/list", response_transformer: FIRST_DOC, attribute: :id, expected: 99_002_001 }, + { resource: :check, method: :get, args: [55_003_001], verb: :get, path: "/partner/checks/55003001/", fixture: "checks/list", response_transformer: FIRST_DOC, attribute: :id, expected: 55_003_001 }, + { resource: :pdf_split, method: :get, args: [66_004_001], verb: :get, path: "/partner/documents-set/66004001", fixture: "documents_set/list", response_transformer: FIRST_DOC, attribute: :id, expected: 66_004_001 }, + { resource: :w2, method: :get, args: [77_005_001], verb: :get, path: "/partner/w2s/77005001/", fixture: "w2s/list", response_transformer: FIRST_DOC, attribute: :id, expected: 77_005_001 }, + { resource: :w2_split, method: :get, args: [78_006_001], verb: :get, path: "/partner/w2s-set/78006001/", fixture: "w2s_set/list", response_transformer: FIRST_DOC, attribute: :id, expected: 78_006_001 }, + { resource: :w8, method: :get, args: [79_007_001], verb: :get, path: "/partner/w-8ben-e/79007001/", fixture: "w8s/list", response_transformer: FIRST_DOC, attribute: :id, expected: 79_007_001 }, + { resource: :w9, method: :get, args: [80_008_001], verb: :get, path: "/partner/w9s/80008001/", fixture: "w9s/list", response_transformer: FIRST_DOC, attribute: :id, expected: 80_008_001 }, + { resource: :classify, method: :process_url, args: [{ file_url: "https://x/x.jpg" }], verb: :post, path: "/partner/classify/", fixture: "classify/result", attribute: :document_type, expected: "invoice" } + ].freeze + # rubocop:enable Layout/LineLength, Layout/ExtraSpacing +end + +RSpec.describe "Resource wrapping (end-to-end through Request)" do + include_context :with_veryfi_client + + ResourceWrappingFixtures::CASES.each do |c| + describe "client.#{c[:resource]}.#{c[:method]}" do + let(:body) do + raw = response_fixture_body(c[:fixture]) + c[:response_transformer] ? c[:response_transformer].call(raw) : raw + end + + let(:response) do + stub_request(c[:verb], "https://api.veryfi.com/api/v8#{c[:path]}").to_return(body: body) + client.public_send(c[:resource]).public_send(c[:method], *c[:args]) + end + + it "returns a Hash-compatible Veryfi::Resource (or Array thereof)" do + if c[:returns] == :array + expect(response).to be_an(Array) + expect(response.first).to be_a(Veryfi::Resource) + expect(response.first).to be_a(Hash) + else + expect(response).to be_a(Veryfi::Resource) + expect(response).to be_a(Hash) + end + end + + it "returns the same value via [\"#{c[:attribute]}\"], [:#{c[:attribute]}] and .#{c[:attribute]}" do + target = c[:returns] == :array ? response.first : response + + bracket_str = target[c[:attribute].to_s] + bracket_sym = target[c[:attribute].to_sym] + method_call = target.public_send(c[:attribute]) + + expect(bracket_str).to eq(bracket_sym) + expect(bracket_str).to eq(method_call) + expect(bracket_str).to eq(c[:expected]) unless c[:expected] == :array + end + end + end + + describe "deeply nested attribute access (recursive wrapping)" do + let(:response) do + stub_request(:get, "https://api.veryfi.com/api/v8/partner/documents/44691518").to_return( + body: JSON.parse(response_fixture_body("documents/list"))["documents"].first.to_json + ) + client.document.get(44_691_518) + end + + it "wraps nested hashes" do + expect(response.bill_to).to be_a(Veryfi::Resource) + expect(response.bill_to.name).to eq(response["bill_to"]["name"]) + end + + it "wraps arrays of hashes into arrays of Resources" do + expect(response.line_items).to all(be_a(Veryfi::Resource)) + expect(response.line_items.first.description).to eq(response["line_items"][0]["description"]) + end + end +end diff --git a/spec/veryfi/tag_spec.rb b/spec/veryfi/tag_spec.rb index 0f68ec9..ca02568 100644 --- a/spec/veryfi/tag_spec.rb +++ b/spec/veryfi/tag_spec.rb @@ -5,7 +5,7 @@ RSpec.describe "Tag API" do include_context :with_veryfi_client - let(:tags_fixture) { File.read("spec/fixtures/tags.json") } + let(:tags_fixture) { response_fixture_body("tags/list") } let(:tags) { JSON.parse(tags_fixture)["tags"] } let(:document_id) { 38_947_300 } diff --git a/spec/veryfi/tax_line_spec.rb b/spec/veryfi/tax_line_spec.rb new file mode 100644 index 0000000..a1224bf --- /dev/null +++ b/spec/veryfi/tax_line_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "TaxLine API" do + include_context :with_veryfi_client + + let(:tax_lines_fixture) { response_fixture_body("documents/tax_lines") } + let(:tax_lines) { JSON.parse(tax_lines_fixture)["tax_lines"] } + let(:document_id) { 44_691_518 } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/documents/#{document_id}/tax-lines" } + + describe "tax_line.all(document_id)" do + before do + stub_request(:get, base_url).to_return(body: tax_lines_fixture) + end + + it "fetches tax lines for a document" do + response = client.tax_line.all(document_id) + + expect(response["tax_lines"][0]["id"]).to eq(12_009_001) + end + end + + describe "tax_line.create(document_id, params)" do + before do + stub_request(:post, base_url).to_return(body: tax_lines[0].to_json) + end + + let(:tax_line_params) do + { name: "Sales Tax", rate: 6.25, base: 145.0, total: 9.06, order: 0 } + end + + it "creates a tax line" do + response = client.tax_line.create(document_id, tax_line_params) + + expect(response["id"]).to eq(12_009_001) + end + end + + describe "tax_line.get(document_id, id)" do + before do + stub_request(:get, "#{base_url}/12009001").to_return(body: tax_lines[0].to_json) + end + + it "fetches a tax line by id" do + response = client.tax_line.get(document_id, 12_009_001) + + expect(response["id"]).to eq(12_009_001) + end + end + + describe "tax_line.update(document_id, id, params)" do + before do + stub_request(:put, "#{base_url}/12009001").to_return( + body: tax_lines[0].merge(rate: 7.0).to_json + ) + end + + it "updates a tax line" do + response = client.tax_line.update(document_id, 12_009_001, rate: 7.0) + + expect(response["rate"]).to eq(7.0) + end + end + + describe "tax_line.delete(document_id, id)" do + before do + stub_request(:delete, "#{base_url}/12009001").to_return( + body: { status: "ok", message: "Tax line has been deleted" }.to_json + ) + end + + it "deletes a tax line" do + response = client.tax_line.delete(document_id, 12_009_001) + + expect(response["message"]).to eq("Tax line has been deleted") + end + end +end diff --git a/spec/veryfi/w2_spec.rb b/spec/veryfi/w2_spec.rb new file mode 100644 index 0000000..50f96c3 --- /dev/null +++ b/spec/veryfi/w2_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "W2 API" do + include_context :with_veryfi_client + + let(:w2s_fixture) { response_fixture_body("w2s/list") } + let(:w2s) { JSON.parse(w2s_fixture)["documents"] } + let(:document_id) { 77_005_001 } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/w2s" } + + describe "w2.all" do + before do + stub_request(:get, "#{base_url}/").to_return(body: w2s_fixture) + end + + it "fetches the list" do + response = client.w2.all + + expect(response["documents"][0]["id"]).to eq(document_id) + end + end + + describe "w2.get(id)" do + before do + stub_request(:get, "#{base_url}/#{document_id}/").to_return(body: w2s[0].to_json) + end + + it "fetches a W-2 by id" do + response = client.w2.get(document_id) + + expect(response["id"]).to eq(document_id) + end + end + + describe "w2.process(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: w2s[0].to_json) + end + + it "uploads a file and POSTs to /w2s/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/w2s/", + file_name: "receipt.jpg", + file_data: receipt_file_data + ).and_call_original + + response = client.w2.process(file_path: file_fixture_path("receipt.jpg")) + + expect(response["id"]).to eq(document_id) + end + end + + describe "w2.process_url(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: w2s[0].to_json) + end + + it "defaults file_name from file_url" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/w2s/", + file_url: "https://cdn.example.com/w2.pdf", + file_name: "w2.pdf" + ).and_call_original + + response = client.w2.process_url(file_url: "https://cdn.example.com/w2.pdf") + + expect(response["id"]).to eq(document_id) + end + end + + describe "w2.update(id, params)" do + before do + stub_request(:put, "#{base_url}/#{document_id}/").to_return( + body: w2s[0].merge(notes: "edited").to_json + ) + end + + it "updates a W-2" do + response = client.w2.update(document_id, notes: "edited") + + expect(response["notes"]).to eq("edited") + end + end + + describe "w2.delete(id)" do + before do + stub_request(:delete, "#{base_url}/#{document_id}/").to_return( + body: { status: "ok", message: "W-2 has been deleted" }.to_json + ) + end + + it "deletes a W-2" do + response = client.w2.delete(document_id) + + expect(response["message"]).to eq("W-2 has been deleted") + end + end + + it_behaves_like "a resource with tag operations" do + let(:subject_namespace) { :w2 } + let(:resource_id) { 77_005_001 } + let(:tags_endpoint) { "https://api.veryfi.com/api/v8/partner/w2s/#{resource_id}/tags" } + end +end diff --git a/spec/veryfi/w2_split_spec.rb b/spec/veryfi/w2_split_spec.rb new file mode 100644 index 0000000..50f423e --- /dev/null +++ b/spec/veryfi/w2_split_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "W2Split API" do + include_context :with_veryfi_client + + let(:w2s_set_fixture) { response_fixture_body("w2s_set/list") } + let(:w2s_set) { JSON.parse(w2s_set_fixture)["documents"] } + let(:document_id) { 78_006_001 } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/w2s-set" } + + describe "w2_split.all" do + before do + stub_request(:get, "#{base_url}/").to_return(body: w2s_set_fixture) + end + + it "fetches the list" do + response = client.w2_split.all + + expect(response["documents"][0]["id"]).to eq(document_id) + end + end + + describe "w2_split.get(id)" do + before do + stub_request(:get, "#{base_url}/#{document_id}/").to_return(body: w2s_set[0].to_json) + end + + it "fetches the documents extracted from a multi-W2 file" do + response = client.w2_split.get(document_id) + + expect(response["id"]).to eq(document_id) + expect(response["documents"].length).to eq(2) + end + end + + describe "w2_split.process(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: w2s_set[0].to_json) + end + + it "uploads a file and POSTs to /w2s-set/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/w2s-set/", + file_name: "receipt.jpg", + file_data: receipt_file_data + ).and_call_original + + response = client.w2_split.process(file_path: file_fixture_path("receipt.jpg")) + + expect(response["id"]).to eq(document_id) + end + end + + describe "w2_split.process_url(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: w2s_set[0].to_json) + end + + it "POSTs file_urls to /w2s-set/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/w2s-set/", + file_urls: %w[https://cdn.example.com/w2_one.pdf https://cdn.example.com/w2_two.pdf] + ).and_call_original + + response = client.w2_split.process_url( + file_urls: %w[https://cdn.example.com/w2_one.pdf https://cdn.example.com/w2_two.pdf] + ) + + expect(response["id"]).to eq(document_id) + end + end +end diff --git a/spec/veryfi/w8_spec.rb b/spec/veryfi/w8_spec.rb new file mode 100644 index 0000000..9df74e2 --- /dev/null +++ b/spec/veryfi/w8_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "W8 API" do + include_context :with_veryfi_client + + let(:w8s_fixture) { response_fixture_body("w8s/list") } + let(:w8s) { JSON.parse(w8s_fixture)["documents"] } + let(:document_id) { 79_007_001 } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/w-8ben-e" } + + describe "w8.all" do + before do + stub_request(:get, "#{base_url}/").to_return(body: w8s_fixture) + end + + it "fetches the list" do + response = client.w8.all + + expect(response["documents"][0]["id"]).to eq(document_id) + end + end + + describe "w8.get(id)" do + before do + stub_request(:get, "#{base_url}/#{document_id}/").to_return(body: w8s[0].to_json) + end + + it "fetches a W-8 by id" do + response = client.w8.get(document_id) + + expect(response["id"]).to eq(document_id) + end + end + + describe "w8.process(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: w8s[0].to_json) + end + + it "uploads a file and POSTs to /w-8ben-e/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/w-8ben-e/", + file_name: "receipt.jpg", + file_data: receipt_file_data + ).and_call_original + + response = client.w8.process(file_path: file_fixture_path("receipt.jpg")) + + expect(response["id"]).to eq(document_id) + end + end + + describe "w8.process_url(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: w8s[0].to_json) + end + + it "defaults file_name from file_url" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/w-8ben-e/", + file_url: "https://cdn.example.com/w8.pdf", + file_name: "w8.pdf" + ).and_call_original + + response = client.w8.process_url(file_url: "https://cdn.example.com/w8.pdf") + + expect(response["id"]).to eq(document_id) + end + end + + describe "w8.update(id, params)" do + before do + stub_request(:put, "#{base_url}/#{document_id}/").to_return( + body: w8s[0].merge(notes: "edited").to_json + ) + end + + it "updates a W-8" do + response = client.w8.update(document_id, notes: "edited") + + expect(response["notes"]).to eq("edited") + end + end + + describe "w8.delete(id)" do + before do + stub_request(:delete, "#{base_url}/#{document_id}/").to_return( + body: { status: "ok", message: "W-8 has been deleted" }.to_json + ) + end + + it "deletes a W-8" do + response = client.w8.delete(document_id) + + expect(response["message"]).to eq("W-8 has been deleted") + end + end + + it_behaves_like "a resource with tag operations" do + let(:subject_namespace) { :w8 } + let(:resource_id) { 79_007_001 } + let(:tags_endpoint) { "https://api.veryfi.com/api/v8/partner/w-8ben-e/#{resource_id}/tags" } + end +end diff --git a/spec/veryfi/w9_spec.rb b/spec/veryfi/w9_spec.rb new file mode 100644 index 0000000..daf1968 --- /dev/null +++ b/spec/veryfi/w9_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "W9 API" do + include_context :with_veryfi_client + + let(:w9s_fixture) { response_fixture_body("w9s/list") } + let(:w9s) { JSON.parse(w9s_fixture)["documents"] } + let(:document_id) { 80_008_001 } + let(:base_url) { "https://api.veryfi.com/api/v8/partner/w9s" } + + describe "w9.all" do + before do + stub_request(:get, "#{base_url}/").to_return(body: w9s_fixture) + end + + it "fetches the list" do + response = client.w9.all + + expect(response["documents"][0]["id"]).to eq(document_id) + end + end + + describe "w9.get(id)" do + before do + stub_request(:get, "#{base_url}/#{document_id}/").to_return(body: w9s[0].to_json) + end + + it "fetches a W-9 by id" do + response = client.w9.get(document_id) + + expect(response["id"]).to eq(document_id) + end + end + + describe "w9.process(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: w9s[0].to_json) + end + + it "uploads a file and POSTs to /w9s/" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/w9s/", + file_name: "receipt.jpg", + file_data: receipt_file_data + ).and_call_original + + response = client.w9.process(file_path: file_fixture_path("receipt.jpg")) + + expect(response["id"]).to eq(document_id) + end + end + + describe "w9.process_url(params)" do + before do + stub_request(:post, "#{base_url}/").to_return(body: w9s[0].to_json) + end + + it "defaults file_name from file_url" do + expect_any_instance_of(Veryfi::Request).to receive(:post).with( + "/partner/w9s/", + file_url: "https://cdn.example.com/w9.pdf", + file_name: "w9.pdf" + ).and_call_original + + response = client.w9.process_url(file_url: "https://cdn.example.com/w9.pdf") + + expect(response["id"]).to eq(document_id) + end + end + + describe "w9.update(id, params)" do + before do + stub_request(:put, "#{base_url}/#{document_id}/").to_return( + body: w9s[0].merge(notes: "edited").to_json + ) + end + + it "updates a W-9" do + response = client.w9.update(document_id, notes: "edited") + + expect(response["notes"]).to eq("edited") + end + end + + describe "w9.delete(id)" do + before do + stub_request(:delete, "#{base_url}/#{document_id}/").to_return( + body: { status: "ok", message: "W-9 has been deleted" }.to_json + ) + end + + it "deletes a W-9" do + response = client.w9.delete(document_id) + + expect(response["message"]).to eq("W-9 has been deleted") + end + end + + it_behaves_like "a resource with tag operations" do + let(:subject_namespace) { :w9 } + let(:resource_id) { 80_008_001 } + let(:tags_endpoint) { "https://api.veryfi.com/api/v8/partner/w9s/#{resource_id}/tags" } + end +end diff --git a/spec/veryfi_spec.rb b/spec/veryfi_spec.rb new file mode 100644 index 0000000..796a33d --- /dev/null +++ b/spec/veryfi_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Veryfi do + before { described_class.reset! } + after { described_class.reset! } + + describe ".configure" do + it "yields a Configuration the caller can populate" do + described_class.configure do |c| + c.client_id = "ci" + c.client_secret = "cs" + c.username = "u" + c.api_key = "k" + end + + expect(described_class.configuration.client_id).to eq("ci") + expect(described_class.configuration.username).to eq("u") + end + + it "returns the Configuration even without a block" do + expect(described_class.configure).to be_a(Veryfi::Configuration) + end + + it "exposes sensible defaults" do + expect(described_class.configuration.base_url).to eq("https://api.veryfi.com/api/") + expect(described_class.configuration.api_version).to eq("v8") + expect(described_class.configuration.timeout).to eq(20) + end + end + + describe ".client" do + before do + described_class.configure do |c| + c.client_id = "ci" + c.client_secret = "cs" + c.username = "u" + c.api_key = "k" + end + end + + it "returns a memoized Veryfi::Client built from the configuration" do + expect(described_class.client).to be_a(Veryfi::Client) + expect(described_class.client).to equal(described_class.client) + end + + it "rebuilds the client when configuration changes" do + first = described_class.client + described_class.configure { |c| c.api_version = "v9" } + second = described_class.client + + expect(second).not_to equal(first) + expect(second.api_url).to end_with("v9") + end + end + + describe ".reset!" do + it "clears memoized configuration and client" do + described_class.configure do |c| + c.client_id = "ci" + c.client_secret = "cs" + c.username = "u" + c.api_key = "k" + end + original_config = described_class.configuration + + described_class.reset! + + expect(described_class.configuration).not_to equal(original_config) + expect(described_class.configuration.client_id).to be_nil + end + end +end diff --git a/veryfi.gemspec b/veryfi.gemspec index 09d04a3..6ae19fd 100644 --- a/veryfi.gemspec +++ b/veryfi.gemspec @@ -27,9 +27,11 @@ Gem::Specification.new do |spec| ".rubocop.yml", ".ruby-version", ".semaphore/semaphore.yml", + ".yardopts", "Gemfile", "Gemfile.lock", "README.md", + "Rakefile", "bin/autospec", "bin/bundle-audit", "bin/ci", @@ -48,13 +50,29 @@ Gem::Specification.new do |spec| "docs/index.markdown", "lib/.keep", "lib/veryfi.rb", + "lib/veryfi/api/any_document.rb", + "lib/veryfi/api/bank_statement.rb", + "lib/veryfi/api/bank_statement_split.rb", + "lib/veryfi/api/business_card.rb", + "lib/veryfi/api/check.rb", + "lib/veryfi/api/classify.rb", "lib/veryfi/api/document.rb", "lib/veryfi/api/document_tag.rb", + "lib/veryfi/api/file_payload.rb", "lib/veryfi/api/line_item.rb", + "lib/veryfi/api/pdf_split.rb", "lib/veryfi/api/tag.rb", + "lib/veryfi/api/tag_operations.rb", + "lib/veryfi/api/tax_line.rb", + "lib/veryfi/api/w2.rb", + "lib/veryfi/api/w2_split.rb", + "lib/veryfi/api/w8.rb", + "lib/veryfi/api/w9.rb", "lib/veryfi/client.rb", + "lib/veryfi/configuration.rb", "lib/veryfi/error.rb", "lib/veryfi/request.rb", + "lib/veryfi/resource.rb", "lib/veryfi/signature.rb", "lib/veryfi/version.rb", "veryfi.gemspec" @@ -65,10 +83,13 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.license = "MIT" + spec.required_ruby_version = ">= 3.0" + spec.add_dependency "base64", "~> 0.1" spec.add_dependency "openssl", ">= 2.2", "< 4.1" - spec.add_dependency "faraday", ">= 1.7", "< 3.0" + # Faraday 2.14.1 is the floor of the CVE-2026-25765-fixed 2.x line. + spec.add_dependency "faraday", ">= 2.14.1", "< 3.0" spec.add_development_dependency "bundler", "~> 2.4" spec.add_development_dependency "bundler-audit", "~> 0.9" @@ -77,11 +98,12 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rspec", "~> 3.9" spec.add_development_dependency "rspec-its", "~> 1.3" spec.add_development_dependency "rubocop", "~> 1.65" + spec.add_development_dependency "rubocop-rake", "~> 0.6" spec.add_development_dependency "rubocop-rspec", "~> 3.0" spec.add_development_dependency "simplecov", "~> 0.21" spec.add_development_dependency "simplecov-badge", "~> 2.0" spec.add_development_dependency "vcr", "~> 6.0" spec.add_development_dependency "webmock", "~> 3.14" spec.add_development_dependency "rexml", "~> 3.4.4" - spec.add_development_dependency "activesupport", "~> 6.0" + spec.add_development_dependency "yard", "~> 0.9" end