intacct-rest 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +22 -0
- data/README.md +523 -0
- data/lib/intacct_rest/authenticated_request.rb +75 -0
- data/lib/intacct_rest/client.rb +65 -0
- data/lib/intacct_rest/configuration.rb +169 -0
- data/lib/intacct_rest/custom_field.rb +19 -0
- data/lib/intacct_rest/endpoints/create_bill.rb +28 -0
- data/lib/intacct_rest/endpoints/create_bill_line.rb +31 -0
- data/lib/intacct_rest/endpoints/create_customer.rb +35 -0
- data/lib/intacct_rest/endpoints/create_invoice.rb +35 -0
- data/lib/intacct_rest/endpoints/create_invoice_line.rb +31 -0
- data/lib/intacct_rest/endpoints/create_term.rb +28 -0
- data/lib/intacct_rest/endpoints/create_vendor.rb +35 -0
- data/lib/intacct_rest/endpoints/update_vendor.rb +38 -0
- data/lib/intacct_rest/error_reporting.rb +28 -0
- data/lib/intacct_rest/errors.rb +48 -0
- data/lib/intacct_rest/filter.rb +34 -0
- data/lib/intacct_rest/model/base.rb +48 -0
- data/lib/intacct_rest/model/bill.rb +120 -0
- data/lib/intacct_rest/model/bill_line.rb +103 -0
- data/lib/intacct_rest/model/contact.rb +52 -0
- data/lib/intacct_rest/model/currency.rb +50 -0
- data/lib/intacct_rest/model/customer.rb +116 -0
- data/lib/intacct_rest/model/invoice.rb +121 -0
- data/lib/intacct_rest/model/invoice_line.rb +101 -0
- data/lib/intacct_rest/model/term.rb +88 -0
- data/lib/intacct_rest/model/vendor.rb +163 -0
- data/lib/intacct_rest/oauth_client.rb +85 -0
- data/lib/intacct_rest/objects.rb +30 -0
- data/lib/intacct_rest/page.rb +5 -0
- data/lib/intacct_rest/patch.rb +46 -0
- data/lib/intacct_rest/post.rb +43 -0
- data/lib/intacct_rest/query.rb +88 -0
- data/lib/intacct_rest/result/base.rb +17 -0
- data/lib/intacct_rest/result/error.rb +14 -0
- data/lib/intacct_rest/result/success.rb +27 -0
- data/lib/intacct_rest/schema_generator.rb +69 -0
- data/lib/intacct_rest/schema_source.rb +49 -0
- data/lib/intacct_rest/token_store/memory.rb +36 -0
- data/lib/intacct_rest/validators/custom.rb +17 -0
- data/lib/intacct_rest/validators/inclusion.rb +16 -0
- data/lib/intacct_rest/validators/kind_of.rb +24 -0
- data/lib/intacct_rest/validators/presence.rb +13 -0
- data/lib/intacct_rest/validators.rb +33 -0
- data/lib/intacct_rest/version.rb +3 -0
- data/lib/intacct_rest.rb +75 -0
- metadata +152 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: aa26a0193bf0a743c64e8b7a0872a17922be601d53bb0a8f809aa09d33bee382
|
|
4
|
+
data.tar.gz: 1d27d3c95af15ddf66d879fb8c08d86a7b0f67c223034606dff1df8cf1e32624
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f8d45d6db98887b8af8974e601fff28d4c8534d8d52147b41d8336c2f2da4865864fde4c146a978d20dac67f32ebda91eb4e459ccbf83d5a6077cc5ff62954f8
|
|
7
|
+
data.tar.gz: d2c3b257318be66c710a2c008ccb4b65d2e719e3ee9e35701511ac0cfd3358a6c42ab98214647490426e28f295b40678abba1a7b8c28340ae40eb75933edae4b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2026-10-01
|
|
11
|
+
|
|
12
|
+
First public release.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- OAuth2 token handling (`client_credentials` and `refresh_token` grants) with pluggable token storage; `IntacctRest::TokenStore::Memory` is the default.
|
|
17
|
+
- `IntacctRest::Query` for `POST /services/core/query`, with `IntacctRest::Filter` operators (`eq`, `not_eq`, `gt`, `gte`, `lt`, `lte`, `and`) and pagination via `#each_page` (raises `TooManyPagesError` past `max_pages`).
|
|
18
|
+
- `IntacctRest::Objects#list`/`#find` for reading records from the objects API.
|
|
19
|
+
- `IntacctRest::SchemaGenerator` and `IntacctRest::SchemaSource` for discovering and loading per-resource field lists.
|
|
20
|
+
- Create endpoints: `Endpoints::CreateVendor`, `CreateCustomer`, `CreateInvoice`, `CreateInvoiceLine`, `CreateTerm`, `CreateBill`, `CreateBillLine`, built on the generic `IntacctRest::Post`.
|
|
21
|
+
- Vendor update (`PATCH`) via `Endpoints::UpdateVendor` and the generic `IntacctRest::Patch`; only the fields set are sent.
|
|
22
|
+
- Endpoints check the `results:` fields they expect on a successful response and raise `ApiError` when one is missing.
|
|
23
|
+
- Custom fields via `IntacctRest::CustomField`, serialized as `"namespace::name"` (default namespace `nsp`).
|
|
24
|
+
- `Model::Currency` and `Model::Contact` helpers for building nested payloads.
|
|
25
|
+
- A validation DSL (`Model::Base.validate`) with `:presence`, `:kind_of`, `:inclusion` and `:custom` validators and `on: :create`/`on: :update` contexts.
|
|
26
|
+
- `IntacctRest::Result::Success`/`Result::Error`: endpoints return a Result and never raise for the HTTP outcome.
|
|
27
|
+
- An error hierarchy under `IntacctRest::Error`: `AuthenticationError`, `ApiError`, `ResponseParseError`, `ValidationError`, `TooManyPagesError`, `SchemaGenerationError`, `SchemaLoadError`.
|
|
28
|
+
- Optional `on_error` hook on `Configuration`, called with `(error, context:)` right before the gem raises an `ApiError` from an Intacct request (non-2xx response or `ia::error` payload), an `AuthenticationError`, a `ResponseParseError`, or a `SchemaLoadError`; exceptions raised by the hook are ignored.
|
|
29
|
+
- MIT license, CONTRIBUTING.md, and GitHub issue/pull request templates.
|
|
30
|
+
|
|
31
|
+
[Unreleased]: https://github.com/bernespinoza/intacct-rest/compare/v1.0.0...HEAD
|
|
32
|
+
[1.0.0]: https://github.com/bernespinoza/intacct-rest/releases/tag/v1.0.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2026 Bernardo Espinoza
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# IntacctRest
|
|
2
|
+
|
|
3
|
+
A small, framework-agnostic Ruby client for [Sage Intacct's REST API v1](https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api). It wraps:
|
|
4
|
+
|
|
5
|
+
- **OAuth2 token handling** (`client_credentials` and `refresh_token` grants), with pluggable token storage
|
|
6
|
+
- **The `POST /services/core/query` endpoint**, for any Intacct object (invoices, bills, customers, ...), with pagination and a small filter-operator builder
|
|
7
|
+
- **The `POST /objects/accounts-payable/vendor` endpoint**, via `IntacctRest::Endpoints::CreateVendor` (the use case), `IntacctRest::Post` (the generic, reusable "send this model" operation), and `IntacctRest::Model::Vendor` (the data + a small declarative validation DSL), covering every vendor field plus custom fields
|
|
8
|
+
- **The `PATCH /objects/accounts-payable/vendor/{key}` endpoint**, via `IntacctRest::Endpoints::UpdateVendor` and `IntacctRest::Patch` (the generic "update this model" operation), sending only the fields you set
|
|
9
|
+
- **The `POST /objects/accounts-receivable/invoice` endpoint**, via `IntacctRest::Endpoints::CreateInvoice` and `IntacctRest::Model::Invoice`, reusing the same `Post`/validation pattern
|
|
10
|
+
- **The `POST /objects/accounts-receivable/term` endpoint**, via `IntacctRest::Endpoints::CreateTerm` and `IntacctRest::Model::Term`
|
|
11
|
+
- **The `POST /objects/accounts-receivable/invoice-line` endpoint**, via `IntacctRest::Endpoints::CreateInvoiceLine` and `IntacctRest::Model::InvoiceLine`
|
|
12
|
+
- **`IntacctRest::Model::Currency`**, a data + validation object for the currency shape shared by invoices and invoice lines (no dedicated create endpoint — Intacct manages currencies elsewhere; this is just a typed helper for building the payload)
|
|
13
|
+
- **The `POST /objects/accounts-receivable/customer` endpoint**, via `IntacctRest::Endpoints::CreateCustomer` and `IntacctRest::Model::Customer`, reusing the same `Post`/validation pattern
|
|
14
|
+
- **`IntacctRest::Model::Contact`**, a data + validation object for the non-deprecated subset of the contact shape shared by vendors and customers (no dedicated create endpoint — contacts are referenced by id from `contacts`/`contact_list`; this is just a typed helper for building that nested payload)
|
|
15
|
+
- **The `POST /objects/accounts-payable/bill` endpoint**, via `IntacctRest::Endpoints::CreateBill` and `IntacctRest::Model::Bill`, reusing the same `Post`/validation pattern
|
|
16
|
+
- **The `POST /objects/accounts-payable/bill-line` endpoint**, via `IntacctRest::Endpoints::CreateBillLine` and `IntacctRest::Model::BillLine`
|
|
17
|
+
|
|
18
|
+
It has no Rails, ActiveRecord, or Redis dependency — the host application supplies its own token store and error-handling hook.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "intacct-rest", github: "bernespinoza/intacct-rest", tag: "v1.0.0"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Publishing to RubyGems is planned; until then, install from GitHub.
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
IntacctRest.configure do |config|
|
|
32
|
+
config.client_id = ENV.fetch("INTACCT_OAUTH_CLIENT_ID")
|
|
33
|
+
config.client_secret = ENV.fetch("INTACCT_OAUTH_CLIENT_SECRET")
|
|
34
|
+
config.username = ENV.fetch("INTACCT_OAUTH_USERNAME")
|
|
35
|
+
|
|
36
|
+
# Optional — all of these have sane defaults (see IntacctRest::Configuration):
|
|
37
|
+
# config.base_url = "https://api.intacct.com/ia/api/v1"
|
|
38
|
+
# config.token_path = "/oauth2/token"
|
|
39
|
+
# config.query_path = "/services/core/query"
|
|
40
|
+
# config.page_size = 200
|
|
41
|
+
# config.max_pages = 50
|
|
42
|
+
# config.token_key_prefix = "intacct_rest:oauth"
|
|
43
|
+
|
|
44
|
+
# Token storage defaults to an in-memory store (IntacctRest::TokenStore::Memory).
|
|
45
|
+
# Swap in your own — anything responding to #read(key), #write(key, value, ttl:), #delete(key):
|
|
46
|
+
config.token_store = MyApp::RedisTokenStore.new(Redis.new(url: ENV.fetch("REDIS_URL")))
|
|
47
|
+
|
|
48
|
+
# Optional. Called with (error, context:) right before the gem raises an ApiError from
|
|
49
|
+
# an Intacct request (non-2xx response or ia::error payload), an AuthenticationError,
|
|
50
|
+
# a ResponseParseError, or a SchemaLoadError — wire this to your own logging/
|
|
51
|
+
# instrumentation. context: holds only operation (:api_request, :token_request,
|
|
52
|
+
# :schema_load), path, and http_status / grant_type when known — never credentials,
|
|
53
|
+
# tokens or bodies. Not called for errors the gem recovers from (a failed refresh
|
|
54
|
+
# falling back to client_credentials), errors raised before any request
|
|
55
|
+
# (ValidationError, ArgumentError), the results: check on endpoints, or non-2xx
|
|
56
|
+
# Results from Post/Patch. Exceptions raised by your hook are ignored, and the
|
|
57
|
+
# original error is raised unchanged. The gem never logs on its own.
|
|
58
|
+
config.on_error = ->(error, context:) { MyApp::Logger.warn(error.message, context) }
|
|
59
|
+
|
|
60
|
+
# A Hash or a YAML file path — see "Schema" below. Defaults to nil (empty).
|
|
61
|
+
config.schema = "config/intacct_schema.yml"
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Writing a custom token store
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class RedisTokenStore
|
|
69
|
+
def initialize(redis)
|
|
70
|
+
@redis = redis
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def read(key)
|
|
74
|
+
@redis.get(key)
|
|
75
|
+
rescue Redis::BaseError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def write(key, value, ttl: nil)
|
|
80
|
+
ttl ? @redis.set(key, value, ex: ttl) : @redis.set(key, value)
|
|
81
|
+
rescue Redis::BaseError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def delete(key)
|
|
86
|
+
@redis.del(key)
|
|
87
|
+
rescue Redis::BaseError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Querying
|
|
94
|
+
|
|
95
|
+
### A single page
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
query = IntacctRest::Query.new(
|
|
99
|
+
resource: "accounts-receivable/invoice",
|
|
100
|
+
schema: %w[key invoiceNumber state],
|
|
101
|
+
filters: [
|
|
102
|
+
IntacctRest::Filter.eq("state", "paid"),
|
|
103
|
+
IntacctRest::Filter.gt("audit.modifiedDateTime", "2026-01-01")
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
page = query.call
|
|
108
|
+
page.items # => Array<Hash>
|
|
109
|
+
page.next_cursor # => String or nil
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Paginating
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
IntacctRest::Query.new(resource: "accounts-receivable/invoice", schema: %w[key state])
|
|
116
|
+
.each_page(max_pages: 50) do |items, next_cursor|
|
|
117
|
+
items.each { |invoice| ... }
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`each_page` raises `IntacctRest::TooManyPagesError` if `max_pages` is exceeded, rather than silently truncating results.
|
|
122
|
+
|
|
123
|
+
## Objects API
|
|
124
|
+
|
|
125
|
+
`IntacctRest::Objects` reads records straight from the objects API:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
objects = IntacctRest::Objects.new
|
|
129
|
+
objects.list("accounts-receivable/invoice") # => Array<Hash> (first page, no pagination)
|
|
130
|
+
objects.find("accounts-receivable/invoice", "42") # => Hash (single record)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Schema
|
|
134
|
+
|
|
135
|
+
`IntacctRest::SchemaGenerator` discovers a resource's fields by sampling **one real record**
|
|
136
|
+
(list → pick one key → fetch it → flatten its keys, nested Hashes dot-joined, e.g.
|
|
137
|
+
`paymentInformation.fullyPaidDate`). This is a **lower bound, not a guarantee of
|
|
138
|
+
completeness** — any field that's nil/empty on the sampled record won't appear (a nil
|
|
139
|
+
`paymentInformation` collapses to one bare `paymentInformation` leaf instead of its nested
|
|
140
|
+
fields), and there's no confirmed sort order on the list endpoint, so the auto-picked record
|
|
141
|
+
is an arbitrary sample, not "the latest." Treat its output as a starting point to hand-review
|
|
142
|
+
and merge, not as ground truth to commit blindly. Pass `key:` to override the auto-picked
|
|
143
|
+
sample with a known-good record.
|
|
144
|
+
|
|
145
|
+
The gem itself has no opinion on what resources you care about — you supply the mapping:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
generator = IntacctRest::SchemaGenerator.new
|
|
149
|
+
resources = { invoices: "accounts-receivable/invoice", payments: "accounts-receivable/payment" }
|
|
150
|
+
|
|
151
|
+
generator.generate("accounts-receivable/invoice") # => Array<String>
|
|
152
|
+
generator.generate_all(resources) # => {invoices: [...], payments: [...]}
|
|
153
|
+
generator.write("config/intacct_schema.yml", resources) # dumps YAML, overwrites unconditionally
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Written YAML looks like:
|
|
157
|
+
|
|
158
|
+
```yaml
|
|
159
|
+
invoices:
|
|
160
|
+
- key
|
|
161
|
+
- state
|
|
162
|
+
- paymentInformation.fullyPaidDate
|
|
163
|
+
payments:
|
|
164
|
+
- key
|
|
165
|
+
- amount
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`IntacctRest::SchemaSource` resolves `Configuration#schema` (a Hash, a YAML path, or nil)
|
|
169
|
+
into field arrays for use as `Query#schema:`. Not auto-wired into `Query` — pass it explicitly
|
|
170
|
+
so `Query` stays resource-agnostic:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
fields = IntacctRest::SchemaSource.new(IntacctRest.configuration.schema).for(:invoices)
|
|
174
|
+
IntacctRest::Query.new(resource: "accounts-receivable/invoice", schema: fields, ...)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Creating a vendor
|
|
178
|
+
|
|
179
|
+
Three pieces work together, each with one job:
|
|
180
|
+
|
|
181
|
+
- **`IntacctRest::Model::Vendor`** — the vendor's data and validations. No config, no token provider, no HTTP knowledge. Build one, inspect it, `valid?`/`errors` it, all without touching the network.
|
|
182
|
+
- **`IntacctRest::Post`** — a generic "send this model" operation. Works against *any* model that responds to `#intacct_object` (the endpoint path), `#payload` (the outgoing JSON), `#errors(:create)`, and `#apply_result` — not specific to vendors.
|
|
183
|
+
- **`IntacctRest::Endpoints::CreateVendor`** — the vendor-specific use case: calls `Post`, then checks the response actually contains the fields you expect.
|
|
184
|
+
|
|
185
|
+
`IntacctRest::Model::Vendor` exposes every field from Sage Intacct's vendor object as a Ruby-idiomatic snake_case accessor (`is_one_time_use`, `default_lead_time`, `vendor_account_number`, ...), mapped to Intacct's exact camelCase JSON key when `#payload` builds the request — see `IntacctRest::Configuration::DEFAULT_VENDOR_WRITABLE_ATTRIBUTES` for the full name mapping. Nested objects (`bank_files`, `contacts`, `term`, `bill_payment`, ...) are **not** individually modeled — pass them as raw Hashes using Intacct's native (camelCase) nested key names, as shown for `term` below.
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
vendor = IntacctRest::Model::Vendor.new(
|
|
189
|
+
id: "V-00014",
|
|
190
|
+
name: "NCS, Inc.",
|
|
191
|
+
credit_limit: 40_000,
|
|
192
|
+
is_on_hold: false,
|
|
193
|
+
term: { "id" => "Net 30" }
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
vendor.key # => nil (not created yet)
|
|
197
|
+
|
|
198
|
+
result = IntacctRest::Endpoints::CreateVendor.call(vendor: vendor, results: %i[id key href])
|
|
199
|
+
|
|
200
|
+
result.success? # => true
|
|
201
|
+
vendor.key # => "111" (written onto the model by Post, via model.apply_result)
|
|
202
|
+
vendor.href # => "/objects/accounts-payable/vendor/111"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
You can also build the outgoing JSON payload without sending anything, e.g. for debugging:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
vendor.payload.to_json # => {"id":"V-00014","name":"NCS, Inc.","isOnHold":false,"creditLimit":40000,"term":{"id":"Net 30"}}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Building a model from an existing object
|
|
212
|
+
|
|
213
|
+
`Model::Vendor.new` also accepts a single positional "source" object — an ActiveRecord record, an `OpenStruct`, another `Model::Vendor` — and pulls any matching attribute off it via duck-typing (`source.respond_to?(attr)`). Explicit keyword attributes always win over whatever the source provided:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
vendor = IntacctRest::Model::Vendor.new(some_ar_vendor, credit_limit: 1_000) # override just one field
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### The Result
|
|
220
|
+
|
|
221
|
+
`IntacctRest::Post` and `IntacctRest::Patch` (and therefore every endpoint) never raise for the HTTP outcome itself — they always return an `IntacctRest::Result` (`Result::Success` or `Result::Error`), each responding to `#code`, `#body`, `#response` (alias for `#body`), `#headers`, `#model`, and `#success?`/`#failed?`:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
result = IntacctRest::Endpoints::CreateVendor.call(vendor: vendor)
|
|
225
|
+
|
|
226
|
+
if result.success?
|
|
227
|
+
result.key # => "111" — dynamic access into the response's ia::result
|
|
228
|
+
result.href
|
|
229
|
+
else
|
|
230
|
+
result.error # => the parsed ia::error payload
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
What still raises, before any request is sent or when something is actually broken (not just "Intacct rejected this vendor"):
|
|
235
|
+
|
|
236
|
+
- `ArgumentError` — an endpoint got the wrong kind of model (e.g. `CreateVendor`/`UpdateVendor` with a `vendor:` that isn't a `Model::Vendor`)
|
|
237
|
+
- `IntacctRest::ValidationError` — the model is invalid (see Validation, below)
|
|
238
|
+
- `IntacctRest::AuthenticationError` — a request 401'd even after a token refresh
|
|
239
|
+
- `IntacctRest::ResponseParseError` — the response body wasn't valid JSON
|
|
240
|
+
- `IntacctRest::ApiError` (from any endpoint that takes `results:` — `CreateVendor`, `UpdateVendor`, `CreateInvoice`, ...) — a *successful* response was missing one of the fields declared in `results:`
|
|
241
|
+
|
|
242
|
+
### Validation
|
|
243
|
+
|
|
244
|
+
`IntacctRest::Model::Vendor` declares its validations with a small DSL (`IntacctRest::Model::Base`, shared by any future model) — each declaration is `validate :kind, *options, [:attr, ...], on: context`:
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
validate :presence, %i[id name], on: :create
|
|
248
|
+
validate :presence, %i[key], on: :update
|
|
249
|
+
validate :kind_of, :string, %i[key], on: :update
|
|
250
|
+
|
|
251
|
+
validate :kind_of, :string, %i[id name status state ...]
|
|
252
|
+
validate :inclusion, BANK_FILE_COUNTRY_CODES, %i[bank_files_payment_country_code]
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`on:` scopes a validation to one operation: it only runs when `#errors`/`#valid?` is called with that context. A validation without `on:` always runs. `Post` validates with `:create` and `Patch` with `:update`, so a vendor needs `id`/`name` to be created and `key` to be updated (see Updating a vendor, below).
|
|
256
|
+
|
|
257
|
+
Four validator kinds ship in `IntacctRest::Validators`:
|
|
258
|
+
|
|
259
|
+
- `:presence` — the attribute must not be `nil`
|
|
260
|
+
- `:kind_of` — the attribute, if present, must be one of a small set of types (`:string`, `:integer`, `:numeric`, `:float`, `:boolean`, `:hash`, `:array`, `:date`, `:time`)
|
|
261
|
+
- `:inclusion` — the attribute, if present, must be included in a given list (e.g. Intacct's documented bank-file country codes)
|
|
262
|
+
- `:custom` — the attribute, if present, is passed to an instance method you name (`record.send(method_name, value)`); a falsy return means invalid
|
|
263
|
+
|
|
264
|
+
By default, `Post` validates the model first, raising `IntacctRest::ValidationError` and skipping the HTTP request entirely if it's invalid — currently that means `id`/`name` presence, every field's documented type, and `bank_files["paymentCountryCode"]` (if set) matching a real country code. `Patch` does the same with `key` in place of `id`/`name`. Note Intacct can auto-generate `id` when document sequencing is enabled for your company; override `errors` in a `Model::Vendor` subclass if you need to relax that.
|
|
265
|
+
|
|
266
|
+
### Custom fields
|
|
267
|
+
|
|
268
|
+
`custom_fields` is a collection of `IntacctRest::CustomField` (`namespace`/`name`/`value`), serialized as `"#{namespace}::#{name}"`. The namespace defaults to `"nsp"`:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
IntacctRest::Model::Vendor.new(
|
|
272
|
+
id: "V-00014",
|
|
273
|
+
name: "NCS, Inc.",
|
|
274
|
+
custom_fields: [IntacctRest::CustomField.new(name: "preferredCourier", value: "UPS")]
|
|
275
|
+
)
|
|
276
|
+
# payload includes: "nsp::preferredCourier" => "UPS"
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
A flat Hash also works as shorthand — each entry becomes a `CustomField` with the default `"nsp"` namespace:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
IntacctRest::Model::Vendor.new(id: "V-00014", name: "NCS, Inc.", custom_fields: { "preferredCourier" => "UPS" })
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Subclassing for extra validation
|
|
286
|
+
|
|
287
|
+
Subclass `IntacctRest::Model::Vendor` and add your own `validate` declarations — they accumulate on top of the built-in ones:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
class StrictVendor < IntacctRest::Model::Vendor
|
|
291
|
+
validate :presence, %i[tax_id]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
IntacctRest::Endpoints::CreateVendor.call(vendor: StrictVendor.new(id: "V-00014", name: "NCS, Inc."))
|
|
295
|
+
# => raises IntacctRest::ValidationError ("tax_id is required"), no request sent
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Updating a vendor
|
|
299
|
+
|
|
300
|
+
`IntacctRest::Endpoints::UpdateVendor` sends `PATCH /objects/accounts-payable/vendor/{key}`. Set `key` to pick the record, plus only the fields you want to change:
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
vendor = IntacctRest::Model::Vendor.new(key: "111", credit_limit: 10_000)
|
|
304
|
+
|
|
305
|
+
vendor.update_payload.to_json # => {"creditLimit":10000}
|
|
306
|
+
|
|
307
|
+
result = IntacctRest::Endpoints::UpdateVendor.call(vendor: vendor, results: %i[id key href])
|
|
308
|
+
|
|
309
|
+
result.success? # => true
|
|
310
|
+
vendor.id # => "V-00014" (filled in from the response by Patch, via model.apply_result)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
- `key` is required on update and must be a String. `id` is read-only on update (`Model::Vendor::UPDATE_READONLY_ATTRIBUTES`), so it's left out of the request body even when set.
|
|
314
|
+
- Only the fields you set are sent (`#update_payload`); anything left `nil` stays unchanged in Intacct. That also means a PATCH can't clear a field to null — `nil` is never sent — while `false` and `""` are sent as is (and `""` can blank a field in Intacct).
|
|
315
|
+
- `results:` works the same as on create: it defaults to `%i[id key href]`, and a successful response missing one of them raises `IntacctRest::ApiError`. A failed Result skips the check.
|
|
316
|
+
- On success, `apply_result` writes the response back onto the model: `key` and `href` whenever the response has them, and `id` only when it isn't already set.
|
|
317
|
+
|
|
318
|
+
### IntacctRest::Patch
|
|
319
|
+
|
|
320
|
+
`IntacctRest::Patch` is the generic "update this model" operation behind `UpdateVendor`, the PATCH counterpart of `Post`. It works against any model that responds to:
|
|
321
|
+
|
|
322
|
+
- `#intacct_object` — the collection path; `Patch` appends `/#{model.key}`
|
|
323
|
+
- `#key` — the record to update
|
|
324
|
+
- `#update_payload` — the outgoing JSON, only the fields to change
|
|
325
|
+
- `#errors(:update)` — validated first; any error raises `IntacctRest::ValidationError` and no request is sent
|
|
326
|
+
- `#apply_result` — called with the Result on success
|
|
327
|
+
|
|
328
|
+
Like `Post`, it never raises for the HTTP outcome and always returns an `IntacctRest::Result` (see The Result, above).
|
|
329
|
+
|
|
330
|
+
## Creating an invoice
|
|
331
|
+
|
|
332
|
+
Same three pieces as vendors — `IntacctRest::Model::Invoice` (data + validation), `IntacctRest::Post` (generic send), `IntacctRest::Endpoints::CreateInvoice` (the invoice-specific use case):
|
|
333
|
+
|
|
334
|
+
```ruby
|
|
335
|
+
invoice = IntacctRest::Model::Invoice.new(
|
|
336
|
+
invoice_date: "2022-12-06",
|
|
337
|
+
due_date: "2022-12-31",
|
|
338
|
+
customer: { "id" => "C-00019" },
|
|
339
|
+
lines: [
|
|
340
|
+
{ "txnAmount" => "100.40", "glAccount" => { "id" => "5004" } }
|
|
341
|
+
]
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
result = IntacctRest::Endpoints::CreateInvoice.call(invoice: invoice, results: %i[id key href])
|
|
345
|
+
|
|
346
|
+
result.success? # => true
|
|
347
|
+
invoice.key # => "2091"
|
|
348
|
+
invoice.href # => "/objects/accounts-receivable/invoice/2091"
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`customer` is how an invoice references an existing customer record — like every other nested object in this gem, it's a raw Hash (`{"id" => "..."}`) using Intacct's native key names, not a `Model::Customer` instance; there's no code-level dependency between `Model::Invoice` and `Model::Customer`. Same for `lines` — an Array of raw line-item Hashes (see the [Invoice API docs](https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api) for the full line-item shape). Only `invoice_date` and `due_date` are validated as required at this layer; nested required fields (`customer.id`, `lines[].txnAmount`, ...) are left to Intacct's own validation — the same nested-fields boundary as vendors' `bank_files`/`term`/etc.
|
|
352
|
+
|
|
353
|
+
`term` (payment terms) and `currency` on `Model::Invoice` follow the same rule — they stay plain Hashes on the invoice itself, so nothing about `Model::Invoice` changes. `Model::Term`, `Model::InvoiceLine`, and `Model::Currency` below are separate, optional objects for the cases where you want validation and a typed `#payload` for those pieces individually (e.g. creating a new term, or building an invoice line before nesting its `#payload` into `invoice.lines`) — you're never required to use them.
|
|
354
|
+
|
|
355
|
+
## Creating a term
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
term = IntacctRest::Model::Term.new(
|
|
359
|
+
id: "2-10 Net 30",
|
|
360
|
+
description: "N30 with discount",
|
|
361
|
+
due: { "days" => 30, "from" => "fromInvoiceDate" }
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
result = IntacctRest::Endpoints::CreateTerm.call(term: term, results: %i[id key href])
|
|
365
|
+
|
|
366
|
+
result.success? # => true
|
|
367
|
+
term.key # => "18"
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
`id` and `description` are required; `due`, `discount`, and `penalty` are raw Hashes, same nested-fields boundary as everywhere else in this gem.
|
|
371
|
+
|
|
372
|
+
## Creating an invoice line
|
|
373
|
+
|
|
374
|
+
`IntacctRest::Model::InvoiceLine` wraps `POST /objects/accounts-receivable/invoice-line` directly, for adding a line to an existing invoice (referenced by `invoice:`):
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
line = IntacctRest::Model::InvoiceLine.new(
|
|
378
|
+
invoice: { "key" => "350" },
|
|
379
|
+
txn_amount: "70.00",
|
|
380
|
+
gl_account: { "id" => "4000" }
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
result = IntacctRest::Endpoints::CreateInvoiceLine.call(invoice_line: line, results: %i[id key href])
|
|
384
|
+
|
|
385
|
+
result.success? # => true
|
|
386
|
+
line.key # => "806"
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`invoice`, `txn_amount`, and `gl_account` are required. `currency` is read-only on a line (Intacct derives it from the invoice), so it isn't a writable attribute here even though it is on `Model::Invoice`'s header.
|
|
390
|
+
|
|
391
|
+
To instead build a line as part of a new invoice's `lines:` array, use `line.payload` (or just a raw Hash — both work, since `Model::Invoice#lines` stays a plain Array):
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
IntacctRest::Model::Invoice.new(
|
|
395
|
+
invoice_date: "2022-12-06",
|
|
396
|
+
due_date: "2022-12-31",
|
|
397
|
+
customer: { "id" => "C-00019" },
|
|
398
|
+
lines: [line.payload]
|
|
399
|
+
)
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
## Currency
|
|
403
|
+
|
|
404
|
+
`IntacctRest::Model::Currency` has no create endpoint of its own — it's a small typed helper for the currency shape that appears on invoices and other objects:
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
currency = IntacctRest::Model::Currency.new(
|
|
408
|
+
txn_currency: "USD",
|
|
409
|
+
exchange_rate: { "date" => "2022-12-06", "typeId" => "Intacct Daily Rate", "rate" => 0.05112 }
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
currency.payload # => {"txnCurrency"=>"USD", "exchangeRate"=>{"date"=>"2022-12-06", "typeId"=>"Intacct Daily Rate", "rate"=>0.05112}}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Assign `currency.payload` (or a raw Hash) to `Model::Invoice#currency` the same way as `term`/`customer`.
|
|
416
|
+
|
|
417
|
+
## Creating a customer
|
|
418
|
+
|
|
419
|
+
Same three pieces as vendors — `IntacctRest::Model::Customer` (data + validation), `IntacctRest::Post` (generic send), `IntacctRest::Endpoints::CreateCustomer` (the customer-specific use case):
|
|
420
|
+
|
|
421
|
+
```ruby
|
|
422
|
+
customer = IntacctRest::Model::Customer.new(
|
|
423
|
+
id: "CUST-002",
|
|
424
|
+
name: "Starluck",
|
|
425
|
+
tax_id: "12-3456789",
|
|
426
|
+
credit_limit: 50_000,
|
|
427
|
+
status: "active"
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
result = IntacctRest::Endpoints::CreateCustomer.call(customer: customer, results: %i[id key href])
|
|
431
|
+
|
|
432
|
+
result.success? # => true
|
|
433
|
+
customer.key # => "32"
|
|
434
|
+
customer.href # => "/objects/accounts-receivable/customer/32"
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
`vendor` is how a customer references an associated vendor record — like every other nested object in this gem, it's a raw Hash (`{"id" => "..."}`), not a `Model::Vendor` instance; there's no code-level dependency between `Model::Customer` and `Model::Vendor`. Only `name` is required at this layer, matching the schema's own `required: [name]`.
|
|
438
|
+
|
|
439
|
+
`contacts`/`contact_list` follow the same rule — they stay plain Hashes/Arrays on `Model::Customer` itself, so nothing about `Model::Customer` changes. `Model::Contact` below is a separate, optional object for the case where you want validation and a typed `#payload` for a contact individually — you're never required to use it.
|
|
440
|
+
|
|
441
|
+
## Contact
|
|
442
|
+
|
|
443
|
+
`IntacctRest::Model::Contact` has no create endpoint of its own — Intacct references an existing contact by `id` from a vendor's or customer's `contacts`/`contact_list` fields rather than creating one inline. It only exposes the *non-deprecated* subset of the "contacts.default" nested shape (`id`, `showInContactList`, `tax`, `electronicInvoiceDetails`, `internationalTaxId`, `electronicAddress`) — every richer field (`firstName`, `email1`, `phone1`, `mailingAddress`, ...) is individually marked `deprecated: true` in Intacct's schema:
|
|
444
|
+
|
|
445
|
+
```ruby
|
|
446
|
+
contact = IntacctRest::Model::Contact.new(id: "C-001", show_in_contact_list: true)
|
|
447
|
+
|
|
448
|
+
contact.payload # => {"id"=>"C-001", "showInContactList"=>true}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Assign `contact.payload` (or a raw Hash) into `Model::Customer#contacts`/`#contact_list` the same way as `vendor`/`term`.
|
|
452
|
+
|
|
453
|
+
## Creating a bill
|
|
454
|
+
|
|
455
|
+
Same three pieces as invoices — `IntacctRest::Model::Bill` (data + validation), `IntacctRest::Post` (generic send), `IntacctRest::Endpoints::CreateBill` (the bill-specific use case):
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
bill = IntacctRest::Model::Bill.new(
|
|
459
|
+
due_date: "2024-03-08",
|
|
460
|
+
created_date: "2024-02-21",
|
|
461
|
+
vendor: { "id" => "1099 Int" },
|
|
462
|
+
currency: { "baseCurrency" => "USD", "txnCurrency" => "USD" },
|
|
463
|
+
lines: [
|
|
464
|
+
{ "txnAmount" => "5", "glAccount" => { "id" => "6000" } }
|
|
465
|
+
]
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
result = IntacctRest::Endpoints::CreateBill.call(bill: bill, results: %i[id key href])
|
|
469
|
+
|
|
470
|
+
result.success? # => true
|
|
471
|
+
bill.key # => "299"
|
|
472
|
+
bill.href # => "/objects/accounts-payable/bill/299"
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
`vendor` is how a bill references an existing vendor record — like every other nested object in this gem, it's a raw Hash (`{"id" => "..."}`), not a `Model::Vendor` instance. Same for `lines`, `term`, and `currency`. Only `due_date` and `created_date` are validated as required at this layer, matching the schema's own `required: [dueDate, createdDate]`; nested required fields (`vendor.id`, `currency.txnCurrency`, `lines[].txnAmount`/`glAccount`, `lines[].dimensions.location`) are left to Intacct's own validation — the same nested-fields boundary as invoices' `customer`/`lines`.
|
|
476
|
+
|
|
477
|
+
## Creating a bill line
|
|
478
|
+
|
|
479
|
+
`IntacctRest::Model::BillLine` wraps `POST /objects/accounts-payable/bill-line` directly, for adding a line to an existing bill (referenced by `bill:`):
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
line = IntacctRest::Model::BillLine.new(
|
|
483
|
+
bill: { "key" => "19876" },
|
|
484
|
+
txn_amount: "5",
|
|
485
|
+
gl_account: { "id" => "6000" }
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
result = IntacctRest::Endpoints::CreateBillLine.call(bill_line: line, results: %i[id key href])
|
|
489
|
+
|
|
490
|
+
result.success? # => true
|
|
491
|
+
line.key # => "1955"
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
`bill`, `txn_amount`, and `gl_account` are required. To instead build a line as part of a new bill's `lines:` array, use `line.payload` (or just a raw Hash — both work, since `Model::Bill#lines` stays a plain Array), the same way as `Model::InvoiceLine`.
|
|
495
|
+
|
|
496
|
+
## Errors
|
|
497
|
+
|
|
498
|
+
All exceptions inherit from `IntacctRest::Error`:
|
|
499
|
+
|
|
500
|
+
- `IntacctRest::AuthenticationError` — token request failed, or a request 401'd even after a token refresh
|
|
501
|
+
- `IntacctRest::ApiError` — `Query`: non-2xx response or an `ia::error` payload (`#http_status`, `#body`). `Endpoints::CreateVendor`/`CreateInvoice`/`CreateTerm`/`CreateInvoiceLine`/`CreateCustomer`/`CreateBill`/`CreateBillLine`/`UpdateVendor`: a successful response was missing a field declared in `results:`
|
|
502
|
+
- `IntacctRest::ResponseParseError` — response body wasn't valid JSON (`#raw_body`)
|
|
503
|
+
- `IntacctRest::ValidationError` — a model failed validation before any request was sent (`#attributes`)
|
|
504
|
+
- `IntacctRest::TooManyPagesError` — `each_page` exceeded `max_pages` (`#pages_fetched`)
|
|
505
|
+
- `IntacctRest::SchemaGenerationError` — `SchemaGenerator` found no records to sample
|
|
506
|
+
- `IntacctRest::SchemaLoadError` — `SchemaSource` failed to read/parse a YAML file
|
|
507
|
+
|
|
508
|
+
Non-2xx responses from `IntacctRest::Post`/`IntacctRest::Patch` and any endpoint are **not** exceptions — they come back as a Result (see The Result, above).
|
|
509
|
+
|
|
510
|
+
## Development
|
|
511
|
+
|
|
512
|
+
```sh
|
|
513
|
+
bundle install
|
|
514
|
+
bundle exec rake test
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
## Contributing
|
|
518
|
+
|
|
519
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/bernespinoza/intacct-rest. Please open an issue first; see [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow.
|
|
520
|
+
|
|
521
|
+
## License
|
|
522
|
+
|
|
523
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IntacctRest
|
|
4
|
+
# Shared by any class that sends one authenticated HTTP request to the
|
|
5
|
+
# Intacct REST API: retries once on 401 (refreshing the token first).
|
|
6
|
+
# parses JSON, and raises on a non-2xx response or an `ia::error` payload.
|
|
7
|
+
# Stops there — pagination (Query#each_page) and response shaping (Page)
|
|
8
|
+
# are the including class's responsibility, not this module's.
|
|
9
|
+
#
|
|
10
|
+
# Including classes must expose private `config` and `token_provider`
|
|
11
|
+
# readers (not enforced by Ruby — just the implicit contract here).
|
|
12
|
+
module AuthenticatedRequest
|
|
13
|
+
include ErrorReporting
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Raises IntacctRest::ApiError for a non-2xx response or an `ia::error`
|
|
18
|
+
# payload, and returns the parsed JSON body. Used by callers (Query)
|
|
19
|
+
# that want request failures raised.
|
|
20
|
+
def authenticated_request(method, path, body: nil)
|
|
21
|
+
response = send_request(method, path, body, retried: false)
|
|
22
|
+
|
|
23
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
24
|
+
raise_reported IntacctRest::ApiError.new("HTTP #{response.code} requesting #{path}",
|
|
25
|
+
http_status: response.code, body: response.body),
|
|
26
|
+
operation: :api_request, path: path, http_status: response.code
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
parsed = parse_json(response.body, path)
|
|
30
|
+
|
|
31
|
+
if parsed['ia::error']
|
|
32
|
+
raise_reported IntacctRest::ApiError.new("API error requesting #{path}", http_status: response.code, body: parsed),
|
|
33
|
+
operation: :api_request, path: path, http_status: response.code
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
parsed
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Like authenticated_request, but returns the raw Net::HTTPResponse
|
|
40
|
+
# instead of raising ApiError for a non-2xx or an ia::error payload —
|
|
41
|
+
# for callers (IntacctRest::Post) that hand failure responses back as
|
|
42
|
+
# a Result value instead of an exception. Still raises
|
|
43
|
+
# AuthenticationError on a repeated 401.
|
|
44
|
+
def authenticated_response(method, path, body: nil)
|
|
45
|
+
send_request(method, path, body, retried: false)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def send_request(method, path, body, retried:)
|
|
49
|
+
client = IntacctRest::Client.new("#{config.base_url}#{path}", method)
|
|
50
|
+
client.add_header('Authorization', "Bearer #{token_provider.access_token}")
|
|
51
|
+
client.body = body if body
|
|
52
|
+
|
|
53
|
+
response = client.request
|
|
54
|
+
|
|
55
|
+
if response.is_a?(Net::HTTPUnauthorized)
|
|
56
|
+
if retried
|
|
57
|
+
raise_reported IntacctRest::AuthenticationError.new('Authentication failed after token refresh'),
|
|
58
|
+
operation: :api_request, path: path, http_status: response.code
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
token_provider.refresh_access_token
|
|
62
|
+
return send_request(method, path, body, retried: true)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
response
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_json(body, path)
|
|
69
|
+
JSON.parse(body)
|
|
70
|
+
rescue JSON::ParserError => e
|
|
71
|
+
raise_reported IntacctRest::ResponseParseError.new("#{e.message} (#{path})", raw_body: body),
|
|
72
|
+
operation: :api_request, path: path
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|