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
|
@@ -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,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
|