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
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Model
5
+ # An AR invoice line's data and validations only — no config, no token
6
+ # provider, no HTTP. Build and inspect one freely; IntacctRest::Post
7
+ # (via IntacctRest::Endpoints::CreateInvoiceLine) is what actually
8
+ # sends it.
9
+ #
10
+ # This is a standalone-creatable object (POST
11
+ # /objects/accounts-receivable/invoice-line) distinct from the raw
12
+ # line-item Hashes accepted by Model::Invoice#lines — Intacct allows
13
+ # both: send lines inline with the invoice, or create them separately
14
+ # against an existing invoice via `invoice: { "key" => "..." }`.
15
+ #
16
+ # Nested objects (glAccount, dimensions, allocation, ...) are NOT
17
+ # individually modeled — pass them as raw Hashes using Intacct's
18
+ # native (camelCase) nested key names.
19
+ class InvoiceLine < IntacctRest::Model::Base
20
+ INTACCT_OBJECT = '/objects/accounts-receivable/invoice-line'.freeze
21
+
22
+ WRITABLE_ATTRIBUTES = IntacctRest::Configuration::DEFAULT_INVOICE_LINE_WRITABLE_ATTRIBUTES
23
+
24
+ # Set by IntacctRest::Post after a successful create — writable here
25
+ # even though Intacct itself treats them as read-only. `href` is
26
+ # appended even though the schema omits it — the actual 201
27
+ # response includes it, same as Invoice/Vendor/Customer.
28
+ READONLY_ATTRIBUTES = %i[
29
+ key id base_amount currency payment_information line_number is_subtotal base_location
30
+ created_date is_summarized retainage project_contract project_contract_line audit href
31
+ ].freeze
32
+
33
+ attr_accessor(*WRITABLE_ATTRIBUTES.keys)
34
+ attr_accessor(*READONLY_ATTRIBUTES)
35
+ attr_reader :custom_fields
36
+
37
+ validate :presence, %i[invoice txn_amount gl_account]
38
+ validate :kind_of, :string, %i[txn_amount memo]
39
+ validate :kind_of, :hash, %i[gl_account override_offset_gl_account allocation account_label dimensions invoice]
40
+ validate :kind_of, :array, %i[tax_entries]
41
+
42
+ def initialize(source = nil, custom_fields: [], **attributes)
43
+ self.custom_fields = custom_fields
44
+ merged = source ? attributes_from(source).merge(attributes) : attributes
45
+ merged.each { |key, value| public_send(:"#{key}=", value) if respond_to?(:"#{key}=") }
46
+ end
47
+
48
+ def custom_fields=(value)
49
+ @custom_fields = normalize_custom_fields(value)
50
+ end
51
+
52
+ def intacct_object
53
+ self.class::INTACCT_OBJECT
54
+ end
55
+
56
+ def attributes
57
+ WRITABLE_ATTRIBUTES.keys.each_with_object({}) { |attr, hash| hash[attr] = public_send(attr) }
58
+ end
59
+
60
+ def payload
61
+ WRITABLE_ATTRIBUTES.each_with_object({}) do |(attr, json_key), hash|
62
+ value = public_send(attr)
63
+ hash[json_key] = value unless value.nil?
64
+ end.merge(custom_fields_payload)
65
+ end
66
+
67
+ # Called by IntacctRest::Post after a successful create.
68
+ def apply_result(result)
69
+ data = result.result
70
+ self.key = data['key'] if data['key']
71
+ self.href = data['href'] if data['href']
72
+ self.id = data['id'] if data['id'] && id.nil?
73
+ end
74
+
75
+ private
76
+
77
+ def attributes_from(source)
78
+ (WRITABLE_ATTRIBUTES.keys + READONLY_ATTRIBUTES).each_with_object({}) do |attr, hash|
79
+ hash[attr] = source.public_send(attr) if source.respond_to?(attr)
80
+ end
81
+ end
82
+
83
+ def normalize_custom_fields(value)
84
+ case value
85
+ when Array
86
+ value.map { |v| v.is_a?(IntacctRest::CustomField) ? v : IntacctRest::CustomField.new(**v) }
87
+ when Hash
88
+ value.map { |name, val| IntacctRest::CustomField.new(name: name.to_s, value: val) }
89
+ when nil
90
+ []
91
+ else
92
+ raise ArgumentError, "Unsupported custom_fields value: #{value.class}"
93
+ end
94
+ end
95
+
96
+ def custom_fields_payload
97
+ custom_fields.each_with_object({}) { |field, hash| hash[field.key] = field.value }
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Model
5
+ # An AR term's data and validations only — no config, no token
6
+ # provider, no HTTP. Build and inspect one freely; IntacctRest::Post
7
+ # (via IntacctRest::Endpoints::CreateTerm) is what actually sends it.
8
+ #
9
+ # Nested objects (due, discount, penalty) are NOT individually
10
+ # modeled — pass them as raw Hashes using Intacct's native (camelCase)
11
+ # nested key names, e.g. due: { "days" => 30, "from" => "fromInvoiceDate" }.
12
+ class Term < IntacctRest::Model::Base
13
+ INTACCT_OBJECT = '/objects/accounts-receivable/term'.freeze
14
+
15
+ WRITABLE_ATTRIBUTES = IntacctRest::Configuration::DEFAULT_TERM_WRITABLE_ATTRIBUTES
16
+
17
+ READONLY_ATTRIBUTES = %i[key href audit].freeze
18
+
19
+ attr_accessor(*WRITABLE_ATTRIBUTES.keys)
20
+ attr_accessor(*READONLY_ATTRIBUTES)
21
+ attr_reader :custom_fields
22
+
23
+ validate :presence, %i[id description]
24
+ validate :kind_of, :string, %i[id status description]
25
+ validate :kind_of, :hash, %i[due discount penalty]
26
+
27
+ # source: an arbitrary domain object to pull matching attributes off
28
+ # of via duck-typing. Explicit keyword attributes always win.
29
+ def initialize(source = nil, custom_fields: [], **attributes)
30
+ self.custom_fields = custom_fields
31
+ merged = source ? attributes_from(source).merge(attributes) : attributes
32
+ merged.each { |key, value| public_send(:"#{key}=", value) if respond_to?(:"#{key}=") }
33
+ end
34
+
35
+ def custom_fields=(value)
36
+ @custom_fields = normalize_custom_fields(value)
37
+ end
38
+
39
+ def intacct_object
40
+ self.class::INTACCT_OBJECT
41
+ end
42
+
43
+ def attributes
44
+ WRITABLE_ATTRIBUTES.keys.each_with_object({}) { |attr, hash| hash[attr] = public_send(attr) }
45
+ end
46
+
47
+ def payload
48
+ WRITABLE_ATTRIBUTES.each_with_object({}) do |(attr, json_key), hash|
49
+ value = public_send(attr)
50
+ hash[json_key] = value unless value.nil?
51
+ end.merge(custom_fields_payload)
52
+ end
53
+
54
+ # Called by IntacctRest::Post after a successful create.
55
+ def apply_result(result)
56
+ data = result.result
57
+ self.key = data['key'] if data['key']
58
+ self.href = data['href'] if data['href']
59
+ self.id = data['id'] if data['id'] && id.nil?
60
+ end
61
+
62
+ private
63
+
64
+ def attributes_from(source)
65
+ (WRITABLE_ATTRIBUTES.keys + READONLY_ATTRIBUTES).each_with_object({}) do |attr, hash|
66
+ hash[attr] = source.public_send(attr) if source.respond_to?(attr)
67
+ end
68
+ end
69
+
70
+ def normalize_custom_fields(value)
71
+ case value
72
+ when Array
73
+ value.map { |v| v.is_a?(IntacctRest::CustomField) ? v : IntacctRest::CustomField.new(**v) }
74
+ when Hash
75
+ value.map { |name, val| IntacctRest::CustomField.new(name: name.to_s, value: val) }
76
+ when nil
77
+ []
78
+ else
79
+ raise ArgumentError, "Unsupported custom_fields value: #{value.class}"
80
+ end
81
+ end
82
+
83
+ def custom_fields_payload
84
+ custom_fields.each_with_object({}) { |field, hash| hash[field.key] = field.value }
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Model
5
+ # The vendor's data and validations only — no config, no token
6
+ # provider, no HTTP. Build and inspect one freely; IntacctRest::Post
7
+ # (via IntacctRest::Endpoints::CreateVendor) and IntacctRest::Patch
8
+ # (via IntacctRest::Endpoints::UpdateVendor) are what actually send it.
9
+ # Create requires id/name; update requires key and sends only the
10
+ # fields that are set (see #update_payload).
11
+ #
12
+ # Nested objects (bank_files, contacts, term, bill_payment, ...) are
13
+ # not individually modeled: pass them as raw Hashes using Intacct's
14
+ # native (camelCase) nested key names, e.g. term: { "id" => "Net 30" },
15
+ # or a helper's #payload, e.g. contacts: contact.payload.
16
+ class Vendor < IntacctRest::Model::Base
17
+ INTACCT_OBJECT = '/objects/accounts-payable/vendor'.freeze
18
+
19
+ WRITABLE_ATTRIBUTES = IntacctRest::Configuration::DEFAULT_VENDOR_WRITABLE_ATTRIBUTES
20
+
21
+ # Set by IntacctRest::Post (via #apply_result) after a successful
22
+ # create — writable here even though Intacct itself treats them as
23
+ # read-only, since this is the only place that result gets recorded.
24
+ READONLY_ATTRIBUTES = %i[
25
+ key href is_system_generated customer employee total_due last_bill_created_date
26
+ last_payment_made_date web_url audit entity
27
+ ].freeze
28
+
29
+ # Writable on create but readOnly on PATCH — left out of
30
+ # #update_payload.
31
+ UPDATE_READONLY_ATTRIBUTES = %i[id].freeze
32
+
33
+ # The exact enum from the OpenAPI bankFiles.paymentCountryCode /
34
+ # country-code schema (nil is separately allowed by the :inclusion
35
+ # validator below — not listed here).
36
+ BANK_FILE_COUNTRY_CODES = %w[
37
+ us af ax al dz as ad ao ai aq ag ar am aw au at az bs bh bd bb by be bz bj bm bt bo bq ba
38
+ bw bv br io bn bg bf bi kh cm ca ic cv ky cf td cl cn cx cc co km cg cd ck cr ci hr cu cw
39
+ cy cz dk dj dm do ec eg sv gq er ee sz et fk fo fj fi fr gf pf tf ga gm ge de gh gi gr gl
40
+ gd gp gu gt gg gn gw gy ht hm hn hk hu is in id ir iq ie im il it jm jp je jo kz ke ki kr
41
+ kp xk kw kg la lv lb ls lr ly li lt lu mo mk mg mw my mv ml mt mh mq mr mu yt mx fm md mc
42
+ mn me ms ma mz mm na nr np nl an nc nz ni ne ng nu nf mp no om pk pw ps pa pg py pe ph pn
43
+ pl pt pr qa re ro ru rw bl sh kn lc mf pm vc ws sm st sa sn rs sc sl sg sx sk si sb so za
44
+ gs es lk sd ss sr sj se ch sy tw tj tz th tl tg tk to tt tn tr tm tc tv ug ua ae gb um uy
45
+ uz vu va ve vn vg vi wf eh ye zm zw
46
+ ].freeze
47
+
48
+ attr_accessor(*WRITABLE_ATTRIBUTES.keys)
49
+ attr_accessor(*READONLY_ATTRIBUTES)
50
+ attr_reader :custom_fields
51
+
52
+ validate :presence, %i[id name], on: :create
53
+ validate :presence, %i[key], on: :update
54
+ validate :kind_of, :string, %i[key], on: :update
55
+
56
+ validate :kind_of, :string, %i[
57
+ id name status state file_payment_service tax_id notes preferred_payment_method
58
+ billing_type payment_priority currency vendor_account_number vendor_restriction
59
+ ]
60
+ validate :kind_of, :boolean, %i[
61
+ is_one_time_use is_on_hold do_not_pay always_create_bill is_individual_person
62
+ merge_payment_requests send_payment_notification display_term_discount_on_check_stub
63
+ display_vendor_account_on_check_stub
64
+ ]
65
+ validate :kind_of, :integer, %i[default_lead_time]
66
+ validate :kind_of, :numeric, %i[credit_limit retainage_percentage discount_percent]
67
+ validate :kind_of, :hash, %i[
68
+ vendor_type parent account_group account_label bank_files tpar t5018 form1099 attachment
69
+ price_list price_schedule override_offset_gl_account default_expense_gl_account contacts
70
+ term bill_payment ach
71
+ ]
72
+ validate :kind_of, :array, %i[
73
+ contact_list vendor_email_templates vendor_payment_providers vendor_bank_file_setup
74
+ vendor_account_number_list restricted_locations restricted_departments
75
+ ]
76
+
77
+ validate :inclusion, BANK_FILE_COUNTRY_CODES, %i[bank_files_payment_country_code]
78
+
79
+ # source: an arbitrary domain object (ActiveRecord record, OpenStruct,
80
+ # another Model::Vendor, ...) to pull matching attributes off of via
81
+ # duck-typing — any WRITABLE_ATTRIBUTES/READONLY_ATTRIBUTES key it
82
+ # responds to is copied over. Explicit keyword attributes always win
83
+ # over whatever the source provided.
84
+ def initialize(source = nil, custom_fields: [], **attributes)
85
+ self.custom_fields = custom_fields
86
+ merged = source ? attributes_from(source).merge(attributes) : attributes
87
+ merged.each { |key, value| public_send(:"#{key}=", value) if respond_to?(:"#{key}=") }
88
+ end
89
+
90
+ def custom_fields=(value)
91
+ @custom_fields = normalize_custom_fields(value)
92
+ end
93
+
94
+ def intacct_object
95
+ self.class::INTACCT_OBJECT
96
+ end
97
+
98
+ # Ruby-side attributes (unmapped, no custom fields) — useful for
99
+ # logging/introspection without the camelCase JSON translation.
100
+ def attributes
101
+ WRITABLE_ATTRIBUTES.keys.each_with_object({}) { |attr, hash| hash[attr] = public_send(attr) }
102
+ end
103
+
104
+ def payload
105
+ WRITABLE_ATTRIBUTES.each_with_object({}) do |(attr, json_key), hash|
106
+ value = public_send(attr)
107
+ hash[json_key] = value unless value.nil?
108
+ end.merge(custom_fields_payload)
109
+ end
110
+
111
+ # The PATCH body: #payload minus fields Intacct treats as readOnly on
112
+ # update. nil fields are already left out, so only what was set gets
113
+ # sent — Intacct leaves everything else unchanged.
114
+ def update_payload
115
+ payload.except(*UPDATE_READONLY_ATTRIBUTES.map { |attr| WRITABLE_ATTRIBUTES.fetch(attr) })
116
+ end
117
+
118
+ # Called by IntacctRest::Post/IntacctRest::Patch after a successful
119
+ # create/update.
120
+ def apply_result(result)
121
+ data = result.result
122
+ self.key = data['key'] if data['key']
123
+ self.href = data['href'] if data['href']
124
+ self.id = data['id'] if data['id'] && id.nil?
125
+ end
126
+
127
+ # Virtual attribute so the generic :inclusion validator can point at
128
+ # a nested field — bank_files itself stays a raw Hash (see above), so
129
+ # this just digs the one field out of it, tolerating string or
130
+ # symbol keys.
131
+ def bank_files_payment_country_code
132
+ return nil unless bank_files
133
+
134
+ bank_files['paymentCountryCode'] || bank_files[:paymentCountryCode]
135
+ end
136
+
137
+ private
138
+
139
+ def attributes_from(source)
140
+ (WRITABLE_ATTRIBUTES.keys + READONLY_ATTRIBUTES).each_with_object({}) do |attr, hash|
141
+ hash[attr] = source.public_send(attr) if source.respond_to?(attr)
142
+ end
143
+ end
144
+
145
+ def normalize_custom_fields(value)
146
+ case value
147
+ when Array
148
+ value.map { |v| v.is_a?(IntacctRest::CustomField) ? v : IntacctRest::CustomField.new(**v) }
149
+ when Hash
150
+ value.map { |name, val| IntacctRest::CustomField.new(name: name.to_s, value: val) }
151
+ when nil
152
+ []
153
+ else
154
+ raise ArgumentError, "Unsupported custom_fields value: #{value.class}"
155
+ end
156
+ end
157
+
158
+ def custom_fields_payload
159
+ custom_fields.each_with_object({}) { |field, hash| hash[field.key] = field.value }
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ # OAuth2 token handling against Intacct's v1 token endpoint: client_credentials
5
+ # and refresh_token grants, with pluggable token storage (see Configuration#token_store).
6
+ class OauthClient
7
+ include ErrorReporting
8
+
9
+ TOKEN_EXPIRY_BUFFER = 60 # seconds
10
+
11
+ def initialize(config: IntacctRest.configuration)
12
+ @config = config
13
+ end
14
+
15
+ def access_token
16
+ token_store.read(access_token_key) || fetch_token
17
+ end
18
+
19
+ def refresh_access_token
20
+ refresh = token_store.read(refresh_token_key)
21
+ refresh ? exchange_refresh_token(refresh) : fetch_token
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :config
27
+
28
+ # Token errors are reported here, not in post_token: a failed
29
+ # refresh_token exchange is recovered by falling back to this method, so
30
+ # only errors leaving fetch_token actually reach the caller.
31
+ def fetch_token
32
+ store_tokens(post_token(grant_type: 'client_credentials'))
33
+ rescue IntacctRest::Error => e
34
+ raise_reported e, operation: :token_request, path: config.token_path, grant_type: 'client_credentials'
35
+ end
36
+
37
+ def exchange_refresh_token(refresh)
38
+ store_tokens(post_token(grant_type: 'refresh_token', refresh_token: refresh))
39
+ rescue IntacctRest::Error
40
+ fetch_token
41
+ end
42
+
43
+ def post_token(params)
44
+ client = IntacctRest::Client.new(token_url, :post)
45
+ client.body = params.merge(
46
+ client_id: config.client_id,
47
+ client_secret: config.client_secret,
48
+ username: config.username
49
+ )
50
+
51
+ response = client.request
52
+ unless response.is_a?(Net::HTTPSuccess)
53
+ raise IntacctRest::AuthenticationError,
54
+ "OAuth token request failed: HTTP #{response.code} — #{response.body}"
55
+ end
56
+
57
+ JSON.parse(response.body)
58
+ rescue JSON::ParserError => e
59
+ raise IntacctRest::ResponseParseError.new(e.message, raw_body: response&.body)
60
+ end
61
+
62
+ def store_tokens(parsed)
63
+ ttl = [parsed['expires_in'].to_i - TOKEN_EXPIRY_BUFFER, 1].max
64
+ token_store.write(access_token_key, parsed['access_token'], ttl: ttl)
65
+ token_store.write(refresh_token_key, parsed['refresh_token']) if parsed['refresh_token']
66
+ parsed['access_token']
67
+ end
68
+
69
+ def token_store
70
+ config.token_store
71
+ end
72
+
73
+ def token_url
74
+ "#{config.base_url}#{config.token_path}"
75
+ end
76
+
77
+ def access_token_key
78
+ "#{config.token_key_prefix}:access_token"
79
+ end
80
+
81
+ def refresh_token_key
82
+ "#{config.token_key_prefix}:refresh_token"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ # Wraps Intacct's Objects API (GET /objects/{resource} and
5
+ # GET /objects/{resource}/{key}) — distinct from the Query API (Query).
6
+ # No pagination handling: callers needing more than "list" or "fetch one"
7
+ # should use Query against the same resource instead.
8
+ class Objects
9
+ include AuthenticatedRequest
10
+
11
+ attr_reader :token_provider
12
+
13
+ def initialize(config: IntacctRest.configuration, token_provider: nil)
14
+ @config = config
15
+ @token_provider = token_provider || IntacctRest::OauthClient.new(config: config)
16
+ end
17
+
18
+ def list(resource)
19
+ authenticated_request(:get, "/objects/#{resource}")['ia::result'] || []
20
+ end
21
+
22
+ def find(resource, key)
23
+ authenticated_request(:get, "/objects/#{resource}/#{key}")['ia::result']
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :config
29
+ end
30
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ Page = Struct.new(:items, :next_cursor, :raw, keyword_init: true)
5
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ # Generic operation for PATCH-ing (updating) any model that responds to
5
+ # #intacct_object (the collection path), #key (the record appended to
6
+ # it), #update_payload (the outgoing JSON hash — only the fields to
7
+ # change), and #valid?/#errors with an :update context. Raises
8
+ # IntacctRest::ValidationError if the model is invalid for update,
9
+ # before sending anything. Otherwise always returns an
10
+ # IntacctRest::Result (Success or Error) — never raises for the HTTP
11
+ # outcome itself. On success, calls model.apply_result(result) so the
12
+ # model can absorb whatever it cares about from the response.
13
+ class Patch
14
+ include AuthenticatedRequest
15
+
16
+ def self.call(model, config: IntacctRest.configuration, token_provider: nil)
17
+ new(config: config, token_provider: token_provider).send(:perform, model)
18
+ end
19
+
20
+ def initialize(config: IntacctRest.configuration, token_provider: nil)
21
+ @config = config
22
+ @token_provider = token_provider || IntacctRest::OauthClient.new(config: config)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :config, :token_provider
28
+
29
+ def perform(model)
30
+ errors = model.errors(:update)
31
+ raise IntacctRest::ValidationError.new(errors.join('; '), attributes: errors) unless errors.empty?
32
+
33
+ path = "#{model.intacct_object}/#{model.key}"
34
+ response = authenticated_response(:patch, path, body: model.update_payload)
35
+ parsed = parse_json(response.body, path)
36
+ result = build_result(model, response, parsed)
37
+ model.apply_result(result) if result.success?
38
+ result
39
+ end
40
+
41
+ def build_result(model, response, parsed)
42
+ klass = response.is_a?(Net::HTTPSuccess) ? IntacctRest::Result::Success : IntacctRest::Result::Error
43
+ klass.new(model: model, code: response.code, body: parsed, headers: response.to_hash)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ # Generic operation for POST-ing any model that responds to
5
+ # #intacct_object (the endpoint path), #payload (the outgoing JSON
6
+ # hash), and #valid?/#errors. Raises IntacctRest::ValidationError if the
7
+ # model is invalid, before sending anything. Otherwise always returns an
8
+ # IntacctRest::Result (Success or Error) — never raises for the HTTP
9
+ # outcome itself. On success, calls model.apply_result(result) so the
10
+ # model can absorb whatever it cares about from the response.
11
+ class Post
12
+ include AuthenticatedRequest
13
+
14
+ def self.call(model, config: IntacctRest.configuration, token_provider: nil)
15
+ new(config: config, token_provider: token_provider).send(:perform, model)
16
+ end
17
+
18
+ def initialize(config: IntacctRest.configuration, token_provider: nil)
19
+ @config = config
20
+ @token_provider = token_provider || IntacctRest::OauthClient.new(config: config)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :config, :token_provider
26
+
27
+ def perform(model)
28
+ errors = model.errors(:create)
29
+ raise IntacctRest::ValidationError.new(errors.join('; '), attributes: errors) unless errors.empty?
30
+
31
+ response = authenticated_response(:post, model.intacct_object, body: model.payload)
32
+ parsed = parse_json(response.body, model.intacct_object)
33
+ result = build_result(model, response, parsed)
34
+ model.apply_result(result) if result.success?
35
+ result
36
+ end
37
+
38
+ def build_result(model, response, parsed)
39
+ klass = response.is_a?(Net::HTTPSuccess) ? IntacctRest::Result::Success : IntacctRest::Result::Error
40
+ klass.new(model: model, code: response.code, body: parsed, headers: response.to_hash)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ # Wraps Intacct's POST /services/core/query endpoint for any object type
5
+ # (not just invoices), with pagination support.
6
+ #
7
+ # query = IntacctRest::Query.new(
8
+ # resource: "accounts-receivable/invoice",
9
+ # schema: %w[key state],
10
+ # filters: [IntacctRest::Filter.eq("state", "paid")]
11
+ # )
12
+ # query.each_page { |items, next_cursor| ... }
13
+ class Query
14
+ include AuthenticatedRequest
15
+
16
+ attr_reader :resource, :schema, :filters, :order_by, :size, :start, :token_provider
17
+
18
+ def initialize(resource:, schema: [], filters: [], order_by: nil, size: nil, start: nil,
19
+ config: IntacctRest.configuration, token_provider: nil)
20
+ @resource = resource
21
+ @schema = schema
22
+ @filters = filters
23
+ @order_by = order_by
24
+ @config = config
25
+ @size = size || config.page_size
26
+ @start = start
27
+ @token_provider = token_provider || IntacctRest::OauthClient.new(config: config)
28
+ end
29
+
30
+ def url
31
+ "#{config.base_url}#{config.query_path}"
32
+ end
33
+
34
+ def payload
35
+ {
36
+ 'object' => resource,
37
+ 'fields' => schema,
38
+ 'filters' => filters,
39
+ 'orderBy' => order_by,
40
+ 'size' => size,
41
+ 'start' => start
42
+ }.compact
43
+ end
44
+
45
+ def call
46
+ parsed = authenticated_request(:post, config.query_path, body: payload)
47
+
48
+ IntacctRest::Page.new(
49
+ items: parsed['ia::result'] || [],
50
+ next_cursor: parsed.dig('ia::meta', 'next'),
51
+ raw: parsed
52
+ )
53
+ end
54
+
55
+ def each_page(max_pages: config.max_pages)
56
+ cursor = start
57
+ pages_fetched = 0
58
+
59
+ loop do
60
+ pages_fetched += 1
61
+ if pages_fetched > max_pages
62
+ raise IntacctRest::TooManyPagesError.new(
63
+ "Exceeded max pages (#{max_pages}) while querying #{resource}",
64
+ pages_fetched: pages_fetched - 1
65
+ )
66
+ end
67
+
68
+ page = with_start(cursor).call
69
+ yield page.items, page.next_cursor
70
+
71
+ break unless page.next_cursor
72
+
73
+ cursor = page.next_cursor
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :config
80
+
81
+ def with_start(cursor)
82
+ return self if cursor == start
83
+
84
+ self.class.new(resource: resource, schema: schema, filters: filters, order_by: order_by,
85
+ size: size, start: cursor, config: config, token_provider: token_provider)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Result
5
+ class Base
6
+ attr_reader :model, :code, :body, :headers
7
+ alias response body
8
+
9
+ def initialize(model:, code:, body:, headers: {})
10
+ @model = model
11
+ @code = code
12
+ @body = body
13
+ @headers = headers
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Result
5
+ class Error < Base
6
+ def success? = false
7
+ def failed? = true
8
+
9
+ def error
10
+ body.dig('ia::result', 'ia::error') || body['ia::error']
11
+ end
12
+ end
13
+ end
14
+ end