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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +22 -0
  4. data/README.md +523 -0
  5. data/lib/intacct_rest/authenticated_request.rb +75 -0
  6. data/lib/intacct_rest/client.rb +65 -0
  7. data/lib/intacct_rest/configuration.rb +169 -0
  8. data/lib/intacct_rest/custom_field.rb +19 -0
  9. data/lib/intacct_rest/endpoints/create_bill.rb +28 -0
  10. data/lib/intacct_rest/endpoints/create_bill_line.rb +31 -0
  11. data/lib/intacct_rest/endpoints/create_customer.rb +35 -0
  12. data/lib/intacct_rest/endpoints/create_invoice.rb +35 -0
  13. data/lib/intacct_rest/endpoints/create_invoice_line.rb +31 -0
  14. data/lib/intacct_rest/endpoints/create_term.rb +28 -0
  15. data/lib/intacct_rest/endpoints/create_vendor.rb +35 -0
  16. data/lib/intacct_rest/endpoints/update_vendor.rb +38 -0
  17. data/lib/intacct_rest/error_reporting.rb +28 -0
  18. data/lib/intacct_rest/errors.rb +48 -0
  19. data/lib/intacct_rest/filter.rb +34 -0
  20. data/lib/intacct_rest/model/base.rb +48 -0
  21. data/lib/intacct_rest/model/bill.rb +120 -0
  22. data/lib/intacct_rest/model/bill_line.rb +103 -0
  23. data/lib/intacct_rest/model/contact.rb +52 -0
  24. data/lib/intacct_rest/model/currency.rb +50 -0
  25. data/lib/intacct_rest/model/customer.rb +116 -0
  26. data/lib/intacct_rest/model/invoice.rb +121 -0
  27. data/lib/intacct_rest/model/invoice_line.rb +101 -0
  28. data/lib/intacct_rest/model/term.rb +88 -0
  29. data/lib/intacct_rest/model/vendor.rb +163 -0
  30. data/lib/intacct_rest/oauth_client.rb +85 -0
  31. data/lib/intacct_rest/objects.rb +30 -0
  32. data/lib/intacct_rest/page.rb +5 -0
  33. data/lib/intacct_rest/patch.rb +46 -0
  34. data/lib/intacct_rest/post.rb +43 -0
  35. data/lib/intacct_rest/query.rb +88 -0
  36. data/lib/intacct_rest/result/base.rb +17 -0
  37. data/lib/intacct_rest/result/error.rb +14 -0
  38. data/lib/intacct_rest/result/success.rb +27 -0
  39. data/lib/intacct_rest/schema_generator.rb +69 -0
  40. data/lib/intacct_rest/schema_source.rb +49 -0
  41. data/lib/intacct_rest/token_store/memory.rb +36 -0
  42. data/lib/intacct_rest/validators/custom.rb +17 -0
  43. data/lib/intacct_rest/validators/inclusion.rb +16 -0
  44. data/lib/intacct_rest/validators/kind_of.rb +24 -0
  45. data/lib/intacct_rest/validators/presence.rb +13 -0
  46. data/lib/intacct_rest/validators.rb +33 -0
  47. data/lib/intacct_rest/version.rb +3 -0
  48. data/lib/intacct_rest.rb +75 -0
  49. 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