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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Model
5
+ # Shared by any IntacctRest::Model class: a small `validate` macro plus
6
+ # #errors/#valid?. Not tied to Vendor specifically — Customer, Bill,
7
+ # etc. can reuse this the same way.
8
+ #
9
+ # class Thing < IntacctRest::Model::Base
10
+ # attr_accessor :name
11
+ # validate :presence, [:name]
12
+ # end
13
+ class Base
14
+ class << self
15
+ # kind: a key registered in IntacctRest::Validators (:presence,
16
+ # :kind_of, :inclusion, ...). Trailing positional args before the
17
+ # final Array of attribute names are passed through to that
18
+ # validator (e.g. the type symbol for :kind_of, the list for
19
+ # :inclusion).
20
+ #
21
+ # on: an optional operation context (:create, :update, ...). A
22
+ # validation declared with on: only runs when #errors/#valid? is
23
+ # called with that same context; without on: it always runs.
24
+ def validate(kind, *args, on: nil)
25
+ attributes = Array(args.pop)
26
+ validators << { kind: kind, attributes: attributes, options: args, on: on }
27
+ end
28
+
29
+ def validators
30
+ @validators ||= superclass.respond_to?(:validators) ? superclass.validators.dup : []
31
+ end
32
+ end
33
+
34
+ def errors(context = nil)
35
+ self.class.validators.flat_map do |spec|
36
+ next [] unless spec[:on].nil? || spec[:on] == context
37
+
38
+ validator = IntacctRest::Validators.fetch(spec[:kind])
39
+ spec[:attributes].flat_map { |attribute| validator.call(self, attribute, *spec[:options]) }
40
+ end
41
+ end
42
+
43
+ def valid?(context = nil)
44
+ errors(context).empty?
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Model
5
+ # An AP bill's data and validations only — no config, no token
6
+ # provider, no HTTP. Build and inspect one freely; IntacctRest::Post
7
+ # (via IntacctRest::Endpoints::CreateBill) is what actually sends it.
8
+ #
9
+ # Nested objects (vendor, term, currency, lines, ...) are NOT
10
+ # individually modeled — pass them as raw Hashes using Intacct's
11
+ # native (camelCase) nested key names, e.g.:
12
+ #
13
+ # vendor: { "id" => "1099 Int" }
14
+ # lines: [{ "txnAmount" => "5", "glAccount" => { "id" => "6000" } }]
15
+ #
16
+ # Nested required fields (vendor.id, currency.txnCurrency,
17
+ # lines[].txnAmount/glAccount, lines[].dimensions.location, ...) are
18
+ # not validated client-side — only top-level presence/type is.
19
+ # Intacct's own 400 response is the source of truth for those, same
20
+ # boundary already accepted for Invoice/Customer/Vendor's nested
21
+ # objects.
22
+ class Bill < IntacctRest::Model::Base
23
+ INTACCT_OBJECT = '/objects/accounts-payable/bill'.freeze
24
+
25
+ WRITABLE_ATTRIBUTES = IntacctRest::Configuration::DEFAULT_BILL_WRITABLE_ATTRIBUTES
26
+
27
+ # Set by IntacctRest::Post after a successful create — writable here
28
+ # even though Intacct itself treats them as read-only.
29
+ READONLY_ATTRIBUTES = %i[
30
+ key id href module_name record_type due_in_days total_base_amount total_base_amount_due
31
+ total_txn_amount total_txn_amount_due is_system_generated purchasing_document
32
+ recurring_schedule e_invoice_status import_status import_error_message recipient_email
33
+ sender_email anomaly_code document_source source_module payment_information retainage
34
+ customer_refund entity web_url audit
35
+ ].freeze
36
+
37
+ attr_accessor(*WRITABLE_ATTRIBUTES.keys)
38
+ attr_accessor(*READONLY_ATTRIBUTES)
39
+ attr_reader :custom_fields
40
+
41
+ validate :presence, %i[due_date created_date]
42
+
43
+ validate :kind_of, :string, %i[
44
+ bill_number state reference_number description posting_date discount_cut_off_date
45
+ due_date recommended_payment_date created_date invoice_type invoice_mode
46
+ ]
47
+ validate :kind_of, :boolean, %i[is_on_hold is_tax_inclusive]
48
+ validate :kind_of, :hash, %i[
49
+ vendor term contacts currency tax_solution bill_summary bill_back_template attachment
50
+ dispute refuse
51
+ ]
52
+ validate :kind_of, :array, %i[lines]
53
+
54
+ # source: an arbitrary domain object (ActiveRecord record, OpenStruct,
55
+ # another Model::Bill, ...) to pull matching attributes off of via
56
+ # duck-typing — any WRITABLE_ATTRIBUTES/READONLY_ATTRIBUTES key it
57
+ # responds to is copied over. Explicit keyword attributes always win
58
+ # over whatever the source provided.
59
+ def initialize(source = nil, custom_fields: [], **attributes)
60
+ self.custom_fields = custom_fields
61
+ merged = source ? attributes_from(source).merge(attributes) : attributes
62
+ merged.each { |key, value| public_send(:"#{key}=", value) if respond_to?(:"#{key}=") }
63
+ end
64
+
65
+ def custom_fields=(value)
66
+ @custom_fields = normalize_custom_fields(value)
67
+ end
68
+
69
+ def intacct_object
70
+ self.class::INTACCT_OBJECT
71
+ end
72
+
73
+ # Ruby-side attributes (unmapped, no custom fields) — useful for
74
+ # logging/introspection without the camelCase JSON translation.
75
+ def attributes
76
+ WRITABLE_ATTRIBUTES.keys.each_with_object({}) { |attr, hash| hash[attr] = public_send(attr) }
77
+ end
78
+
79
+ def payload
80
+ WRITABLE_ATTRIBUTES.each_with_object({}) do |(attr, json_key), hash|
81
+ value = public_send(attr)
82
+ hash[json_key] = value unless value.nil?
83
+ end.merge(custom_fields_payload)
84
+ end
85
+
86
+ # Called by IntacctRest::Post after a successful create.
87
+ def apply_result(result)
88
+ data = result.result
89
+ self.key = data['key'] if data['key']
90
+ self.href = data['href'] if data['href']
91
+ self.id = data['id'] if data['id'] && id.nil?
92
+ end
93
+
94
+ private
95
+
96
+ def attributes_from(source)
97
+ (WRITABLE_ATTRIBUTES.keys + READONLY_ATTRIBUTES).each_with_object({}) do |attr, hash|
98
+ hash[attr] = source.public_send(attr) if source.respond_to?(attr)
99
+ end
100
+ end
101
+
102
+ def normalize_custom_fields(value)
103
+ case value
104
+ when Array
105
+ value.map { |v| v.is_a?(IntacctRest::CustomField) ? v : IntacctRest::CustomField.new(**v) }
106
+ when Hash
107
+ value.map { |name, val| IntacctRest::CustomField.new(name: name.to_s, value: val) }
108
+ when nil
109
+ []
110
+ else
111
+ raise ArgumentError, "Unsupported custom_fields value: #{value.class}"
112
+ end
113
+ end
114
+
115
+ def custom_fields_payload
116
+ custom_fields.each_with_object({}) { |field, hash| hash[field.key] = field.value }
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Model
5
+ # An AP bill 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::CreateBillLine) is what actually sends
8
+ # it.
9
+ #
10
+ # This is a standalone-creatable object (POST
11
+ # /objects/accounts-payable/bill-line) distinct from the raw line-item
12
+ # Hashes accepted by Model::Bill#lines — Intacct allows both: send
13
+ # lines inline with the bill, or create them separately against an
14
+ # existing bill via `bill: { "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 BillLine < IntacctRest::Model::Base
20
+ INTACCT_OBJECT = '/objects/accounts-payable/bill-line'.freeze
21
+
22
+ WRITABLE_ATTRIBUTES = IntacctRest::Configuration::DEFAULT_BILL_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.
26
+ READONLY_ATTRIBUTES = %i[
27
+ key id href line_number bill_amount base_amount currency payment_information
28
+ is_sub_total base_location retainage created_date audit
29
+ ].freeze
30
+
31
+ attr_accessor(*WRITABLE_ATTRIBUTES.keys)
32
+ attr_accessor(*READONLY_ATTRIBUTES)
33
+ attr_reader :custom_fields
34
+
35
+ validate :presence, %i[bill txn_amount gl_account]
36
+ validate :kind_of, :string, %i[txn_amount total_txn_amount memo has_form1099]
37
+ validate :kind_of, :boolean, %i[release_to_pay]
38
+ validate :kind_of, :hash, %i[
39
+ vendor gl_account override_offset_gl_account account_label allocation form1099 project
40
+ fixed_asset amortization_template purchasing dimensions bill
41
+ ]
42
+ validate :kind_of, :array, %i[tax_entries]
43
+
44
+ def initialize(source = nil, custom_fields: [], **attributes)
45
+ self.custom_fields = custom_fields
46
+ merged = source ? attributes_from(source).merge(attributes) : attributes
47
+ merged.each { |key, value| public_send(:"#{key}=", value) if respond_to?(:"#{key}=") }
48
+ end
49
+
50
+ def custom_fields=(value)
51
+ @custom_fields = normalize_custom_fields(value)
52
+ end
53
+
54
+ def intacct_object
55
+ self.class::INTACCT_OBJECT
56
+ end
57
+
58
+ def attributes
59
+ WRITABLE_ATTRIBUTES.keys.each_with_object({}) { |attr, hash| hash[attr] = public_send(attr) }
60
+ end
61
+
62
+ def payload
63
+ WRITABLE_ATTRIBUTES.each_with_object({}) do |(attr, json_key), hash|
64
+ value = public_send(attr)
65
+ hash[json_key] = value unless value.nil?
66
+ end.merge(custom_fields_payload)
67
+ end
68
+
69
+ # Called by IntacctRest::Post after a successful create.
70
+ def apply_result(result)
71
+ data = result.result
72
+ self.key = data['key'] if data['key']
73
+ self.href = data['href'] if data['href']
74
+ self.id = data['id'] if data['id'] && id.nil?
75
+ end
76
+
77
+ private
78
+
79
+ def attributes_from(source)
80
+ (WRITABLE_ATTRIBUTES.keys + READONLY_ATTRIBUTES).each_with_object({}) do |attr, hash|
81
+ hash[attr] = source.public_send(attr) if source.respond_to?(attr)
82
+ end
83
+ end
84
+
85
+ def normalize_custom_fields(value)
86
+ case value
87
+ when Array
88
+ value.map { |v| v.is_a?(IntacctRest::CustomField) ? v : IntacctRest::CustomField.new(**v) }
89
+ when Hash
90
+ value.map { |name, val| IntacctRest::CustomField.new(name: name.to_s, value: val) }
91
+ when nil
92
+ []
93
+ else
94
+ raise ArgumentError, "Unsupported custom_fields value: #{value.class}"
95
+ end
96
+ end
97
+
98
+ def custom_fields_payload
99
+ custom_fields.each_with_object({}) { |field, hash| hash[field.key] = field.value }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Model
5
+ # A contact's data and validations only — no config, no token
6
+ # provider, no HTTP, and no create endpoint of its own (Intacct has no
7
+ # standalone "create a contact" endpoint documented; contacts are
8
+ # referenced by id from vendor/customer's `contacts`/`contact_list`
9
+ # fields, which stay raw Hashes there). This is a small typed helper
10
+ # for building that nested payload with validation.
11
+ class Contact < IntacctRest::Model::Base
12
+ ATTRIBUTES = IntacctRest::Configuration::DEFAULT_CONTACT_ATTRIBUTES
13
+
14
+ attr_accessor(*ATTRIBUTES.keys)
15
+
16
+ validate :presence, %i[id]
17
+ validate :kind_of, :string, %i[id international_tax_id]
18
+ validate :kind_of, :boolean, %i[show_in_contact_list]
19
+ validate :kind_of, :hash, %i[tax electronic_invoice_details electronic_address]
20
+
21
+ # source: an arbitrary domain object (ActiveRecord record, OpenStruct,
22
+ # another Model::Contact, ...) to pull matching attributes off of via
23
+ # duck-typing. Explicit keyword attributes always win over whatever
24
+ # the source provided.
25
+ def initialize(source = nil, **attributes)
26
+ merged = source ? attributes_from(source).merge(attributes) : attributes
27
+ merged.each { |key, value| public_send(:"#{key}=", value) if respond_to?(:"#{key}=") }
28
+ end
29
+
30
+ # Ruby-side attributes (unmapped) — useful for logging/introspection
31
+ # without the camelCase JSON translation.
32
+ def attributes
33
+ ATTRIBUTES.keys.each_with_object({}) { |attr, hash| hash[attr] = public_send(attr) }
34
+ end
35
+
36
+ def payload
37
+ ATTRIBUTES.each_with_object({}) do |(attr, json_key), hash|
38
+ value = public_send(attr)
39
+ hash[json_key] = value unless value.nil?
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def attributes_from(source)
46
+ ATTRIBUTES.keys.each_with_object({}) do |attr, hash|
47
+ hash[attr] = source.public_send(attr) if source.respond_to?(attr)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Model
5
+ # Currency details nested inside an invoice header or invoice line
6
+ # (baseCurrency/txnCurrency/exchangeRate). Intacct has no standalone
7
+ # "create a currency" endpoint anywhere in its REST API — this exists
8
+ # purely as a small, validated way to build that nested Hash, e.g.:
9
+ #
10
+ # currency = IntacctRest::Model::Currency.new(txn_currency: "EUR")
11
+ # invoice.currency = currency.payload
12
+ #
13
+ # exchangeRate stays a raw Hash — not individually modeled.
14
+ class Currency < IntacctRest::Model::Base
15
+ ATTRIBUTES = IntacctRest::Configuration::DEFAULT_CURRENCY_ATTRIBUTES
16
+
17
+ attr_accessor(*ATTRIBUTES.keys)
18
+
19
+ validate :kind_of, :string, %i[base_currency txn_currency]
20
+ validate :kind_of, :hash, %i[exchange_rate]
21
+
22
+ # source: an arbitrary domain object to pull matching attributes off
23
+ # of via duck-typing. Explicit keyword attributes always win.
24
+ def initialize(source = nil, **attributes)
25
+ merged = source ? attributes_from(source).merge(attributes) : attributes
26
+ merged.each { |key, value| public_send(:"#{key}=", value) if respond_to?(:"#{key}=") }
27
+ end
28
+
29
+ def attributes
30
+ ATTRIBUTES.keys.each_with_object({}) { |attr, hash| hash[attr] = public_send(attr) }
31
+ end
32
+
33
+ # The camelCase Hash to embed into a parent's payload.
34
+ def payload
35
+ ATTRIBUTES.each_with_object({}) do |(attr, json_key), hash|
36
+ value = public_send(attr)
37
+ hash[json_key] = value unless value.nil?
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def attributes_from(source)
44
+ ATTRIBUTES.keys.each_with_object({}) do |attr, hash|
45
+ hash[attr] = source.public_send(attr) if source.respond_to?(attr)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Model
5
+ # A customer's data and validations only — no config, no token
6
+ # provider, no HTTP. Build and inspect one freely; IntacctRest::Post
7
+ # (via IntacctRest::Endpoints::CreateCustomer) is what actually sends it.
8
+ #
9
+ # Nested objects (contacts, term, vendor, ...) are NOT individually
10
+ # modeled — pass them as raw Hashes using Intacct's native (camelCase)
11
+ # nested key names, e.g. term: { "id" => "Net 15" }. `vendor` is how a
12
+ # customer references an associated vendor record, same raw-Hash
13
+ # pattern: vendor: { "id" => "V-00014" }.
14
+ class Customer < IntacctRest::Model::Base
15
+ INTACCT_OBJECT = '/objects/accounts-receivable/customer'.freeze
16
+
17
+ WRITABLE_ATTRIBUTES = IntacctRest::Configuration::DEFAULT_CUSTOMER_WRITABLE_ATTRIBUTES
18
+
19
+ # Set by IntacctRest::Post after a successful create — writable here
20
+ # even though Intacct itself treats them as read-only, since this is
21
+ # the only place that result gets recorded.
22
+ READONLY_ATTRIBUTES = %i[
23
+ key href total_due last_invoice_created_date last_statement_generated_date
24
+ subscription_end_date entity web_url audit
25
+ ].freeze
26
+
27
+ attr_accessor(*WRITABLE_ATTRIBUTES.keys)
28
+ attr_accessor(*READONLY_ATTRIBUTES)
29
+ attr_reader :custom_fields
30
+
31
+ validate :presence, %i[name]
32
+
33
+ validate :kind_of, :string, %i[
34
+ id name status tax_id discount_percent advance_bill_by advance_bill_by_type
35
+ customer_resale_number delivery_options override_price_list currency notes
36
+ customer_restriction
37
+ ]
38
+ validate :kind_of, :boolean, %i[is_on_hold email_opt_in is_one_time_use disable_refund]
39
+ validate :kind_of, :numeric, %i[credit_limit retainage_percentage]
40
+ validate :kind_of, :hash, %i[
41
+ customer_type parent sales_representative default_revenue_gl_account shipping_method
42
+ contacts restrictions term customer_message territory vendor account_group
43
+ account_label attachment price_list price_schedule override_offset_gl_account
44
+ customer_account_health
45
+ ]
46
+ validate :kind_of, :array, %i[
47
+ contact_list customer_email_templates restricted_locations restricted_departments
48
+ ]
49
+
50
+ # source: an arbitrary domain object (ActiveRecord record, OpenStruct,
51
+ # another Model::Customer, ...) to pull matching attributes off of via
52
+ # duck-typing — any WRITABLE_ATTRIBUTES/READONLY_ATTRIBUTES key it
53
+ # responds to is copied over. Explicit keyword attributes always win
54
+ # over whatever the source provided.
55
+ def initialize(source = nil, custom_fields: [], **attributes)
56
+ self.custom_fields = custom_fields
57
+ merged = source ? attributes_from(source).merge(attributes) : attributes
58
+ merged.each { |key, value| public_send(:"#{key}=", value) if respond_to?(:"#{key}=") }
59
+ end
60
+
61
+ def custom_fields=(value)
62
+ @custom_fields = normalize_custom_fields(value)
63
+ end
64
+
65
+ def intacct_object
66
+ self.class::INTACCT_OBJECT
67
+ end
68
+
69
+ # Ruby-side attributes (unmapped, no custom fields) — useful for
70
+ # logging/introspection without the camelCase JSON translation.
71
+ def attributes
72
+ WRITABLE_ATTRIBUTES.keys.each_with_object({}) { |attr, hash| hash[attr] = public_send(attr) }
73
+ end
74
+
75
+ def payload
76
+ WRITABLE_ATTRIBUTES.each_with_object({}) do |(attr, json_key), hash|
77
+ value = public_send(attr)
78
+ hash[json_key] = value unless value.nil?
79
+ end.merge(custom_fields_payload)
80
+ end
81
+
82
+ # Called by IntacctRest::Post after a successful create.
83
+ def apply_result(result)
84
+ data = result.result
85
+ self.key = data['key'] if data['key']
86
+ self.href = data['href'] if data['href']
87
+ self.id = data['id'] if data['id'] && id.nil?
88
+ end
89
+
90
+ private
91
+
92
+ def attributes_from(source)
93
+ (WRITABLE_ATTRIBUTES.keys + READONLY_ATTRIBUTES).each_with_object({}) do |attr, hash|
94
+ hash[attr] = source.public_send(attr) if source.respond_to?(attr)
95
+ end
96
+ end
97
+
98
+ def normalize_custom_fields(value)
99
+ case value
100
+ when Array
101
+ value.map { |v| v.is_a?(IntacctRest::CustomField) ? v : IntacctRest::CustomField.new(**v) }
102
+ when Hash
103
+ value.map { |name, val| IntacctRest::CustomField.new(name: name.to_s, value: val) }
104
+ when nil
105
+ []
106
+ else
107
+ raise ArgumentError, "Unsupported custom_fields value: #{value.class}"
108
+ end
109
+ end
110
+
111
+ def custom_fields_payload
112
+ custom_fields.each_with_object({}) { |field, hash| hash[field.key] = field.value }
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntacctRest
4
+ module Model
5
+ # An AR invoice's data and validations only — no config, no token
6
+ # provider, no HTTP. Build and inspect one freely; IntacctRest::Post
7
+ # (via IntacctRest::Endpoints::CreateInvoice) is what actually sends it.
8
+ #
9
+ # Nested objects (customer, lines, currency, term, ...) are NOT
10
+ # individually modeled — pass them as raw Hashes using Intacct's
11
+ # native (camelCase) nested key names, e.g.:
12
+ #
13
+ # customer: { "id" => "C-00019" }
14
+ # lines: [{ "txnAmount" => "100.40", "glAccount" => { "id" => "5004" } }]
15
+ #
16
+ # Nested required fields (customer.id, lines[].txnAmount, ...) are not
17
+ # validated client-side — only top-level presence/type is. Intacct's
18
+ # own 400 response is the source of truth for those, same boundary
19
+ # already accepted for Vendor's nested objects.
20
+ class Invoice < IntacctRest::Model::Base
21
+ INTACCT_OBJECT = '/objects/accounts-receivable/invoice'.freeze
22
+
23
+ WRITABLE_ATTRIBUTES = IntacctRest::Configuration::DEFAULT_INVOICE_WRITABLE_ATTRIBUTES
24
+
25
+ # Set by IntacctRest::Post after a successful create — writable here
26
+ # even though Intacct itself treats them as read-only, since this is
27
+ # the only place that result gets recorded. `href` is appended even
28
+ # though the invoice object schema omits it — the actual 201
29
+ # response includes it, same as every other object here.
30
+ READONLY_ATTRIBUTES = %i[
31
+ key id record_type module_key due_in_days sales_document discount_cut_off_date
32
+ total_base_amount total_base_amount_due total_txn_amount total_txn_amount_due
33
+ dunning_count recurring_schedule payment_information is_system_generated_document
34
+ e_invoice_status dispute refuse download_url allow_online_payment provider_payment
35
+ retainage project_contract project_contract_billing architect
36
+ project_contract_billing_invoice_summary entity web_url audit href
37
+ ].freeze
38
+
39
+ attr_accessor(*WRITABLE_ATTRIBUTES.keys)
40
+ attr_accessor(*READONLY_ATTRIBUTES)
41
+ attr_reader :custom_fields
42
+
43
+ validate :presence, %i[invoice_date due_date]
44
+
45
+ validate :kind_of, :string, %i[
46
+ invoice_number state reference_number description invoice_date due_date
47
+ invoice_type invoice_mode
48
+ ]
49
+ validate :kind_of, :hash, %i[
50
+ currency customer_message contacts billback_template attachment customer term
51
+ tax_solution invoice_summary
52
+ ]
53
+ validate :kind_of, :array, %i[lines]
54
+
55
+ # source: an arbitrary domain object (ActiveRecord record, OpenStruct,
56
+ # another Model::Invoice, ...) to pull matching attributes off of via
57
+ # duck-typing — any WRITABLE_ATTRIBUTES/READONLY_ATTRIBUTES key it
58
+ # responds to is copied over. Explicit keyword attributes always win
59
+ # over whatever the source provided.
60
+ def initialize(source = nil, custom_fields: [], **attributes)
61
+ self.custom_fields = custom_fields
62
+ merged = source ? attributes_from(source).merge(attributes) : attributes
63
+ merged.each { |key, value| public_send(:"#{key}=", value) if respond_to?(:"#{key}=") }
64
+ end
65
+
66
+ def custom_fields=(value)
67
+ @custom_fields = normalize_custom_fields(value)
68
+ end
69
+
70
+ def intacct_object
71
+ self.class::INTACCT_OBJECT
72
+ end
73
+
74
+ # Ruby-side attributes (unmapped, no custom fields) — useful for
75
+ # logging/introspection without the camelCase JSON translation.
76
+ def attributes
77
+ WRITABLE_ATTRIBUTES.keys.each_with_object({}) { |attr, hash| hash[attr] = public_send(attr) }
78
+ end
79
+
80
+ def payload
81
+ WRITABLE_ATTRIBUTES.each_with_object({}) do |(attr, json_key), hash|
82
+ value = public_send(attr)
83
+ hash[json_key] = value unless value.nil?
84
+ end.merge(custom_fields_payload)
85
+ end
86
+
87
+ # Called by IntacctRest::Post after a successful create.
88
+ def apply_result(result)
89
+ data = result.result
90
+ self.key = data['key'] if data['key']
91
+ self.href = data['href'] if data['href']
92
+ self.id = data['id'] if data['id'] && id.nil?
93
+ end
94
+
95
+ private
96
+
97
+ def attributes_from(source)
98
+ (WRITABLE_ATTRIBUTES.keys + READONLY_ATTRIBUTES).each_with_object({}) do |attr, hash|
99
+ hash[attr] = source.public_send(attr) if source.respond_to?(attr)
100
+ end
101
+ end
102
+
103
+ def normalize_custom_fields(value)
104
+ case value
105
+ when Array
106
+ value.map { |v| v.is_a?(IntacctRest::CustomField) ? v : IntacctRest::CustomField.new(**v) }
107
+ when Hash
108
+ value.map { |name, val| IntacctRest::CustomField.new(name: name.to_s, value: val) }
109
+ when nil
110
+ []
111
+ else
112
+ raise ArgumentError, "Unsupported custom_fields value: #{value.class}"
113
+ end
114
+ end
115
+
116
+ def custom_fields_payload
117
+ custom_fields.each_with_object({}) { |field, hash| hash[field.key] = field.value }
118
+ end
119
+ end
120
+ end
121
+ end