From eec6b769f1661d1ac3eb896b3d9697db10ba656b Mon Sep 17 00:00:00 2001 From: Sebastian Carmona <83849194+Kaevan89@users.noreply.github.com> Date: Tue, 26 May 2026 19:34:18 -0500 Subject: [PATCH 1/2] Expand API coverage, add Resource wrapper, error subclasses, tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand API coverage, add Resource wrapper, error subclasses, tooling Brings the SDK to feature parity with veryfi-python (minus CLI), adds a lightweight Hash-compatible response wrapper, splits errors by HTTP status, refreshes tooling and dependencies, and gets the suite to 100% line coverage on lib/. Bumps to 4.0.0. The 4.x line is non-breaking for existing 3.x callers because Veryfi::Resource is a subclass of Hash and the new error subclasses all inherit from Veryfi::Error::VeryfiError — existing `response["id"]` access and `rescue Veryfi::Error::VeryfiError` calls keep working unchanged. == New API coverage == - Any Document (A-Docs): client.any_document.* (incl. async) - Bank Statement: client.bank_statement.* (incl. async) - Bank Statement Split: client.bank_statement_split.* - Business Card: client.business_card.* - Check: client.check.* (incl. async and remittance) - Classify: client.classify.* - PDF Split: client.pdf_split.* - Tax Lines: client.tax_line.* - W-2 / W-8 BEN-E / W-9: client.w2.*, .w8.*, .w9.* - W-2 Split: client.w2_split.* - Extensions: document.process_bulk, document_tag.add_multiple / document_tag.replace, line_item.delete_all, and per-resource tag operations on every processed-document resource via a TagOperations mix-in. == Veryfi::Resource — lightweight response wrapper == API responses are now Veryfi::Resource objects (a subclass of Hash) that support BOTH access styles equivalently: response["id"] # works (Hash compat) response[:id] # symbol keys too response.id # attribute access response.vendor.name # nested response.line_items.first.total # recursive wrapping of arrays response.is_duplicate? # ? predicate sugar JSON.pretty_generate(response) # serializes identically Nested hashes are wrapped recursively, arrays of hashes become arrays of Resources, leaf values pass through. Unknown keys raise NoMethodError so typos surface immediately instead of silently returning nil. #to_h returns a recursively-unwrapped plain Hash on demand. == Error model == - Fix: Veryfi::Error::VeryfiError now calls super(@message) instead of super(message), so error.full_message and the rest of Exception machinery see the same string error.message returns. - Add HTTP-status subclasses, all inheriting from VeryfiError: BadRequest (400), Unauthorized (401), AccessLimitReached (403), NotFound (404), RequestTimeout (408), Conflict (409), UnsupportedMediaType (415), TooManyRequests (429), ClientError (other 4xx), ServerError (5xx). - Every error exposes #status, #response and #message. == Configuration + custom Faraday == - Veryfi.configure { |c| ... } + Veryfi.client for process-wide singletons (Rails-style). Veryfi.reset! for tests. - Veryfi::Client.new accepts a `faraday:` lambda that runs against the Faraday::Connection before first use, so users can attach retries, persistent connections, logging, or any middleware without us bundling extra deps. == Dependencies == - Drop runtime dep on activesupport (only used for strip_heredoc in one spec — replaced with squiggly heredoc). - Bump faraday constraint to >= 1.10.5, < 3.0 to clear CVE-2026-25765. Gemfile is now conditional per Ruby version: faraday 2.14.1+ on Ruby 3.0+ (where faraday 2.9+ is required), faraday 1.10.x on Ruby 2.7. - json updated to 2.19.5 to clear CVE-2026-33210. - bundle-audit is now clean on both supported Rubies. == Tooling == - Add Rakefile with `rake spec`, `rake rubocop`, `rake yard`, `rake ci`. - Add .yardopts (powers https://veryfi.github.io/veryfi-ruby/). - .rubocop.yml: add rubocop-rake plugin, set SuggestExtensions: false, bump Metrics/ParameterLists max to 8 for the new faraday: kwarg. - Add dev-deps: yard, rubocop-rake. == Internals == - Replace 17 copy-pasted memoized accessors in Veryfi::Client with an `api_namespace :document, Veryfi::Api::Document` DSL — wiring a new API class is now a single line. - Normalize HTTP headers in Veryfi::Request to all-string keys for cleaner Faraday middleware integration. == Docs == - README rewrite: * Fix outdated install (~> 0.1 → ~> 4.0). * New "Versioning & compatibility" section. * New "Response objects" section. * New "Common parameters & defaults" section enumerating file_path / file_url / categories / tags / auto_delete / boost_mode / async / external_id / max_pages_to_process / bounding_boxes / confidence_details with defaults, required-ness and meanings. * New "Configuring the client" + "Custom Faraday configuration". * New "Handling errors" with the full error-class table. * Per-endpoint usage examples for every new resource. - YARD doc blocks on every public method across all 16 API classes (descriptions, @see links, @param / @option with defaults, @return). == Tests == - spec/fixtures/ reorganized into: spec/fixtures/files/ spec/fixtures/responses//.json New response_fixture_body / file_fixture_path helpers remove hardcoded paths from every spec. - New spec files for every new API class, Veryfi::Resource, Veryfi.configure/.client, and error subclasses. - New end-to-end resource_wrapping_integration_spec.rb sweeps every API namespace and asserts that response["id"], response[:id] and response.id all return the same value via the real Request pipeline. == Verification == Ruby | rspec | rubocop | bundle-audit ------- | --------------------------- | ------------------------ | ------------ 2.7.8 | 205 examples, 0 failures | 53 files, no offenses | clean 3.4.1 | 205 examples, 0 failures | 53 files, no offenses | clean 100% line coverage on lib/ (609/609 LOC across 24 files). Co-authored-by: Cursor --- .gitignore | 5 +- .rubocop.yml | 5 +- .yardopts | 10 + Gemfile | 10 + Gemfile.lock | 123 +++-- README.md | 516 +++++++++++++++++- Rakefile | 21 + lib/veryfi.rb | 64 +++ lib/veryfi/api/any_document.rb | 123 +++++ lib/veryfi/api/bank_statement.rb | 114 ++++ lib/veryfi/api/bank_statement_split.rb | 66 +++ lib/veryfi/api/business_card.rb | 84 +++ lib/veryfi/api/check.rb | 127 +++++ lib/veryfi/api/classify.rb | 53 ++ lib/veryfi/api/document.rb | 117 ++++ lib/veryfi/api/document_tag.rb | 43 ++ lib/veryfi/api/file_payload.rb | 23 + lib/veryfi/api/line_item.rb | 55 ++ lib/veryfi/api/pdf_split.rb | 75 +++ lib/veryfi/api/tag.rb | 14 + lib/veryfi/api/tag_operations.rb | 63 +++ lib/veryfi/api/tax_line.rb | 71 +++ lib/veryfi/api/w2.rb | 90 +++ lib/veryfi/api/w2_split.rb | 68 +++ lib/veryfi/api/w8.rb | 90 +++ lib/veryfi/api/w9.rb | 92 ++++ lib/veryfi/client.rb | 78 ++- lib/veryfi/configuration.rb | 28 + lib/veryfi/error.rb | 110 +++- lib/veryfi/request.rb | 33 +- lib/veryfi/resource.rb | 100 ++++ lib/veryfi/version.rb | 2 +- spec/fixtures/{ => files}/receipt.jpg | Bin spec/fixtures/{ => files}/receipt_base64.txt | 0 .../responses/any_documents/list.json | 22 + .../responses/bank_statements/list.json | 24 + .../responses/bank_statements_set/list.json | 19 + .../responses/business_cards/list.json | 20 + spec/fixtures/responses/checks/list.json | 22 + .../responses/checks/with_remittance.json | 17 + spec/fixtures/responses/classify/result.json | 9 + .../{ => responses/documents}/line_items.json | 0 .../documents/list.json} | 0 .../responses/documents/tax_lines.json | 20 + .../responses/documents_set/list.json | 19 + .../{tags.json => responses/tags/list.json} | 0 spec/fixtures/responses/w2s/list.json | 22 + spec/fixtures/responses/w2s_set/list.json | 19 + spec/fixtures/responses/w8s/list.json | 18 + spec/fixtures/responses/w9s/list.json | 20 + spec/spec_helper.rb | 10 +- .../shared_contexts/with_veryfi_client.rb | 21 + .../support/shared_examples/tag_operations.rb | 74 +++ .../shared_examples/wrapped_response.rb | 31 ++ spec/veryfi/any_document_spec.rb | 165 ++++++ spec/veryfi/bank_statement_spec.rb | 162 ++++++ spec/veryfi/bank_statement_split_spec.rb | 74 +++ spec/veryfi/business_card_spec.rb | 127 +++++ spec/veryfi/check_spec.rb | 184 +++++++ spec/veryfi/classify_spec.rb | 51 ++ spec/veryfi/document_spec.rb | 42 +- spec/veryfi/document_tag_spec.rb | 38 +- spec/veryfi/error_spec.rb | 82 +++ spec/veryfi/line_item_spec.rb | 16 +- spec/veryfi/pdf_split_spec.rb | 74 +++ spec/veryfi/request_spec.rb | 5 +- spec/veryfi/resource_spec.rb | 135 +++++ .../resource_wrapping_integration_spec.rb | 98 ++++ spec/veryfi/tag_spec.rb | 2 +- spec/veryfi/tax_line_spec.rb | 80 +++ spec/veryfi/w2_spec.rb | 106 ++++ spec/veryfi/w2_split_spec.rb | 74 +++ spec/veryfi/w8_spec.rb | 106 ++++ spec/veryfi/w9_spec.rb | 106 ++++ spec/veryfi_spec.rb | 74 +++ veryfi.gemspec | 26 +- 76 files changed, 4571 insertions(+), 116 deletions(-) create mode 100644 .yardopts create mode 100644 Rakefile create mode 100644 lib/veryfi/api/any_document.rb create mode 100644 lib/veryfi/api/bank_statement.rb create mode 100644 lib/veryfi/api/bank_statement_split.rb create mode 100644 lib/veryfi/api/business_card.rb create mode 100644 lib/veryfi/api/check.rb create mode 100644 lib/veryfi/api/classify.rb create mode 100644 lib/veryfi/api/file_payload.rb create mode 100644 lib/veryfi/api/pdf_split.rb create mode 100644 lib/veryfi/api/tag_operations.rb create mode 100644 lib/veryfi/api/tax_line.rb create mode 100644 lib/veryfi/api/w2.rb create mode 100644 lib/veryfi/api/w2_split.rb create mode 100644 lib/veryfi/api/w8.rb create mode 100644 lib/veryfi/api/w9.rb create mode 100644 lib/veryfi/configuration.rb create mode 100644 lib/veryfi/resource.rb rename spec/fixtures/{ => files}/receipt.jpg (100%) rename spec/fixtures/{ => files}/receipt_base64.txt (100%) create mode 100644 spec/fixtures/responses/any_documents/list.json create mode 100644 spec/fixtures/responses/bank_statements/list.json create mode 100644 spec/fixtures/responses/bank_statements_set/list.json create mode 100644 spec/fixtures/responses/business_cards/list.json create mode 100644 spec/fixtures/responses/checks/list.json create mode 100644 spec/fixtures/responses/checks/with_remittance.json create mode 100644 spec/fixtures/responses/classify/result.json rename spec/fixtures/{ => responses/documents}/line_items.json (100%) rename spec/fixtures/{documents.json => responses/documents/list.json} (100%) create mode 100644 spec/fixtures/responses/documents/tax_lines.json create mode 100644 spec/fixtures/responses/documents_set/list.json rename spec/fixtures/{tags.json => responses/tags/list.json} (100%) create mode 100644 spec/fixtures/responses/w2s/list.json create mode 100644 spec/fixtures/responses/w2s_set/list.json create mode 100644 spec/fixtures/responses/w8s/list.json create mode 100644 spec/fixtures/responses/w9s/list.json create mode 100644 spec/support/shared_examples/tag_operations.rb create mode 100644 spec/support/shared_examples/wrapped_response.rb create mode 100644 spec/veryfi/any_document_spec.rb create mode 100644 spec/veryfi/bank_statement_spec.rb create mode 100644 spec/veryfi/bank_statement_split_spec.rb create mode 100644 spec/veryfi/business_card_spec.rb create mode 100644 spec/veryfi/check_spec.rb create mode 100644 spec/veryfi/classify_spec.rb create mode 100644 spec/veryfi/error_spec.rb create mode 100644 spec/veryfi/pdf_split_spec.rb create mode 100644 spec/veryfi/resource_spec.rb create mode 100644 spec/veryfi/resource_wrapping_integration_spec.rb create mode 100644 spec/veryfi/tax_line_spec.rb create mode 100644 spec/veryfi/w2_spec.rb create mode 100644 spec/veryfi/w2_split_spec.rb create mode 100644 spec/veryfi/w8_spec.rb create mode 100644 spec/veryfi/w9_spec.rb create mode 100644 spec/veryfi_spec.rb 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..f3f02e6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,9 +1,11 @@ plugins: - rubocop-rspec + - rubocop-rake AllCops: DisplayCopNames: true NewCops: enable + SuggestExtensions: false Exclude: - bin/**/* - tmp/**/* @@ -61,7 +63,8 @@ Lint/RedundantDirGlobSort: Enabled: false Metrics/ParameterLists: - Max: 7 + Max: 8 + MaxOptionalParameters: 4 Metrics/BlockLength: Exclude: 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 b/Gemfile index e44f021..9f1069e 100644 --- a/Gemfile +++ b/Gemfile @@ -6,3 +6,13 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } # Specify your gem's dependencies in veryfi.gemspec gemspec + +# Faraday picks different lines per Ruby version (see gemspec for the +# CVE-driven rationale): the 1.10.x line is the latest Ruby-2.7-compatible +# fixed branch, the 2.x line jumps to >= 2.14.1 once Ruby >= 3.0 is +# available. +if RUBY_VERSION >= "3.0" + gem "faraday", ">= 2.14.1", "< 3.0" +else + gem "faraday", "~> 1.10.5" +end diff --git a/Gemfile.lock b/Gemfile.lock index 570e5ef..9a7bad4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,93 +1,109 @@ PATH remote: . specs: - veryfi (3.0.0) + veryfi (4.0.0) base64 (~> 0.1) - faraday (>= 1.7, < 3.0) + faraday (>= 1.10.5, < 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) + diff-lcs (1.6.2) + docile (1.4.1) + faraday (1.10.5) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) 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) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + 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) method_source (1.1.0) - minitest (5.23.1) - openssl (4.0.0) - parallel (1.27.0) - parser (3.3.10.1) + multipart-post (2.4.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) @@ -98,43 +114,42 @@ GEM 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) + 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) + faraday (~> 1.10.5) pry (~> 0.14) rake (~> 13.0) rexml (~> 3.4.4) 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..973839e 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,64 @@ 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', '~> 0.1' +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. | + +The minimum supported Ruby version is **2.7**. + +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 +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 +319,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..205373c --- /dev/null +++ b/lib/veryfi/resource.rb @@ -0,0 +1,100 @@ +# 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 + + def fetch(key, *args, &block) + super(key.to_s, *args, &block) + end + + 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..14dfe42 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" @@ -68,7 +86,10 @@ Gem::Specification.new do |spec| spec.add_dependency "base64", "~> 0.1" spec.add_dependency "openssl", ">= 2.2", "< 4.1" - spec.add_dependency "faraday", ">= 1.7", "< 3.0" + # Faraday 1.10.5+ and 2.14.1+ are both fixed for CVE-2026-25765. + # Faraday >= 2.9 requires Ruby >= 3.0, so users on Ruby 2.7 should pin + # `~> 1.10.5` explicitly in their Gemfile. + spec.add_dependency "faraday", ">= 1.10.5", "< 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 From 439b7651734df51039fa1c5eb5dd5201b6041023 Mon Sep 17 00:00:00 2001 From: Sebastian Carmona Date: Tue, 26 May 2026 20:02:25 -0500 Subject: [PATCH 2/2] =?UTF-8?q?Adjust=20supported=20Ruby=20matrix=20to=203?= =?UTF-8?q?.0=20=E2=86=92=204.0=20and=20pin=20RuboCop=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the supported Ruby floor to 3.0 (Faraday 2.x's minimum) so the gem can drop the conditional Gemfile pin that previously co-existed with the single CI `Gemfile.lock`, which had been failing CI under Bundler's frozen deployment mode. - veryfi.gemspec: `required_ruby_version = ">= 3.0"`; simplify the Faraday constraint to `>= 2.14.1, < 3.0` (CVE-2026-25765-fixed line). - Gemfile: drop the `RUBY_VERSION` conditional, let the gemspec be the single source of truth. - Gemfile.lock: re-resolved under Ruby 3.x, pinning `faraday (2.14.2)` with its slim 2.x adapter set; verified inside `ruby:3.0.7` Docker. - .github/workflows/test.yml: matrix now `["3.0", "3.1", "3.2", "3.3", "3.4", "4.0"]` — drops EOL Ruby 2.7, keeps the still-popular 3.0/3.1 lines, adds the new Ruby 4.0 stable. - .ruby-version: 2.7.8 → 3.4.1 for local development parity. - .rubocop.yml: `AllCops/TargetRubyVersion: 3.0` so cops produce identical findings regardless of the runtime Ruby (Ruby 4.0's RuboCop otherwise tightens cops that older Rubies cannot satisfy). - lib/veryfi/resource.rb: scope-disable `Style/ArgumentsForwarding` around `fetch` to keep the explicit `*args, &block` form, which is portable across the full 3.0 → 4.0 range and avoids the call-site `super(key.to_s, ...)` edge cases. - README.md: replace the single "minimum Ruby 2.7" line with a proper supported-Ruby-versions table and a pointer for users still on 2.7 (pin the 3.x gem line). Verified locally: - Ruby 3.0.7 (Docker, aarch64-linux): 205 examples, 0 failures, 99.33% line coverage; RuboCop clean; bundle-audit clean. - Ruby 3.4.1 (rbenv, arm64-darwin): 205 / 0, 99.33%; lint + audit clean. - Ruby 4.0.5 (rbenv, arm64-darwin): 205 / 0, 99.33%; lint + audit clean. Co-authored-by: Cursor --- .github/workflows/test.yml | 2 +- .rubocop.yml | 1 + .ruby-version | 2 +- Gemfile | 10 ---------- Gemfile.lock | 38 +++++++++++--------------------------- README.md | 18 +++++++++++++++++- lib/veryfi/resource.rb | 2 ++ veryfi.gemspec | 8 ++++---- 8 files changed, 37 insertions(+), 44 deletions(-) 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/.rubocop.yml b/.rubocop.yml index f3f02e6..34dfda7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,6 +3,7 @@ plugins: - rubocop-rake AllCops: + TargetRubyVersion: 3.0 DisplayCopNames: true NewCops: enable SuggestExtensions: false 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/Gemfile b/Gemfile index 9f1069e..e44f021 100644 --- a/Gemfile +++ b/Gemfile @@ -6,13 +6,3 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } # Specify your gem's dependencies in veryfi.gemspec gemspec - -# Faraday picks different lines per Ruby version (see gemspec for the -# CVE-driven rationale): the 1.10.x line is the latest Ruby-2.7-compatible -# fixed branch, the 2.x line jumps to >= 2.14.1 once Ruby >= 3.0 is -# available. -if RUBY_VERSION >= "3.0" - gem "faraday", ">= 2.14.1", "< 3.0" -else - gem "faraday", "~> 1.10.5" -end diff --git a/Gemfile.lock b/Gemfile.lock index 9a7bad4..97e30d8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,7 +3,7 @@ PATH specs: veryfi (4.0.0) base64 (~> 0.1) - faraday (>= 1.10.5, < 3.0) + faraday (>= 2.14.1, < 3.0) openssl (>= 2.2, < 4.1) GEM @@ -23,36 +23,21 @@ GEM rexml diff-lcs (1.6.2) docile (1.4.1) - faraday (1.10.5) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) - faraday-multipart (1.2.0) - multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.4) + 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) - multipart-post (2.4.1) + net-http (0.9.1) + uri (>= 0.11.1) openssl (4.0.2) parallel (1.28.0) parser (3.3.11.1) @@ -108,7 +93,6 @@ GEM 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) @@ -120,6 +104,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) + uri (1.1.1) vcr (6.4.0) webmock (3.26.2) addressable (>= 2.8.0) @@ -135,7 +120,6 @@ PLATFORMS DEPENDENCIES bundler (~> 2.4) bundler-audit (~> 0.9) - faraday (~> 1.10.5) pry (~> 0.14) rake (~> 13.0) rexml (~> 3.4.4) diff --git a/README.md b/README.md index 973839e..2165aa2 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,23 @@ require 'veryfi' | `3.x` | Maintenance | Documents, line items, tags, document tags only. | | `<=2.x` | Unsupported | Please upgrade. | -The minimum supported Ruby version is **2.7**. +### 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: diff --git a/lib/veryfi/resource.rb b/lib/veryfi/resource.rb index 205373c..47329c5 100644 --- a/lib/veryfi/resource.rb +++ b/lib/veryfi/resource.rb @@ -47,9 +47,11 @@ 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) diff --git a/veryfi.gemspec b/veryfi.gemspec index 14dfe42..6ae19fd 100644 --- a/veryfi.gemspec +++ b/veryfi.gemspec @@ -83,13 +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" - # Faraday 1.10.5+ and 2.14.1+ are both fixed for CVE-2026-25765. - # Faraday >= 2.9 requires Ruby >= 3.0, so users on Ruby 2.7 should pin - # `~> 1.10.5` explicitly in their Gemfile. - spec.add_dependency "faraday", ">= 1.10.5", "< 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"