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,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ class Client
5
+ HTTP_POST = 'Post'.freeze
6
+ HTTP_GET = 'Get'.freeze
7
+ HTTP_PUT = 'Put'.freeze
8
+ HTTP_DELETE = 'Delete'.freeze
9
+ HTTP_PATCH = 'Patch'.freeze
10
+ METHODS = {
11
+ post: HTTP_POST,
12
+ get: HTTP_GET,
13
+ put: HTTP_PUT,
14
+ patch: HTTP_PATCH,
15
+ delete: HTTP_DELETE
16
+ }
17
+ attr_reader :uri, :http, :net_request
18
+
19
+ def initialize(uri, method = METHODS.fetch(:post))
20
+ @method = METHODS.fetch(method.to_sym, HTTP_POST)
21
+ @uri = URI(uri)
22
+ @http = Net::HTTP.new(@uri.host, @uri.port)
23
+ @http.use_ssl = @uri.scheme == 'https'
24
+ @net_request = Object.const_get("Net::HTTP::#{@method}").new(@uri)
25
+ @body = {}
26
+ @headers = default_headers
27
+ end
28
+
29
+ def body
30
+ @body
31
+ end
32
+
33
+ def body=(value = {})
34
+ @body.merge!(value)
35
+ end
36
+
37
+ def add_header(name, value)
38
+ @headers[name] = value
39
+ end
40
+
41
+ def headers
42
+ @headers
43
+ end
44
+
45
+ def request
46
+ net_request.body = body.to_json
47
+ headers.each_pair do |header, value|
48
+ net_request.add_field(header,value)
49
+ end
50
+ http.request(net_request)
51
+ end
52
+
53
+ def request_method
54
+ @method
55
+ end
56
+
57
+ private
58
+
59
+ def default_headers
60
+ {
61
+ 'CONTENT-TYPE' => 'application/json'
62
+ }
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,169 @@
1
+ module IntacctRest
2
+ class Configuration
3
+ DEFAULT_BASE_URL = "#{IntacctRest::API_URI}/#{IntacctRest::API_VERSION}".freeze
4
+ DEFAULT_TOKEN_PATH = '/oauth2/token'.freeze
5
+ DEFAULT_QUERY_PATH = '/services/core/query'.freeze
6
+ DEFAULT_PAGE_SIZE = 200
7
+ DEFAULT_MAX_PAGES = 50
8
+ DEFAULT_TOKEN_KEY_PREFIX = 'intacct_rest:oauth'.freeze
9
+
10
+ # Maps IntacctRest::Model::Vendor's Ruby (snake_case) accessor names to the
11
+ # exact camelCase JSON field names Intacct's vendor object uses. Kept
12
+ # as explicit pairs rather than derived by a camelCase<->snake_case
13
+ # regex, since a couple of fields have acronym runs a naive regex
14
+ # would mangle (overrideOffsetGLAccount -> override_offset_g_l_account
15
+ # instead of override_offset_gl_account). Excludes the deprecated
16
+ # `accountlabel` alias in favor of `accountLabel`.
17
+ DEFAULT_VENDOR_WRITABLE_ATTRIBUTES = {
18
+ id: 'id', name: 'name', is_one_time_use: 'isOneTimeUse', status: 'status', state: 'state',
19
+ vendor_type: 'vendorType', parent: 'parent', account_group: 'accountGroup',
20
+ account_label: 'accountLabel', default_lead_time: 'defaultLeadTime',
21
+ file_payment_service: 'filePaymentService', bank_files: 'bankFiles', tax_id: 'taxId',
22
+ tpar: 'tpar', t5018: 't5018', form1099: 'form1099', attachment: 'attachment',
23
+ is_on_hold: 'isOnHold', do_not_pay: 'doNotPay', always_create_bill: 'alwaysCreateBill',
24
+ credit_limit: 'creditLimit', retainage_percentage: 'retainagePercentage',
25
+ is_individual_person: 'isIndividualPerson', notes: 'notes', price_list: 'priceList',
26
+ price_schedule: 'priceSchedule', override_offset_gl_account: 'overrideOffsetGLAccount',
27
+ default_expense_gl_account: 'defaultExpenseGLAccount', discount_percent: 'discountPercent',
28
+ contacts: 'contacts', contact_list: 'contactList',
29
+ vendor_email_templates: 'vendorEmailTemplates', vendor_payment_providers: 'vendorPaymentProviders',
30
+ vendor_bank_file_setup: 'vendorBankFileSetup', vendor_account_number_list: 'vendorAccountNumberList',
31
+ preferred_payment_method: 'preferredPaymentMethod', merge_payment_requests: 'mergePaymentRequests',
32
+ send_payment_notification: 'sendPaymentNotification', billing_type: 'billingType',
33
+ payment_priority: 'paymentPriority', currency: 'currency',
34
+ display_term_discount_on_check_stub: 'displayTermDiscountOnCheckStub', term: 'term',
35
+ bill_payment: 'billPayment', vendor_account_number: 'vendorAccountNumber',
36
+ display_vendor_account_on_check_stub: 'displayVendorAccountOnCheckStub', ach: 'ach',
37
+ vendor_restriction: 'vendorRestriction', restricted_locations: 'restrictedLocations',
38
+ restricted_departments: 'restrictedDepartments'
39
+ }.freeze
40
+
41
+ # Maps IntacctRest::Model::Invoice's Ruby (snake_case) accessor names
42
+ # to the exact camelCase JSON field names Intacct's AR invoice object
43
+ # uses. Nested objects (customer, lines, currency, ...) stay raw
44
+ # Hashes — not individually modeled, same as Vendor's nested fields.
45
+ DEFAULT_INVOICE_WRITABLE_ATTRIBUTES = {
46
+ invoice_number: 'invoiceNumber', state: 'state', reference_number: 'referenceNumber',
47
+ description: 'description', invoice_date: 'invoiceDate', due_date: 'dueDate',
48
+ currency: 'currency', customer_message: 'customerMessage', contacts: 'contacts',
49
+ billback_template: 'billbackTemplate', attachment: 'attachment', customer: 'customer',
50
+ term: 'term', tax_solution: 'taxSolution', invoice_type: 'invoiceType',
51
+ invoice_mode: 'invoiceMode', invoice_summary: 'invoiceSummary', lines: 'lines'
52
+ }.freeze
53
+
54
+ # Maps IntacctRest::Model::Term's Ruby (snake_case) accessor names to
55
+ # the exact camelCase JSON field names Intacct's AR term object uses.
56
+ # due/discount/penalty stay raw Hashes — not individually modeled.
57
+ DEFAULT_TERM_WRITABLE_ATTRIBUTES = {
58
+ id: 'id', status: 'status', description: 'description', due: 'due',
59
+ discount: 'discount', penalty: 'penalty'
60
+ }.freeze
61
+
62
+ # Maps IntacctRest::Model::InvoiceLine's Ruby (snake_case) accessor
63
+ # names to the exact camelCase JSON field names Intacct's AR invoice
64
+ # line object uses. Nested objects (glAccount, dimensions, ...) stay
65
+ # raw Hashes — not individually modeled.
66
+ DEFAULT_INVOICE_LINE_WRITABLE_ATTRIBUTES = {
67
+ gl_account: 'glAccount', override_offset_gl_account: 'overrideOffsetGLAccount',
68
+ txn_amount: 'txnAmount', memo: 'memo', allocation: 'allocation',
69
+ account_label: 'accountLabel', tax_entries: 'taxEntries', dimensions: 'dimensions',
70
+ invoice: 'invoice'
71
+ }.freeze
72
+
73
+ # Maps IntacctRest::Model::Currency's Ruby (snake_case) accessor names
74
+ # to the exact camelCase JSON field names used wherever a currency
75
+ # object is nested (invoice header, invoice line, ...). Currency has
76
+ # no standalone create endpoint of its own in Intacct's REST API — this
77
+ # model exists purely to build/validate that nested Hash.
78
+ DEFAULT_CURRENCY_ATTRIBUTES = {
79
+ base_currency: 'baseCurrency', txn_currency: 'txnCurrency', exchange_rate: 'exchangeRate'
80
+ }.freeze
81
+
82
+ # Maps IntacctRest::Model::Customer's Ruby (snake_case) accessor names
83
+ # to the exact camelCase JSON field names Intacct's customer object
84
+ # uses. Excludes deprecated fields (electronicAddress, resaleNumber,
85
+ # enableOnlineACHPayment, enableOnlineCardPayment, activationDate) in
86
+ # favor of their replacements or because they're pure legacy no-ops —
87
+ # same treatment as Vendor's deprecated `accountlabel`.
88
+ DEFAULT_CUSTOMER_WRITABLE_ATTRIBUTES = {
89
+ id: 'id', name: 'name', status: 'status', customer_type: 'customerType', parent: 'parent',
90
+ sales_representative: 'salesRepresentative', tax_id: 'taxId',
91
+ default_revenue_gl_account: 'defaultRevenueGLAccount', shipping_method: 'shippingMethod',
92
+ credit_limit: 'creditLimit', is_on_hold: 'isOnHold', contacts: 'contacts',
93
+ contact_list: 'contactList', restrictions: 'restrictions',
94
+ customer_email_templates: 'customerEmailTemplates', discount_percent: 'discountPercent',
95
+ term: 'term', advance_bill_by: 'advanceBillBy', advance_bill_by_type: 'advanceBillByType',
96
+ customer_resale_number: 'customerResaleNumber', delivery_options: 'deliveryOptions',
97
+ override_price_list: 'overridePriceList', customer_message: 'customerMessage',
98
+ currency: 'currency', email_opt_in: 'emailOptIn', territory: 'territory',
99
+ is_one_time_use: 'isOneTimeUse', disable_refund: 'disableRefund', vendor: 'vendor',
100
+ account_group: 'accountGroup', account_label: 'accountLabel', attachment: 'attachment',
101
+ retainage_percentage: 'retainagePercentage', notes: 'notes', price_list: 'priceList',
102
+ price_schedule: 'priceSchedule', override_offset_gl_account: 'overrideOffsetGLAccount',
103
+ customer_restriction: 'customerRestriction', restricted_locations: 'restrictedLocations',
104
+ restricted_departments: 'restrictedDepartments', customer_account_health: 'customerAccountHealth'
105
+ }.freeze
106
+
107
+ # Maps IntacctRest::Model::Contact's Ruby (snake_case) accessor names
108
+ # to the exact camelCase JSON field names Intacct's contact object
109
+ # uses. Deliberately a small subset of the "contacts.default" nested
110
+ # shape (which appears identically inside both the vendor and customer
111
+ # specs) — every other field there (firstName, lastName, email1/2,
112
+ # phone1/2, mobile, pager, fax, url1/2, companyName, mailingAddress,
113
+ # ...) is individually marked deprecated: true in Intacct's schema, in
114
+ # favor of referencing an existing contact by id. Contact has no
115
+ # standalone create endpoint documented.
116
+ DEFAULT_CONTACT_ATTRIBUTES = {
117
+ id: 'id', show_in_contact_list: 'showInContactList', tax: 'tax',
118
+ electronic_invoice_details: 'electronicInvoiceDetails',
119
+ international_tax_id: 'internationalTaxId', electronic_address: 'electronicAddress'
120
+ }.freeze
121
+
122
+ # Maps IntacctRest::Model::Bill's Ruby (snake_case) accessor names to
123
+ # the exact camelCase JSON field names Intacct's AP bill object uses.
124
+ # Nested objects (vendor, term, currency, lines, ...) stay raw Hashes
125
+ # — not individually modeled, same as Invoice's nested fields.
126
+ # Excludes deprecated fields (`purchasing`, `location` — both marked
127
+ # deprecated: true in the schema in favor of `purchasingDocument` and
128
+ # nothing, respectively).
129
+ DEFAULT_BILL_WRITABLE_ATTRIBUTES = {
130
+ bill_number: 'billNumber', state: 'state', vendor: 'vendor', term: 'term',
131
+ reference_number: 'referenceNumber', description: 'description', posting_date: 'postingDate',
132
+ discount_cut_off_date: 'discountCutOffDate', due_date: 'dueDate',
133
+ recommended_payment_date: 'recommendedPaymentDate', created_date: 'createdDate',
134
+ is_on_hold: 'isOnHold', is_tax_inclusive: 'isTaxInclusive', payment_priority: 'paymentPriority',
135
+ contacts: 'contacts', currency: 'currency', tax_solution: 'taxSolution',
136
+ invoice_type: 'invoiceType', invoice_mode: 'invoiceMode', bill_summary: 'billSummary',
137
+ bill_back_template: 'billBackTemplate', attachment: 'attachment', lines: 'lines',
138
+ dispute: 'dispute', refuse: 'refuse'
139
+ }.freeze
140
+
141
+ # Maps IntacctRest::Model::BillLine's Ruby (snake_case) accessor names
142
+ # to the exact camelCase JSON field names Intacct's AP bill line
143
+ # object uses. Nested objects (glAccount, dimensions, ...) stay raw
144
+ # Hashes — not individually modeled.
145
+ DEFAULT_BILL_LINE_WRITABLE_ATTRIBUTES = {
146
+ vendor: 'vendor', gl_account: 'glAccount', override_offset_gl_account: 'overrideOffsetGLAccount',
147
+ account_label: 'accountLabel', txn_amount: 'txnAmount', total_txn_amount: 'totalTxnAmount',
148
+ memo: 'memo', allocation: 'allocation', has_form1099: 'hasForm1099', form1099: 'form1099',
149
+ release_to_pay: 'releaseToPay', project: 'project', fixed_asset: 'fixedAsset',
150
+ amortization_template: 'amortizationTemplate', amortization_start_date: 'amortizationStartDate',
151
+ amortization_end_date: 'amortizationEndDate', purchasing: 'purchasing',
152
+ tax_entries: 'taxEntries', dimensions: 'dimensions', bill: 'bill'
153
+ }.freeze
154
+
155
+ attr_accessor :client_id, :client_secret, :username, :base_url, :token_path,
156
+ :query_path, :token_store, :page_size, :max_pages, :token_key_prefix,
157
+ :on_error, :schema
158
+
159
+ def initialize
160
+ @base_url = DEFAULT_BASE_URL
161
+ @token_path = DEFAULT_TOKEN_PATH
162
+ @query_path = DEFAULT_QUERY_PATH
163
+ @page_size = DEFAULT_PAGE_SIZE
164
+ @max_pages = DEFAULT_MAX_PAGES
165
+ @token_key_prefix = DEFAULT_TOKEN_KEY_PREFIX
166
+ @token_store = IntacctRest::TokenStore::Memory.new
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ class CustomField
5
+ DEFAULT_NAMESPACE = 'nsp'
6
+
7
+ attr_accessor :namespace, :name, :value
8
+
9
+ def initialize(name:, value: nil, namespace: DEFAULT_NAMESPACE)
10
+ @namespace = namespace
11
+ @name = name
12
+ @value = value
13
+ end
14
+
15
+ def key
16
+ "#{namespace}::#{name}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Endpoints
5
+ class CreateBill
6
+ DEFAULT_RESULT_FIELDS = %i[id key href].freeze
7
+
8
+ def self.call(bill:, results: DEFAULT_RESULT_FIELDS, config: IntacctRest.configuration, token_provider: nil)
9
+ raise ArgumentError, 'bill must be an IntacctRest::Model::Bill' unless bill.is_a?(IntacctRest::Model::Bill)
10
+
11
+ result = IntacctRest::Post.call(bill, config: config, token_provider: token_provider)
12
+ verify_expected_fields!(result, results) if result.success?
13
+ result
14
+ end
15
+
16
+ def self.verify_expected_fields!(result, expected_fields)
17
+ missing = expected_fields.reject { |field| result.respond_to?(field) }
18
+ return if missing.empty?
19
+
20
+ raise IntacctRest::ApiError.new(
21
+ "Expected result fields missing from response: #{missing.join(', ')}",
22
+ http_status: result.code, body: result.body
23
+ )
24
+ end
25
+ private_class_method :verify_expected_fields!
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Endpoints
5
+ class CreateBillLine
6
+ DEFAULT_RESULT_FIELDS = %i[id key href].freeze
7
+
8
+ def self.call(bill_line:, results: DEFAULT_RESULT_FIELDS, config: IntacctRest.configuration,
9
+ token_provider: nil)
10
+ unless bill_line.is_a?(IntacctRest::Model::BillLine)
11
+ raise ArgumentError, 'bill_line must be an IntacctRest::Model::BillLine'
12
+ end
13
+
14
+ result = IntacctRest::Post.call(bill_line, config: config, token_provider: token_provider)
15
+ verify_expected_fields!(result, results) if result.success?
16
+ result
17
+ end
18
+
19
+ def self.verify_expected_fields!(result, expected_fields)
20
+ missing = expected_fields.reject { |field| result.respond_to?(field) }
21
+ return if missing.empty?
22
+
23
+ raise IntacctRest::ApiError.new(
24
+ "Expected result fields missing from response: #{missing.join(', ')}",
25
+ http_status: result.code, body: result.body
26
+ )
27
+ end
28
+ private_class_method :verify_expected_fields!
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Endpoints
5
+ class CreateCustomer
6
+ DEFAULT_RESULT_FIELDS = %i[id key href].freeze
7
+
8
+ # results: fields this endpoint expects a successful response to
9
+ # contain — checked after the fact (raises ApiError if Intacct's
10
+ # response is missing one), not a filter on what gets written back.
11
+ # model.apply_result already decides what it absorbs from any
12
+ # successful result.
13
+ def self.call(customer:, results: DEFAULT_RESULT_FIELDS, config: IntacctRest.configuration, token_provider: nil)
14
+ unless customer.is_a?(IntacctRest::Model::Customer)
15
+ raise ArgumentError, 'customer must be an IntacctRest::Model::Customer'
16
+ end
17
+
18
+ result = IntacctRest::Post.call(customer, config: config, token_provider: token_provider)
19
+ verify_expected_fields!(result, results) if result.success?
20
+ result
21
+ end
22
+
23
+ def self.verify_expected_fields!(result, expected_fields)
24
+ missing = expected_fields.reject { |field| result.respond_to?(field) }
25
+ return if missing.empty?
26
+
27
+ raise IntacctRest::ApiError.new(
28
+ "Expected result fields missing from response: #{missing.join(', ')}",
29
+ http_status: result.code, body: result.body
30
+ )
31
+ end
32
+ private_class_method :verify_expected_fields!
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Endpoints
5
+ class CreateInvoice
6
+ DEFAULT_RESULT_FIELDS = %i[id key href].freeze
7
+
8
+ # results: fields this endpoint expects a successful response to
9
+ # contain — checked after the fact (raises ApiError if Intacct's
10
+ # response is missing one), not a filter on what gets written back.
11
+ # model.apply_result already decides what it absorbs from any
12
+ # successful result.
13
+ def self.call(invoice:, results: DEFAULT_RESULT_FIELDS, config: IntacctRest.configuration, token_provider: nil)
14
+ unless invoice.is_a?(IntacctRest::Model::Invoice)
15
+ raise ArgumentError, 'invoice must be an IntacctRest::Model::Invoice'
16
+ end
17
+
18
+ result = IntacctRest::Post.call(invoice, config: config, token_provider: token_provider)
19
+ verify_expected_fields!(result, results) if result.success?
20
+ result
21
+ end
22
+
23
+ def self.verify_expected_fields!(result, expected_fields)
24
+ missing = expected_fields.reject { |field| result.respond_to?(field) }
25
+ return if missing.empty?
26
+
27
+ raise IntacctRest::ApiError.new(
28
+ "Expected result fields missing from response: #{missing.join(', ')}",
29
+ http_status: result.code, body: result.body
30
+ )
31
+ end
32
+ private_class_method :verify_expected_fields!
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Endpoints
5
+ class CreateInvoiceLine
6
+ DEFAULT_RESULT_FIELDS = %i[id key href].freeze
7
+
8
+ def self.call(invoice_line:, results: DEFAULT_RESULT_FIELDS, config: IntacctRest.configuration,
9
+ token_provider: nil)
10
+ unless invoice_line.is_a?(IntacctRest::Model::InvoiceLine)
11
+ raise ArgumentError, 'invoice_line must be an IntacctRest::Model::InvoiceLine'
12
+ end
13
+
14
+ result = IntacctRest::Post.call(invoice_line, config: config, token_provider: token_provider)
15
+ verify_expected_fields!(result, results) if result.success?
16
+ result
17
+ end
18
+
19
+ def self.verify_expected_fields!(result, expected_fields)
20
+ missing = expected_fields.reject { |field| result.respond_to?(field) }
21
+ return if missing.empty?
22
+
23
+ raise IntacctRest::ApiError.new(
24
+ "Expected result fields missing from response: #{missing.join(', ')}",
25
+ http_status: result.code, body: result.body
26
+ )
27
+ end
28
+ private_class_method :verify_expected_fields!
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Endpoints
5
+ class CreateTerm
6
+ DEFAULT_RESULT_FIELDS = %i[id key href].freeze
7
+
8
+ def self.call(term:, results: DEFAULT_RESULT_FIELDS, config: IntacctRest.configuration, token_provider: nil)
9
+ raise ArgumentError, 'term must be an IntacctRest::Model::Term' unless term.is_a?(IntacctRest::Model::Term)
10
+
11
+ result = IntacctRest::Post.call(term, config: config, token_provider: token_provider)
12
+ verify_expected_fields!(result, results) if result.success?
13
+ result
14
+ end
15
+
16
+ def self.verify_expected_fields!(result, expected_fields)
17
+ missing = expected_fields.reject { |field| result.respond_to?(field) }
18
+ return if missing.empty?
19
+
20
+ raise IntacctRest::ApiError.new(
21
+ "Expected result fields missing from response: #{missing.join(', ')}",
22
+ http_status: result.code, body: result.body
23
+ )
24
+ end
25
+ private_class_method :verify_expected_fields!
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Endpoints
5
+ class CreateVendor
6
+ DEFAULT_RESULT_FIELDS = %i[id key href].freeze
7
+
8
+ # results: fields this endpoint expects a successful response to
9
+ # contain — checked after the fact (raises ApiError if Intacct's
10
+ # response is missing one), not a filter on what gets written back.
11
+ # model.apply_result already decides what it absorbs from any
12
+ # successful result.
13
+ def self.call(vendor:, results: DEFAULT_RESULT_FIELDS, config: IntacctRest.configuration, token_provider: nil)
14
+ unless vendor.is_a?(IntacctRest::Model::Vendor)
15
+ raise ArgumentError, 'vendor must be an IntacctRest::Model::Vendor'
16
+ end
17
+
18
+ result = IntacctRest::Post.call(vendor, config: config, token_provider: token_provider)
19
+ verify_expected_fields!(result, results) if result.success?
20
+ result
21
+ end
22
+
23
+ def self.verify_expected_fields!(result, expected_fields)
24
+ missing = expected_fields.reject { |field| result.respond_to?(field) }
25
+ return if missing.empty?
26
+
27
+ raise IntacctRest::ApiError.new(
28
+ "Expected result fields missing from response: #{missing.join(', ')}",
29
+ http_status: result.code, body: result.body
30
+ )
31
+ end
32
+ private_class_method :verify_expected_fields!
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Endpoints
5
+ # PATCH /objects/accounts-payable/vendor/{key}. vendor.key picks the
6
+ # record; only the fields set on the model are sent, so anything left
7
+ # nil stays unchanged in Intacct.
8
+ class UpdateVendor
9
+ DEFAULT_RESULT_FIELDS = %i[id key href].freeze
10
+
11
+ # results: fields this endpoint expects a successful response to
12
+ # contain — checked after the fact (raises ApiError if Intacct's
13
+ # response is missing one), not a filter on what gets written back.
14
+ # model.apply_result already decides what it absorbs from any
15
+ # successful result.
16
+ def self.call(vendor:, results: DEFAULT_RESULT_FIELDS, config: IntacctRest.configuration, token_provider: nil)
17
+ unless vendor.is_a?(IntacctRest::Model::Vendor)
18
+ raise ArgumentError, 'vendor must be an IntacctRest::Model::Vendor'
19
+ end
20
+
21
+ result = IntacctRest::Patch.call(vendor, config: config, token_provider: token_provider)
22
+ verify_expected_fields!(result, results) if result.success?
23
+ result
24
+ end
25
+
26
+ def self.verify_expected_fields!(result, expected_fields)
27
+ missing = expected_fields.reject { |field| result.respond_to?(field) }
28
+ return if missing.empty?
29
+
30
+ raise IntacctRest::ApiError.new(
31
+ "Expected result fields missing from response: #{missing.join(', ')}",
32
+ http_status: result.code, body: result.body
33
+ )
34
+ end
35
+ private_class_method :verify_expected_fields!
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ # Lets Configuration#on_error see an error right before the gem raises it.
5
+ # Used only where an error originates (a failed request, an unparseable
6
+ # response, an unreadable schema file), so each error reaches the hook once.
7
+ #
8
+ # Including classes must expose a private `config` reader (same implicit
9
+ # contract as AuthenticatedRequest).
10
+ module ErrorReporting
11
+ private
12
+
13
+ # Raises error exactly as `raise error` would at the call site (same
14
+ # class, message, backtrace and cause), after passing it to on_error
15
+ # with context: — a Hash of safe scalars, never credentials or bodies.
16
+ def raise_reported(error, **context)
17
+ error.set_backtrace(caller) unless error.backtrace
18
+ raise error
19
+ rescue IntacctRest::Error
20
+ begin
21
+ config.on_error&.call(error, context: context)
22
+ rescue StandardError
23
+ nil # a failing hook must never replace the error the caller gets
24
+ end
25
+ raise
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ class Error < StandardError; end
5
+
6
+ class AuthenticationError < Error; end
7
+
8
+ class ResponseParseError < Error
9
+ attr_reader :raw_body
10
+
11
+ def initialize(message, raw_body: nil)
12
+ super(message)
13
+ @raw_body = raw_body
14
+ end
15
+ end
16
+
17
+ class ApiError < Error
18
+ attr_reader :http_status, :body
19
+
20
+ def initialize(message, http_status: nil, body: nil)
21
+ super(message)
22
+ @http_status = http_status
23
+ @body = body
24
+ end
25
+ end
26
+
27
+ class ValidationError < Error
28
+ attr_reader :attributes
29
+
30
+ def initialize(message, attributes: [])
31
+ super(message)
32
+ @attributes = attributes
33
+ end
34
+ end
35
+
36
+ class TooManyPagesError < Error
37
+ attr_reader :pages_fetched
38
+
39
+ def initialize(message, pages_fetched:)
40
+ super(message)
41
+ @pages_fetched = pages_fetched
42
+ end
43
+ end
44
+
45
+ class SchemaGenerationError < Error; end
46
+
47
+ class SchemaLoadError < Error; end
48
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ # Builds Intacct's REST query filter fragments. Intacct expects `filters`
5
+ # as an array of single-key hashes that are implicitly AND'd together.
6
+ module Filter
7
+ OPERATORS = {
8
+ equalto: '$eq',
9
+ notequalto: '$ne',
10
+ greaterthan: '$gt',
11
+ greaterthanorequalto: '$gte',
12
+ lessthan: '$lt',
13
+ lessthanorequalto: '$lte'
14
+ }.freeze
15
+
16
+ class << self
17
+ def eq(field, value) = operator(:equalto, field, value)
18
+ def not_eq(field, value) = operator(:notequalto, field, value)
19
+ def gt(field, value) = operator(:greaterthan, field, value)
20
+ def gte(field, value) = operator(:greaterthanorequalto, field, value)
21
+ def lt(field, value) = operator(:lessthan, field, value)
22
+ def lte(field, value) = operator(:lessthanorequalto, field, value)
23
+
24
+ # Filters are implicitly AND'd by Intacct — this just documents intent at call sites.
25
+ def and(*fragments) = fragments.flatten
26
+
27
+ private
28
+
29
+ def operator(name, field, value)
30
+ { OPERATORS.fetch(name) => { field.to_s => value } }
31
+ end
32
+ end
33
+ end
34
+ end