├── .gitignore ├── .ruby-version ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── circle.yml ├── lib ├── generators │ └── netsuite_rails │ │ ├── install_generator.rb │ │ └── templates │ │ └── create_netsuite_poll_timestamps.rb ├── netsuite_rails.rb └── netsuite_rails │ ├── configuration.rb │ ├── errors.rb │ ├── list_sync.rb │ ├── list_sync │ └── poll_manager.rb │ ├── netsuite_rails.rb │ ├── poll_timestamp.rb │ ├── poll_trigger.rb │ ├── record_sync.rb │ ├── record_sync │ ├── poll_manager.rb │ ├── pull_manager.rb │ └── push_manager.rb │ ├── routines │ └── company_contact_match.rb │ ├── spec │ ├── disabler.rb │ ├── query_helpers.rb │ └── spec_helper.rb │ ├── sub_list_sync.rb │ ├── sync_trigger.rb │ ├── tasks │ └── netsuite.rb │ ├── transformations.rb │ └── url_helper.rb ├── netsuite_rails.gemspec └── spec ├── models ├── configuration_spec.rb ├── poll_manager_spec.rb ├── poll_trigger_spec.rb ├── record_sync │ └── push_manager_spec.rb ├── record_sync_spec.rb ├── routines_spec.rb ├── spec_helper_spec.rb ├── sync_trigger_spec.rb ├── transformations_spec.rb └── url_helper_spec.rb ├── spec_helper.rb └── support ├── config └── database.yml ├── dynamic_models ├── class_builder.rb └── model_builder.rb ├── example_models.rb └── test_application.rb /.gitignore: -------------------------------------------------------------------------------- 1 | db/ 2 | tmp/ 3 | .bundle/ 4 | Gemfile.lock 5 | vendor/ 6 | *.gem 7 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.1.6 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | group :test do 5 | gem 'simplecov', :require => false 6 | 7 | gem 'faker' 8 | gem 'shoulda-matchers', '~> 2.8.0' 9 | gem 'rails', '3.2.16' 10 | gem 'sqlite3', :platform => :ruby 11 | 12 | gem 'rspec-rails', '~> 3.4.2' 13 | gem 'pry-nav' 14 | end 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Michael Bianco 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Circle CI](https://circleci.com/gh/NetSweet/netsuite_rails.svg?style=svg)](https://circleci.com/gh/NetSweet/netsuite_rails) 2 | [![Slack Status](https://opensuite-slackin.herokuapp.com/badge.svg)](http://opensuite-slackin.herokuapp.com) 3 | 4 | # NetSuite Rails 5 | 6 | **Note:** Documentation is horrible: PRs welcome. Look at the code for details. 7 | 8 | Build Ruby on Rails applications that sync ActiveRecord (ActiveModel and plain old ruby objects too) in real-time to NetSuite. Here's an example: 9 | 10 | ```ruby 11 | class Item < ActiveRecord::Base 12 | include NetSuiteRails::RecordSync 13 | 14 | # specify the NS record that your rails model maps to 15 | netsuite_record_class NetSuite::Records::InventoryItem 16 | 17 | netsuite_sync :read_write, 18 | # specify the frequency that your app should poll NetSuite for updates 19 | frequency: 1.day, 20 | # it's possible to base syncing off of a saved search. Be sure that "Internal ID" is one of your search result columns 21 | saved_search_id: 123, 22 | # limit pushing to NetSuite based on conditional 23 | if: -> { self.a_condition? }, 24 | # limit pulling from NetSuite based on conditional. This is only 25 | # considered when handling a single pull 26 | pull_if: -> { self.another_condition? }, 27 | 28 | # accepted values are :async and :sync. Default is :async 29 | mode: :sync 30 | 31 | 32 | # local => remote field mapping 33 | netsuite_field_map({ 34 | :item_number => :item_id, 35 | :name => :display_name, 36 | 37 | # the corresponding NetSuite field must be manually specified in before_netsuite_push 38 | :user => Proc.new do |local_rails_record, netsuite_record, sync_direction| 39 | if direction == :pull 40 | 41 | elsif direction == :push 42 | 43 | end 44 | end, 45 | 46 | :custom_field_list => { 47 | :a_local_field => :custrecord_remote_field 48 | :a_special_local_field => Proc.new do |local, ns_record, direction| 49 | if direction == :push 50 | # if proc is used with a field mapping, the field must be specified in `netsuite_manual_fields` 51 | ns_record.custom_field_list.custentity_special_long = 1 52 | ns_record.custom_field_list.custentity_special_long.type = 'platformCore:LongCustomFieldRef' 53 | end 54 | end 55 | } 56 | }) 57 | 58 | # sanitizes input from rails to ensure NS doesn't throw a fatal error 59 | netsuite_field_hints({ 60 | :phone => :phone, 61 | :email => :email 62 | }) 63 | 64 | before_netsuite_push do |netsuite_record| 65 | self.netsuite_manual_fields = [ :entity, :custom_field_list ] 66 | end 67 | end 68 | ``` 69 | 70 | Your ruby model: 71 | 72 | * Needs to have a `netsuite_id` and `netsuite_id=` method 73 | * Does not need to be an `ActiveRecord` model. If you don't use ActiveRecord it is your responsibility 74 | to trigger `Model#netsuite_push`. 75 | 76 | Notes: 77 | 78 | * If `sync_mode == :async` `model.save` will be run if a record is created referencing an existing NetSuite object: `model.create! netsuite_id: 123` 79 | * If you are using `update`, a `update` call will not be run if no changed fields are detected. If you are manually using fields specify them with `netsuite_manual_fields` 80 | 81 | ## Using Upsert 82 | 83 | TODO generating external ID tag 84 | 85 | TODO configuring upsert 86 | 87 | TODO add vs upsert consideration 88 | 89 | ## Installation 90 | 91 | ```ruby 92 | gem 'netsuite_rails' 93 | ``` 94 | 95 | Install the database migration to persist poll timestamps: 96 | 97 | ```bash 98 | rails g netsuite_rails:install 99 | ``` 100 | 101 | This helps netsuite_rails to know when the last time your rails DB was synced with the NS. 102 | 103 | ## Date 104 | 105 | 106 | ## Time 107 | 108 | "Time of Day" fields in NetSuite are especially tricky. To ensure that times don't shift when you push them to NetSuite here are some tips: 109 | 110 | 1. Take a look at the company time zone setup. This is in Setup 111 | 2. Ensure your WebService's Employee record has either: 112 | * No time zone set 113 | * The same time zone as the company 114 | 3. Ensure that the WebService's GUI preferences have the same time zone settings as the company. This effects how times are translated via SuiteTalk. 115 | 4. Set the `netsuite_instance_time_zone_offset` setting to your company's time zone 116 | 117 | ```ruby 118 | # set your timezone offset 119 | NetSuiteRails::Configuration.netsuite_instance_time_zone_offset(-6) 120 | ``` 121 | 122 | ### Changing WebService User's TimeZone Preferences 123 | 124 | It might take a couple hours for time zone changes to take effect. [From my experience](http://mikebian.co/netsuite-suitetalk-user-role-edits-are-delayed/), either the time zone changes have some delay associated with them or the time zone implementation is extremely buggy. 125 | 126 | ## Usage 127 | 128 | ### Syncing Options 129 | 130 | ``` 131 | netsuite_record_class NetSuite::Records::Customer 132 | netsuite_record_class NetSuite::Records::CustomRecord, 123 133 | 134 | netsuite_sync: :read 135 | netsuite_sync: :read_write 136 | # TODO not after_netsuite_push replacement for aggressive sync 137 | 138 | netsuite_sync: :read, frequency: :never 139 | netsuite_sync: :read, frequency: 5.minutes 140 | netsuite_sync: :read, if: -> { self.condition_met? } 141 | 142 | ``` 143 | 144 | When using a proc in a NS mapping, you are responsible for setting local and remote values 145 | 146 | The default sync frequency is [one day](https://github.com/NetSweet/netsuite_rails/blob/c453326a4190e68a2fd9d7690b2b1f2f105ec8b9/lib/netsuite_rails/poll_trigger.rb#L27). 147 | 148 | for pushing tasks to DJ https://github.com/collectiveidea/delayed_job/wiki/Rake-Task-as-a-Delayed-Job 149 | 150 | `:if` for controlling when syncing occurs 151 | 152 | Easily disable/enable syncing via env vars: 153 | 154 | ```ruby 155 | NetSuiteRails.configure do 156 | netsuite_pull_disabled ENV['NETSUITE_PULL_DISABLED'].present? && ENV['NETSUITE_PULL_DISABLED'] == "true" 157 | netsuite_push_disabled ENV['NETSUITE_PUSH_DISABLED'].present? && ENV['NETSUITE_PUSH_DISABLED'] == "true" 158 | 159 | if ENV['NETSUITE_DISABLE_SYNC'].present? && ENV['NETSUITE_DISABLE_SYNC'] == "true" 160 | netsuite_pull_disabled true 161 | netsuite_push_disabled true 162 | end 163 | end 164 | 165 | ``` 166 | 167 | ### Hooks 168 | 169 | ```ruby 170 | # the netsuite record is passed a single argument to this block (or method reference) 171 | # this provides the opportunity to set custom fields or run custom logic to prepare 172 | # the record for the NetSuite envoirnment 173 | before_netsuite_push 174 | after_netsuite_push 175 | 176 | # netsuite_pulling? is true when this callback is executed 177 | after_netsuite_pull 178 | ``` 179 | 180 | ### Rake Tasks for Syncing 181 | 182 | ```bash 183 | # update & create local records modified in netsuite sync the last sync time 184 | rake netsuite:sync 185 | 186 | # pull all records in NetSuite and update/create local records 187 | rake netsuite:fresh_sync 188 | 189 | # only update records that have already been synced 190 | rake netsuite:sync_local RECORD_MODELS=YourModel LIST_MODELS=YourListModel 191 | ``` 192 | 193 | Caveats: 194 | 195 | * If you have date time fields, or custom fields that will trigger `changed_attributes` this might cause issues when pulling an existing record 196 | * `changed_attributes` doesn't work well with `store`s 197 | 198 | ### Delayed Job 199 | 200 | The more records that use netsuite_rails, the longer you'll need your job timeout to be: 201 | 202 | ```ruby 203 | # config/initializers/delayed_job.rb 204 | Delayed::Worker.max_run_time = 80.minutes 205 | ``` 206 | 207 | ## Non-AR Backed Model 208 | 209 | Implement `changed_attributes` in your non-AR backed model 210 | 211 | ## Testing 212 | 213 | ```ruby 214 | # in spec_helper.rb 215 | require 'netsuite_rails/spec/spec_helper' 216 | ``` 217 | 218 | Using the helpers: 219 | 220 | ```ruby 221 | it 'does something' do 222 | # setup... 223 | 224 | # action 225 | 226 | ns_customer = get_last_netsuite_object(NetSuite::Records::Customer) 227 | end 228 | ``` 229 | 230 | # Syncing Using Rake Tasks 231 | 232 | ```ruby 233 | # clockwork.rb 234 | every(1.minutes, 'netsuite sync') { 235 | # prevent multiple netsuite:sync DJ commands from being added; only one is needed in the queue at a time 236 | unless Delayed::Job.where(failed_at: nil, locked_by: nil).detect { |j| j.payload_object.class == DelayedRake && j.payload_object.task == 'netsuite:sync'} 237 | Delayed::Job.enqueue DelayedRake.new("netsuite:sync") 238 | end 239 | } 240 | 241 | # schedule.rb 242 | # DelayedRake: https://github.com/collectiveidea/delayed_job/wiki/Rake-Task-as-a-Delayed-Job 243 | every 2.minutes do 244 | runner 'Delayed::Job.enqueue(DelayedRake.new("netsuite:sync"),priority:1,run_at: Time.now);' 245 | end 246 | ``` 247 | 248 | ## Author 249 | 250 | * Michael Bianco @iloveitaly 251 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | database: 2 | override: 3 | - echo "no database setup" 4 | -------------------------------------------------------------------------------- /lib/generators/netsuite_rails/install_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/migration' 3 | require 'rails/generators/active_record' 4 | 5 | module NetsuiteRails 6 | module Generators 7 | class InstallGenerator < Rails::Generators::Base 8 | # http://stackoverflow.com/questions/4141739/generators-and-migrations-in-plugins-rails-3 9 | 10 | if Rails::VERSION::STRING.start_with?('3.2') 11 | include Rails::Generators::Migration 12 | extend ActiveRecord::Generators::Migration 13 | else 14 | include ActiveRecord::Generators::Migration 15 | end 16 | 17 | source_root File.expand_path('../templates', __FILE__) 18 | 19 | def copy_migration 20 | migration_template "create_netsuite_poll_timestamps.rb", "db/migrate/create_netsuite_poll_timestamps.rb" 21 | end 22 | 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/generators/netsuite_rails/templates/create_netsuite_poll_timestamps.rb: -------------------------------------------------------------------------------- 1 | class CreateNetsuitePollTimestamps < ActiveRecord::Migration 2 | def change 3 | create_table :netsuite_poll_timestamps do |t| 4 | t.string :name, :limit => 100 5 | t.text :value 6 | t.string :key 7 | t.timestamps 8 | end 9 | 10 | add_index :netsuite_poll_timestamps, [:key], :name => 'index_netsuite_poll_timestamps_on_key', :unique => true 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/netsuite_rails.rb: -------------------------------------------------------------------------------- 1 | require 'netsuite_rails/netsuite_rails' -------------------------------------------------------------------------------- /lib/netsuite_rails/configuration.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module Configuration 3 | extend self 4 | 5 | NETSUITE_MAX_PAGE_SIZE = 1000 6 | 7 | def reset! 8 | attributes.clear 9 | end 10 | 11 | def attributes 12 | @attributes ||= {} 13 | end 14 | 15 | def netsuite_sync_mode(mode = nil) 16 | if mode.nil? 17 | attributes[:sync_mode] ||= :async 18 | else 19 | attributes[:sync_mode] = mode 20 | end 21 | end 22 | 23 | def netsuite_push_disabled(flag = nil) 24 | if flag.nil? 25 | attributes[:push_disabled] = false if attributes[:push_disabled].nil? 26 | attributes[:push_disabled] 27 | else 28 | attributes[:push_disabled] = flag 29 | end 30 | end 31 | 32 | def netsuite_pull_disabled(flag = nil) 33 | if flag.nil? 34 | attributes[:pull_disabled] = false if attributes[:pull_disabled].nil? 35 | attributes[:pull_disabled] 36 | else 37 | attributes[:pull_disabled] = flag 38 | end 39 | end 40 | 41 | def netsuite_instance_time_zone_offset(zone_offset = nil) 42 | if zone_offset.nil? 43 | attributes[:zone_offset] ||= -8 44 | else 45 | attributes[:zone_offset] = zone_offset 46 | end 47 | end 48 | 49 | def polling_page_size(size = nil) 50 | if size.nil? 51 | attributes[:size] ||= NETSUITE_MAX_PAGE_SIZE 52 | else 53 | attributes[:size] = size 54 | end 55 | end 56 | 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/netsuite_rails/errors.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | class Error < StandardError; end 3 | end 4 | -------------------------------------------------------------------------------- /lib/netsuite_rails/list_sync.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module ListSync 3 | 4 | def self.included(klass) 5 | klass.send(:extend, ClassMethods) 6 | 7 | PollTrigger.attach(klass) 8 | end 9 | 10 | module ClassMethods 11 | def netsuite_list_id(internal_id = nil) 12 | if internal_id.nil? 13 | @netsuite_list_id 14 | else 15 | @netsuite_list_id = internal_id 16 | end 17 | end 18 | 19 | def netsuite_poll(opts = {}) 20 | NetSuiteRails::ListSync::PollManager.poll(self, opts) 21 | end 22 | end 23 | 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/netsuite_rails/list_sync/poll_manager.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module ListSync 3 | module PollManager 4 | extend self 5 | 6 | def poll(klass, opts = {}) 7 | custom_list = NetSuite::Utilities.backoff { NetSuite::Records::CustomList.get(klass.netsuite_list_id) } 8 | 9 | process_results(klass, opts, custom_list.custom_value_list.custom_value) 10 | end 11 | 12 | def process_results(klass, opts, list) 13 | list.each do |custom_value| 14 | local_record = klass.where(netsuite_id: custom_value.attributes[:value_id]).first_or_initialize 15 | 16 | if local_record.respond_to?(:value=) 17 | local_record.value = custom_value.attributes[:value] 18 | end 19 | 20 | if local_record.respond_to?(:inactive=) 21 | local_record.inactive = custom_value.attributes[:is_inactive] 22 | end 23 | 24 | local_record.save! 25 | end 26 | end 27 | 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/netsuite_rails/netsuite_rails.rb: -------------------------------------------------------------------------------- 1 | require 'netsuite' 2 | 3 | require 'netsuite_rails/errors' 4 | require 'netsuite_rails/configuration' 5 | require 'netsuite_rails/poll_timestamp' if defined?(::ActiveRecord) 6 | require 'netsuite_rails/transformations' 7 | require 'netsuite_rails/url_helper' 8 | 9 | require 'netsuite_rails/poll_trigger' 10 | require 'netsuite_rails/sync_trigger' 11 | require 'netsuite_rails/sub_list_sync' 12 | 13 | require 'netsuite_rails/record_sync' 14 | require 'netsuite_rails/record_sync/poll_manager' 15 | require 'netsuite_rails/record_sync/pull_manager' 16 | require 'netsuite_rails/record_sync/push_manager' 17 | 18 | require 'netsuite_rails/routines/company_contact_match' 19 | 20 | require 'netsuite_rails/list_sync' 21 | require 'netsuite_rails/list_sync/poll_manager' 22 | 23 | module NetSuiteRails 24 | 25 | def self.rails4? 26 | ::Rails::VERSION::MAJOR >= 4 27 | end 28 | 29 | def self.configure_from_env(&block) 30 | self.configure do 31 | reset! 32 | 33 | netsuite_pull_disabled ENV['NETSUITE_PULL_DISABLED'].present? && ENV['NETSUITE_PULL_DISABLED'] == "true" 34 | netsuite_push_disabled ENV['NETSUITE_PUSH_DISABLED'].present? && ENV['NETSUITE_PUSH_DISABLED'] == "true" 35 | 36 | if ENV['NETSUITE_DISABLE_SYNC'].present? && ENV['NETSUITE_DISABLE_SYNC'] == "true" 37 | netsuite_pull_disabled true 38 | netsuite_push_disabled true 39 | end 40 | 41 | polling_page_size if ENV['NETSUITE_POLLING_PAGE_SIZE'].present? 42 | end 43 | 44 | self.configure(&block) if block 45 | end 46 | 47 | def self.configure(&block) 48 | NetSuiteRails::Configuration.instance_eval(&block) 49 | end 50 | 51 | class Railtie < ::Rails::Railtie 52 | rake_tasks do 53 | load 'netsuite_rails/tasks/netsuite.rb' 54 | end 55 | end 56 | 57 | end 58 | -------------------------------------------------------------------------------- /lib/netsuite_rails/poll_timestamp.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | class PollTimestamp < ActiveRecord::Base 3 | serialize :value 4 | 5 | validates :key, presence: true, uniqueness: true 6 | 7 | def self.for_class(klass) 8 | self.where(key: "netsuite_poll_#{klass.to_s.downcase}timestamp").first_or_initialize 9 | end 10 | 11 | def self.table_name_prefix 12 | 'netsuite_' 13 | end 14 | 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/netsuite_rails/poll_trigger.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | class PollTrigger 3 | 4 | class << self 5 | 6 | def attach(klass) 7 | @record_models ||= [] 8 | @list_models ||= [] 9 | 10 | if klass.include? RecordSync 11 | @record_models << klass 12 | elsif klass.include? ListSync 13 | @list_models << klass 14 | end 15 | end 16 | 17 | def sync(opts = {}) 18 | record_models = opts[:record_models] || @record_models 19 | list_models = opts[:list_models] || @list_models 20 | 21 | list_models.each do |klass| 22 | Rails.logger.info "NetSuite: Syncing #{klass}" 23 | klass.netsuite_poll 24 | end 25 | 26 | record_models.each do |klass| 27 | sync_frequency = klass.netsuite_sync_options[:frequency] || 1.day 28 | 29 | if sync_frequency == :never 30 | Rails.logger.info "NetSuite: Not syncing #{klass.to_s}" 31 | next 32 | end 33 | 34 | last_class_poll = PollTimestamp.for_class(klass) 35 | poll_execution_time = DateTime.now 36 | 37 | # check if we've never synced before 38 | if last_class_poll.new_record? 39 | Rails.logger.info "NetSuite: Syncing #{klass} for the first time" 40 | klass.netsuite_poll({ import_all: true }.merge(opts)) 41 | else 42 | # TODO look into removing the conditional parsing; I don't think this is needed 43 | last_poll_date = last_class_poll.value 44 | last_poll_date = DateTime.parse(last_poll_date) unless last_poll_date.is_a?(DateTime) 45 | 46 | if poll_execution_time.to_i - last_poll_date.to_i > sync_frequency 47 | Rails.logger.info "NetSuite: #{klass} is due to be synced, last checked #{last_poll_date}" 48 | klass.netsuite_poll({ last_poll: last_poll_date }.merge(opts)) 49 | else 50 | Rails.logger.info "NetSuite: Skipping #{klass} because of syncing frequency" 51 | next 52 | end 53 | end 54 | 55 | last_class_poll.value = poll_execution_time 56 | last_class_poll.save! 57 | end 58 | end 59 | 60 | def update_local_records(opts = {}) 61 | record_models = opts[:record_models] || @record_models 62 | list_models = opts[:list_models] || @list_models 63 | 64 | # TODO only records are supported right now 65 | # list_models.each do |klass| 66 | # Rails.logger.info "NetSuite: Syncing #{klass}" 67 | # klass.netsuite_poll 68 | # end 69 | 70 | record_models.each do |klass| 71 | NetSuiteRails::RecordSync::PollManager.update_local_records(klass, opts) 72 | end 73 | end 74 | 75 | end 76 | 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/netsuite_rails/record_sync.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module RecordSync 3 | 4 | def self.included(klass) 5 | klass.class_eval do 6 | class_attribute :netsuite_settings 7 | 8 | self.netsuite_settings = { 9 | before_netsuite_push: [], 10 | after_netsuite_push: [], 11 | after_netsuite_pull: [], 12 | 13 | netsuite_sync: :read, 14 | netsuite_field_map: {}, 15 | netsuite_field_hints: {}, 16 | netsuite_record_class: nil, 17 | } 18 | 19 | cattr_accessor :netsuite_custom_record_type_id 20 | cattr_accessor :netsuite_sync_options 21 | 22 | self.netsuite_sync_options = {} 23 | end 24 | 25 | klass.send(:extend, ClassMethods) 26 | klass.send(:include, InstanceMethods) 27 | 28 | SyncTrigger.attach(klass) 29 | PollTrigger.attach(klass) 30 | end 31 | 32 | module ClassMethods 33 | def netsuite_poll(opts = {}) 34 | RecordSync::PollManager.poll(self, opts) 35 | end 36 | 37 | attr_accessor :netsuite_custom_record_type_id 38 | attr_accessor :netsuite_sync_options 39 | 40 | # TODO is there a better way to implement callback chains? 41 | # https://github.com/rails/rails/blob/0c0f278ab20f3042cdb69604166e18a61f8605ad/activesupport/lib/active_support/callbacks.rb#L491 42 | 43 | def before_netsuite_push(callback = nil, &block) 44 | self.netsuite_settings[:before_netsuite_push] << (callback || block) if callback || block 45 | self.netsuite_settings[:before_netsuite_push] 46 | end 47 | 48 | def after_netsuite_push(callback = nil, &block) 49 | self.netsuite_settings[:after_netsuite_push] << (callback || block) if callback || block 50 | self.netsuite_settings[:after_netsuite_push] 51 | end 52 | 53 | def after_netsuite_pull(callback = nil, &block) 54 | self.netsuite_settings[:after_netsuite_pull] << (callback || block) if callback || block 55 | self.netsuite_settings[:after_netsuite_pull] 56 | end 57 | 58 | def netsuite_field_map(field_mapping = nil) 59 | if !field_mapping.nil? 60 | self.netsuite_settings[:netsuite_field_map] = field_mapping 61 | end 62 | 63 | self.netsuite_settings[:netsuite_field_map] 64 | end 65 | 66 | def netsuite_field_hints(list = nil) 67 | if !list.nil? 68 | self.netsuite_settings[:netsuite_field_hints] = list 69 | end 70 | 71 | self.netsuite_settings[:netsuite_field_hints] 72 | end 73 | 74 | # TODO persist type for CustomRecordRef 75 | def netsuite_record_class(record_class = nil, custom_record_type_id = nil) 76 | if !record_class.nil? 77 | self.netsuite_settings[:netsuite_record_class] = record_class 78 | self.netsuite_custom_record_type_id = custom_record_type_id 79 | end 80 | 81 | self.netsuite_settings[:netsuite_record_class] 82 | end 83 | 84 | # there is a model level of this method in order to be based on the model level record class 85 | def netsuite_custom_record? 86 | self.netsuite_record_class == NetSuite::Records::CustomRecord 87 | end 88 | 89 | # :read, :write_only, :read_write 90 | def netsuite_sync(flag = nil, opts = {}) 91 | if !flag.nil? 92 | self.netsuite_sync_options = opts 93 | self.netsuite_settings[:netsuite_sync] = flag 94 | end 95 | 96 | self.netsuite_settings[:netsuite_sync] 97 | end 98 | end 99 | 100 | module InstanceMethods 101 | attr_writer :netsuite_manual_fields 102 | 103 | def netsuite_manual_fields 104 | @netsuite_manual_fields ||= [] 105 | end 106 | 107 | # these methods are here for easy model override 108 | 109 | def netsuite_sync_options 110 | self.class.netsuite_sync_options 111 | end 112 | 113 | def netsuite_sync 114 | self.class.netsuite_sync 115 | end 116 | 117 | def netsuite_record_class 118 | self.class.netsuite_record_class 119 | end 120 | 121 | def netsuite_field_map 122 | self.class.netsuite_field_map 123 | end 124 | 125 | def netsuite_field_hints 126 | self.class.netsuite_field_hints 127 | end 128 | 129 | # assumes netsuite_id field on activerecord 130 | 131 | def netsuite_pulling? 132 | @netsuite_pulling ||= false 133 | end 134 | 135 | def netsuite_pulled? 136 | @netsuite_pulled ||= false 137 | end 138 | 139 | def netsuite_async_jobs? 140 | self.netsuite_sync_options[:sync_mode] == :async || (self.netsuite_sync_options[:sync_mode].blank? && NetSuiteRails::Configuration.netsuite_sync_mode == :async) 141 | end 142 | 143 | # TODO need to support the opts hash 144 | def netsuite_pull(opts = {}) 145 | netsuite_extract_from_record(netsuite_pull_record) 146 | 147 | if self.netsuite_async_jobs? 148 | # without callbacks? 149 | self.save 150 | end 151 | end 152 | 153 | def netsuite_pull_record 154 | # TODO support use_external_id / netsuite_external_id 155 | 156 | if netsuite_custom_record? 157 | NetSuite::Utilities.backoff { NetSuite::Records::CustomRecord.get( 158 | internal_id: self.netsuite_id, 159 | type_id: self.class.netsuite_custom_record_type_id 160 | ) } 161 | else 162 | NetSuite::Utilities.backoff { self.netsuite_record_class.get(self.netsuite_id) } 163 | end 164 | end 165 | 166 | def netsuite_push(opts = {}) 167 | NetSuiteRails::RecordSync::PushManager.push(self, opts) 168 | end 169 | 170 | # TODO move this login into separate service object 171 | def netsuite_extract_from_record(netsuite_record) 172 | Rails.logger.info "NetSuite: Pull #{netsuite_record.class} #{netsuite_record.internal_id}" 173 | 174 | @netsuite_pulling = true 175 | 176 | field_hints = self.netsuite_field_hints 177 | 178 | custom_field_list = self.netsuite_field_map[:custom_field_list] || {} 179 | 180 | all_field_list = self.netsuite_field_map.except(:custom_field_list) || {} 181 | all_field_list.merge!(custom_field_list) 182 | 183 | # TODO should have a helper module for common push/pull methods 184 | reflection_attributes = NetSuiteRails::RecordSync::PushManager.relationship_attributes_list(self) 185 | 186 | # handle non-collection associations 187 | association_keys = reflection_attributes.values.reject(&:collection?).map(&:name) 188 | 189 | all_field_list.each do |local_field, netsuite_field| 190 | is_custom_field = custom_field_list.keys.include?(local_field) 191 | 192 | if netsuite_field.is_a?(Proc) 193 | netsuite_field.call(self, netsuite_record, :pull) 194 | next 195 | end 196 | 197 | field_value = if is_custom_field 198 | netsuite_record.custom_field_list.send(netsuite_field).value rescue "" 199 | else 200 | netsuite_record.send(netsuite_field) 201 | end 202 | 203 | if field_value.nil? 204 | # TODO possibly nil out the local value? 205 | next 206 | end 207 | 208 | if association_keys.include?(local_field) 209 | field_value = reflection_attributes[local_field]. 210 | klass. 211 | where(netsuite_id: field_value.internal_id). 212 | first_or_initialize 213 | elsif is_custom_field 214 | field_value = NetSuiteRails::RecordSync::PullManager.extract_custom_field_value(field_value) 215 | else 216 | # then it's not a custom field 217 | end 218 | 219 | # TODO should we just check for nil? vs present? 220 | 221 | if field_hints.has_key?(local_field) && !field_value.nil? 222 | field_value = NetSuiteRails::Transformations.transform(field_hints[local_field], field_value, :pull) 223 | end 224 | 225 | self.send(:"#{local_field}=", field_value) 226 | end 227 | 228 | netsuite_execute_callbacks(self.class.after_netsuite_pull, netsuite_record) 229 | 230 | @netsuite_pulling = false 231 | @netsuite_pulled = true 232 | end 233 | 234 | 235 | def new_netsuite_record? 236 | self.netsuite_id.blank? 237 | end 238 | 239 | def netsuite_custom_record? 240 | self.netsuite_record_class == NetSuite::Records::CustomRecord 241 | end 242 | 243 | # TODO this should be protected; it needs to be pushed down to the Push/Pull manager level 244 | 245 | def netsuite_execute_callbacks(list, record) 246 | list.each do |callback| 247 | if callback.is_a?(Symbol) 248 | self.send(callback, record) 249 | else 250 | instance_exec(record, &callback) 251 | end 252 | end 253 | end 254 | 255 | end 256 | 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /lib/netsuite_rails/record_sync/poll_manager.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module RecordSync 3 | module PollManager 4 | extend self 5 | 6 | def update_local_records(klass, opts = {}) 7 | klass.select([:netsuite_id, :id]).find_in_batches(batch_size: NetSuiteRails::Configuration.polling_page_size) do |local_batch| 8 | netsuite_batch = if klass.netsuite_custom_record? 9 | NetSuite::Utilities.backoff { NetSuite::Records::CustomRecord.get_list( 10 | list: local_batch.map(&:netsuite_id), 11 | type_id: klass.netsuite_custom_record_type_id, 12 | allow_incomplete: true 13 | ) } 14 | else 15 | NetSuite::Utilities.backoff { klass.netsuite_record_class.get_list( 16 | list: local_batch.map(&:netsuite_id), 17 | allow_incomplete: true 18 | ) } 19 | end 20 | 21 | unless netsuite_batch 22 | raise NetSuiteRails::Error, "#{klass}. Error running NS search. No Netsuite batch found. Most likely a search timeout." 23 | end 24 | 25 | netsuite_batch.each do |netsuite_record| 26 | self.process_search_result_item(klass, opts, netsuite_record) 27 | end 28 | end 29 | end 30 | 31 | def poll(klass, opts = {}) 32 | opts = { 33 | import_all: false, 34 | }.merge(opts) 35 | 36 | opts[:netsuite_record_class] ||= klass.netsuite_record_class 37 | opts[:netsuite_custom_record_type_id] ||= klass.netsuite_custom_record_type_id if opts[:netsuite_record_class] == NetSuite::Records::CustomRecord 38 | opts[:saved_search_id] ||= klass.netsuite_sync_options[:saved_search_id] 39 | 40 | search = NetSuite::Utilities.backoff { opts[:netsuite_record_class].search( 41 | poll_criteria(klass, opts) 42 | ) } 43 | 44 | # TODO more robust error reporting 45 | unless search 46 | raise NetSuiteRails::Error, "#{klass}. Error running NS search. Most likely a search timeout." 47 | end 48 | 49 | process_search_results(klass, opts, search) 50 | end 51 | 52 | def poll_criteria(klass, opts) 53 | opts[:body_fields_only] ||= false 54 | opts[:page_size] ||= NetSuiteRails::Configuration.polling_page_size 55 | 56 | search_criteria = { 57 | criteria: { 58 | basic: poll_basic_criteria(klass, opts) 59 | }.merge(poll_join_criteria(klass, opts)) 60 | } 61 | 62 | if opts[:saved_search_id] 63 | search_criteria[:criteria][:saved] = opts[:saved_search_id] 64 | end 65 | 66 | if needs_get_list?(opts) 67 | search_criteria[:columns] = { 68 | 'listRel:basic' => [ 69 | 'platformCommon:internalId/' => {}, 70 | ], 71 | } 72 | end 73 | 74 | search_criteria.merge!({ 75 | preferences: { 76 | body_fields_only: opts[:body_fields_only], 77 | page_size: opts[:page_size] 78 | } 79 | }) 80 | 81 | search_criteria 82 | end 83 | 84 | def poll_basic_criteria(klass, opts) 85 | opts = { 86 | criteria: [], 87 | # last_poll: DateTime 88 | }.merge(opts) 89 | 90 | # allow custom criteria to be passed directly to the sync call 91 | criteria = extract_basic_search_criteria(opts[:criteria]) 92 | 93 | # allow custom criteria from the model level 94 | criteria += klass.netsuite_sync_options[:criteria] || [] 95 | 96 | if opts[:netsuite_record_class] == NetSuite::Records::CustomRecord 97 | opts[:netsuite_custom_record_type_id] ||= klass.netsuite_custom_record_type_id 98 | 99 | criteria << { 100 | field: 'recType', 101 | operator: 'is', 102 | value: NetSuite::Records::CustomRecordRef.new(internal_id: opts[:netsuite_custom_record_type_id]) 103 | } 104 | end 105 | 106 | # CustomRecordSearchBasic uses lastModified instead of the standard lastModifiedDate 107 | opts[:netsuite_poll_field] ||= (klass.netsuite_custom_record?) ? 'lastModified' : 'lastModifiedDate' 108 | 109 | # TODO investigate if defining a date range for `import_all` increases peformance 110 | 111 | unless opts[:import_all] 112 | if opts[:updated_before].present? 113 | criteria << { 114 | field: opts[:netsuite_poll_field], 115 | operator: 'within', 116 | type: 'SearchDateField', 117 | value: [ 118 | opts[:last_poll], 119 | opts[:updated_before] 120 | ] 121 | } 122 | else 123 | criteria << { 124 | field: opts[:netsuite_poll_field], 125 | operator: 'after', 126 | value: opts[:last_poll] 127 | } 128 | end 129 | end 130 | 131 | criteria 132 | end 133 | 134 | def poll_join_criteria(klass, opts) 135 | extract_advanced_search_criteria(opts[:criteria]) 136 | end 137 | 138 | def extract_advanced_search_criteria(criteria_option) 139 | if criteria_option.is_a?(Hash) 140 | criteria_option = criteria_option.dup 141 | criteria_option.delete(:basic) 142 | 143 | criteria_option 144 | elsif criteria_option.is_a?(Array) || criteria_option.nil? 145 | {} 146 | else 147 | # TODO unhandled criteria type 148 | end 149 | end 150 | 151 | def extract_basic_search_criteria(criteria_option) 152 | # TODO use `kind_of?` instead? 153 | 154 | if criteria_option.is_a?(Hash) 155 | criteria_option[:basic] || [] 156 | elsif criteria_option.is_a?(Array) 157 | criteria_option 158 | else 159 | # TODO unhandled criteria class 160 | end 161 | end 162 | 163 | def process_search_results(klass, opts, search) 164 | opts = { 165 | skip_existing: false, 166 | full_record_data: -1, 167 | }.merge(opts) 168 | 169 | # TODO need to improve the conditional here to match the get_list call conditional belo 170 | if opts[:import_all] && opts[:skip_existing] 171 | synced_netsuite_list = klass.pluck(:netsuite_id) 172 | end 173 | 174 | search.results_in_batches do |batch| 175 | Rails.logger.info "NetSuite: Syncing #{klass}. Current Page: #{search.current_page}. Processing #{search.total_records} over #{search.total_pages} pages." 176 | 177 | # a saved search is processed as a advanced search; advanced search often does not allow you to retrieve 178 | # all of the fields (ex: addressbooklist on customer) that a normal search does 179 | # the only way to get those fields is to pull down the full record again using getAll 180 | 181 | if needs_get_list?(opts) 182 | filtered_netsuite_id_list = batch.map(&:internal_id).map(&:to_i) 183 | 184 | if opts[:skip_existing] == true 185 | filtered_netsuite_id_list.reject! { |netsuite_id| synced_netsuite_list.include?(netsuite_id) } 186 | end 187 | 188 | if filtered_netsuite_id_list.present? 189 | Rails.logger.info "NetSuite: Syncing #{klass}. Running get_list for #{filtered_netsuite_id_list.length} records" 190 | 191 | if opts[:netsuite_record_class] == NetSuite::Records::CustomRecord 192 | NetSuite::Utilities.backoff { NetSuite::Records::CustomRecord.get_list( 193 | list: filtered_netsuite_id_list, 194 | type_id: opts[:netsuite_custom_record_type_id] 195 | ) } 196 | else 197 | NetSuite::Utilities.backoff { opts[:netsuite_record_class].get_list(list: filtered_netsuite_id_list) } 198 | end 199 | else 200 | [] 201 | end 202 | else 203 | batch 204 | end.each do |netsuite_record| 205 | self.process_search_result_item(klass, opts, netsuite_record) 206 | end 207 | end 208 | end 209 | 210 | def process_search_result_item(klass, opts, netsuite_record) 211 | local_record = klass.where(netsuite_id: netsuite_record.internal_id).first_or_initialize 212 | 213 | # when importing lots of records during an import_all skipping imported records is important 214 | return if opts[:skip_existing] == true && !local_record.new_record? 215 | 216 | local_record.netsuite_extract_from_record(netsuite_record) 217 | 218 | # TODO optionally throw fatal errors; we want to skip fatal errors on intial import 219 | 220 | unless local_record.save 221 | Rails.logger.error "NetSuite: Error pulling record #{klass} NS ID #{netsuite_record.internal_id} #{local_record.errors.full_messages}" 222 | end 223 | end 224 | 225 | def needs_get_list?(opts) 226 | (opts[:saved_search_id].present? && opts[:full_record_data] != false) || 227 | opts[:full_record_data] == true 228 | end 229 | 230 | # TODO this should remain in the pull manager 231 | 232 | def extract_custom_field_value(custom_field_value) 233 | if custom_field_value.present? && custom_field_value.is_a?(Hash) && custom_field_value.has_key?(:name) 234 | custom_field_value = custom_field_value[:name] 235 | end 236 | 237 | if custom_field_value.present? && custom_field_value.is_a?(NetSuite::Records::CustomRecordRef) 238 | custom_field_value = custom_field_value.attributes[:name] 239 | end 240 | 241 | custom_field_value 242 | end 243 | 244 | end 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /lib/netsuite_rails/record_sync/pull_manager.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module RecordSync 3 | module PullManager 4 | extend self 5 | 6 | # TODO pull relevant methods out of poll manager and into this class 7 | 8 | def extract_custom_field_value(custom_field_value) 9 | if custom_field_value.present? && custom_field_value.is_a?(Hash) && custom_field_value.has_key?(:name) 10 | custom_field_value = custom_field_value[:name] 11 | end 12 | 13 | if custom_field_value.present? && custom_field_value.is_a?(NetSuite::Records::CustomRecordRef) 14 | custom_field_value = custom_field_value.attributes[:name] 15 | end 16 | 17 | custom_field_value 18 | end 19 | 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/netsuite_rails/record_sync/push_manager.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module RecordSync 3 | 4 | class PushManager 5 | class << self 6 | 7 | def push(local_record, opts = {}) 8 | # TODO check to see if anything is changed before moving forward 9 | # if changes_keys.blank? && local_record.netsuite_manual_fields 10 | 11 | # always include the full netsuite field mapping, regardless of which 12 | # fields were modfified locally, when initially creating the netsuite record 13 | 14 | if opts[:modified_fields] && !local_record.new_netsuite_record? 15 | # if Array, we need to convert info fields hash based on the record definition 16 | if opts[:modified_fields].is_a?(Array) 17 | opts[:modified_fields] = all_netsuite_fields(local_record).select { |k,v| opts[:modified_fields].include?(k) } 18 | end 19 | else 20 | opts[:modified_fields] = modified_local_fields(local_record) 21 | end 22 | 23 | netsuite_record = build_netsuite_record(local_record, opts) 24 | 25 | local_record.netsuite_execute_callbacks(local_record.class.before_netsuite_push, netsuite_record) 26 | 27 | if opts[:push_method] == :upsert || local_record.new_netsuite_record? 28 | push_add(local_record, netsuite_record, opts) 29 | else 30 | push_update(local_record, netsuite_record, opts) 31 | end 32 | 33 | local_record.netsuite_execute_callbacks(local_record.class.after_netsuite_push, netsuite_record) 34 | 35 | true 36 | end 37 | 38 | def push_add(local_record, netsuite_record, opts = {}) 39 | # push_method is either :add or :upsert 40 | if netsuite_record.send(opts[:push_method] || :add) 41 | Rails.logger.info "NetSuite: action=#{opts[:push_method]}, local_record=#{local_record.class}[#{local_record.id}]" + 42 | "netsuite_record_type=#{netsuite_record.class}, netsuite_record_id=#{netsuite_record.internal_id}" 43 | 44 | if is_active_record_model?(local_record) 45 | # update_column to avoid triggering another save 46 | local_record.update_column(:netsuite_id, netsuite_record.internal_id) 47 | else 48 | netsuite_record.internal_id 49 | end 50 | else 51 | raise NetSuiteRails::Error, "action=#{opts[:push_method]}, netsuite_record_type=#{netsuite_record.class}, errors=#{netsuite_record.errors}" 52 | end 53 | end 54 | 55 | def push_update(local_record, netsuite_record, opts = {}) 56 | # build change hash to limit the number of fields pushed to NS on change 57 | # NS could have logic which could change field functionality depending on 58 | # input data; it's safest to limit the number of field changes pushed to NS 59 | 60 | # exclude fields that map to procs: they don't indicate which netsuite field 61 | # the local rails field maps to, so the user must specify this manually in `netsuite_manual_fields` 62 | 63 | # TODO add option for model to mark `custom_field_list = true` if custom field mapping to a 64 | # proc is detected. This is helpful for users mapping a local field to a custom field 65 | 66 | custom_field_list = local_record.netsuite_field_map[:custom_field_list] || {} 67 | custom_field_list = custom_field_list.select { |local_field, netsuite_field| !netsuite_field.is_a?(Proc) } 68 | 69 | modified_fields_list = opts[:modified_fields] 70 | modified_fields_list = modified_fields_list.select { |local_field, netsuite_field| !netsuite_field.is_a?(Proc) } 71 | 72 | update_list = {} 73 | 74 | modified_fields_list.each do |local_field, netsuite_field| 75 | if custom_field_list.keys.include?(local_field) 76 | # if custom field has changed, mark and copy over customFieldList later 77 | update_list[:custom_field_list] = true 78 | else 79 | update_list[netsuite_field] = netsuite_record.send(netsuite_field) 80 | end 81 | end 82 | 83 | # manual field list is for fields manually defined on the NS record 84 | # outside the context of ActiveRecord (e.g. in a before_netsuite_push) 85 | 86 | (local_record.netsuite_manual_fields || []).each do |netsuite_field| 87 | if netsuite_field == :custom_field_list 88 | update_list[:custom_field_list] = true 89 | else 90 | update_list[netsuite_field] = netsuite_record.send(netsuite_field) 91 | end 92 | end 93 | 94 | if update_list[:custom_field_list] 95 | update_list[:custom_field_list] = netsuite_record.custom_field_list 96 | end 97 | 98 | if local_record.netsuite_custom_record? 99 | update_list[:rec_type] = netsuite_record.rec_type 100 | end 101 | 102 | Rails.logger.info "NetSuite: Update #{netsuite_record.class} #{netsuite_record.internal_id}, list #{update_list.keys}" 103 | 104 | # don't update if list is empty 105 | return if update_list.empty? 106 | 107 | if netsuite_record.update(update_list) 108 | true 109 | else 110 | raise NetSuiteRails::Error, "error updating record #{netsuite_record.errors}" 111 | end 112 | end 113 | 114 | def build_netsuite_record(local_record, opts = {}) 115 | netsuite_record = build_netsuite_record_reference(local_record, opts) 116 | 117 | all_field_list = opts[:modified_fields] 118 | custom_field_list = local_record.netsuite_field_map[:custom_field_list] || {} 119 | field_hints = local_record.netsuite_field_hints 120 | 121 | reflections = relationship_attributes_list(local_record) 122 | 123 | all_field_list.each do |local_field, netsuite_field| 124 | # allow Procs as field mapping in the record definition for custom mapping 125 | if netsuite_field.is_a?(Proc) 126 | netsuite_field.call(local_record, netsuite_record, :push) 127 | next 128 | end 129 | 130 | # TODO pretty sure this will break if we are dealing with has_many 131 | 132 | netsuite_field_value = if reflections.has_key?(local_field) 133 | if (remote_internal_id = local_record.send(local_field).try(:netsuite_id)).present? 134 | { internal_id: remote_internal_id } 135 | else 136 | nil 137 | end 138 | else 139 | local_record.send(local_field) 140 | end 141 | 142 | if field_hints.has_key?(local_field) && netsuite_field_value.present? 143 | netsuite_field_value = NetSuiteRails::Transformations.transform(field_hints[local_field], netsuite_field_value, :push) 144 | end 145 | 146 | # TODO should we skip setting nil values completely? What if we want to nil out fields on update? 147 | 148 | # be wary of API version issues: https://github.com/NetSweet/netsuite/issues/61 149 | 150 | if custom_field_list.keys.include?(local_field) 151 | netsuite_record.custom_field_list.send(:"#{netsuite_field}=", netsuite_field_value) 152 | else 153 | netsuite_record.send(:"#{netsuite_field}=", netsuite_field_value) 154 | end 155 | end 156 | 157 | netsuite_record 158 | end 159 | 160 | def build_netsuite_record_reference(local_record, opts = {}) 161 | # must set internal_id for records on new; will be set to nil if new record 162 | 163 | init_hash = if opts[:use_external_id] 164 | { external_id: local_record.netsuite_external_id } 165 | else 166 | { internal_id: local_record.netsuite_id } 167 | end 168 | 169 | netsuite_record = local_record.netsuite_record_class.new(init_hash) 170 | 171 | if local_record.netsuite_custom_record? 172 | netsuite_record.rec_type = NetSuite::Records::CustomRecord.new(internal_id: local_record.class.netsuite_custom_record_type_id) 173 | end 174 | 175 | netsuite_record 176 | end 177 | 178 | def modified_local_fields(local_record) 179 | synced_netsuite_fields = all_netsuite_fields(local_record) 180 | 181 | changed_keys = if is_active_record_model?(local_record) 182 | changed_attributes(local_record) 183 | else 184 | local_record.changed_attributes 185 | end 186 | 187 | # filter out unchanged keys when updating record 188 | unless local_record.new_netsuite_record? 189 | synced_netsuite_fields.select! { |k,v| changed_keys.include?(k) } 190 | end 191 | 192 | synced_netsuite_fields 193 | end 194 | 195 | def all_netsuite_fields(local_record) 196 | custom_netsuite_field_list = local_record.netsuite_field_map[:custom_field_list] || {} 197 | standard_netsuite_field_list = local_record.netsuite_field_map.except(:custom_field_list) || {} 198 | 199 | custom_netsuite_field_list.merge(standard_netsuite_field_list) 200 | end 201 | 202 | def changed_attributes(local_record) 203 | # otherwise filter only by attributes that have been changed 204 | # limiting the delta sent to NS will reduce hitting edge cases 205 | 206 | # TODO think about has_many / join table changes 207 | 208 | reflections = relationship_attributes_list(local_record) 209 | 210 | association_field_key_mapping = reflections.values.reject(&:collection?).inject({}) do |h, a| 211 | begin 212 | h[a.association_foreign_key.to_sym] = a.name 213 | rescue Exception => e 214 | # occurs when `has_one through:` exists on a record but `through` is not a valid reference 215 | Rails.logger.error "NetSuite: error detecting foreign key #{a.name}" 216 | end 217 | 218 | h 219 | end 220 | 221 | changed_attributes_keys = local_record.changed_attributes.keys 222 | 223 | serialized_attrs = if NetSuiteRails.rails4? 224 | local_record.class.serialized_attributes 225 | else 226 | local_record.serialized_attributes 227 | end 228 | 229 | # changes_attributes does not track serialized attributes, although it does track the storage key 230 | # if a serialized attribute storage key is dirty assume that all keys in the hash are dirty as well 231 | 232 | changed_attributes_keys += serialized_attrs.keys.map do |k| 233 | local_record.send(k.to_sym).keys.map(&:to_s) 234 | end.flatten 235 | 236 | # convert relationship symbols from :object_id to :object 237 | changed_attributes_keys.map do |k| 238 | association_field_key_mapping[k.to_sym] || k.to_sym 239 | end 240 | end 241 | 242 | def relationship_attributes_list(local_record) 243 | if is_active_record_model?(local_record) 244 | if NetSuiteRails.rails4? 245 | local_record.class.reflections 246 | else 247 | local_record.reflections 248 | end 249 | else 250 | local_record.respond_to?(:reflections) ? local_record.reflections : {} 251 | end 252 | end 253 | 254 | def is_active_record_model?(local_record) 255 | defined?(::ActiveRecord::Base) && local_record.class.ancestors.include?(ActiveRecord::Base) 256 | end 257 | 258 | end 259 | end 260 | 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /lib/netsuite_rails/routines/company_contact_match.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module Routines 3 | module CompanyContactMatch 4 | extend self 5 | 6 | def match(company_customer, contact_data, update_contact_name: false, update_contact_email: false) 7 | search = NetSuite::Records::Contact.search({ 8 | customerJoin: [ 9 | { 10 | field: 'internalId', 11 | operator: 'anyOf', 12 | value: [ 13 | NetSuite::Records::Customer.new(internal_id: company_customer.internal_id) 14 | ] 15 | } 16 | ], 17 | 18 | preferences: { 19 | page_size: 1_000 20 | } 21 | }) 22 | 23 | match_data = { 24 | email: (contact_data[:email] || '').dup, 25 | first_name: (contact_data[:first_name] || '').dup, 26 | last_name: (contact_data[:last_name] || '').dup 27 | } 28 | 29 | match_data. 30 | values. 31 | each(&:strip!). 32 | each(&:downcase!) 33 | 34 | # TODO search error checking 35 | 36 | # try name match first; NS will throw an error if a contact is created or updated if the name already exists 37 | search.results.each do |contact| 38 | contact_first_name = contact.first_name.downcase.strip rescue '' 39 | contact_last_name = contact.last_name.downcase.strip rescue '' 40 | 41 | # if no email match & name data is present try fuzzy matching 42 | if match_data[:first_name] && match_data[:last_name] && !contact_first_name.empty? && !contact_last_name.empty? 43 | 44 | # TODO add logging for these interactions with NetSuite 45 | if update_contact_email && order_payload[:email].present? && contact.email != order_payload[:email] 46 | if !result.update(email: order_payload[:email]) 47 | raise NetSuiteRails::Error, "error updating email on contact" 48 | end 49 | end 50 | 51 | # TODO consider `self.fuzzy_name_matches?(contact_first_name, contact_last_name, match_data[:first_name], match_data[:last_name])` 52 | if contact_first_name == match_data[:first_name] && contact_last_name == match_data[:last_name] 53 | return contact 54 | end 55 | end 56 | end 57 | 58 | # try email match second 59 | search.results.each do |contact| 60 | contact_first_name = contact.first_name.downcase.strip rescue '' 61 | contact_last_name = contact.last_name.downcase.strip rescue '' 62 | 63 | # match on email 64 | if match_data[:email] && contact.email && contact.email.downcase.strip == match_data[:email] 65 | if match_data[:first_name] != contact_first_name || match_data[:last_name] != contact_last_name 66 | # first name and/or last name did not match the input, update contact information 67 | 68 | if update_contact_name 69 | result = contact.update( 70 | # use the first & last name from the payload; the match_data versions have been transformed 71 | first_name: order_payload[:shipping_address][:firstname], 72 | last_name: order_payload[:shipping_address][:lastname] 73 | ) 74 | 75 | unless result 76 | raise NetSuiteRails::Error, 'error updating name on contact placing order' 77 | end 78 | end 79 | end 80 | 81 | return contact 82 | end 83 | end 84 | 85 | nil 86 | end 87 | 88 | # TODO consider optionally using fuzzy name matches in the future 89 | # def fuzzy_name_matches?(first_name_1, last_name_1, first_name_2, last_name_2) 90 | # @fuzzy_comparison ||= FuzzyStringMatch::JaroWinkler.create 91 | 92 | # # Jarow-Winkler returns 1 for exact match 93 | # if @fuzzy_comparison.getDistance(last_name_1, last_name_2) > 0.90 94 | # # check for a match on the first name 95 | # if @fuzzy_comparison.getDistance(first_name_1, first_name_2) > 0.90 96 | # return true 97 | # end 98 | 99 | # # if fuzzy on first name failed; try to see if there are any nickname equivilents 100 | # if Monikers.equivalents?(first_name_1, first_name_2) 101 | # return true 102 | # end 103 | # end 104 | 105 | # false 106 | # end 107 | 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/netsuite_rails/spec/disabler.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module Spec 3 | module TestDisabler 4 | 5 | def disable_netsuite_communication 6 | before do 7 | @_push_disabled = NetSuiteRails::Configuration.netsuite_push_disabled 8 | @_pull_disabled = NetSuiteRails::Configuration.netsuite_pull_disabled 9 | 10 | NetSuiteRails::Configuration.netsuite_push_disabled true 11 | NetSuiteRails::Configuration.netsuite_pull_disabled true 12 | end 13 | 14 | after do 15 | NetSuiteRails::Configuration.netsuite_push_disabled @_push_disabled 16 | NetSuiteRails::Configuration.netsuite_pull_disabled @_pull_disabled 17 | end 18 | end 19 | 20 | 21 | end 22 | end 23 | end 24 | 25 | RSpec.configure do |config| 26 | config.extend NetSuiteRails::Spec::TestDisabler 27 | end -------------------------------------------------------------------------------- /lib/netsuite_rails/spec/query_helpers.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module Spec 3 | module QueryHelpers 4 | 5 | def self.included(base) 6 | base.before { netsuite_timestamp(DateTime.now) } 7 | end 8 | 9 | def netsuite_timestamp(stamp = nil) 10 | if stamp.nil? 11 | @netsuite_timestamp ||= (Time.now - (60 * 2)).to_datetime 12 | else 13 | @netsuite_timestamp = stamp 14 | end 15 | end 16 | 17 | def get_last_netsuite_object(record) 18 | # TODO support passing custom record ref 19 | 20 | if record.is_a?(Class) 21 | record_class = record 22 | is_custom_record = false 23 | else 24 | record_class = record.netsuite_record_class 25 | is_custom_record = record.netsuite_custom_record? 26 | end 27 | 28 | search = record_class.search({ 29 | criteria: { 30 | basic: 31 | ( 32 | if is_custom_record 33 | [ 34 | { 35 | field: 'recType', 36 | operator: 'is', 37 | value: NetSuite::Records::CustomRecordRef.new(internal_id: record.class.netsuite_custom_record_type_id) 38 | }, 39 | { 40 | field: 'lastModified', 41 | operator: 'after', 42 | value: netsuite_timestamp 43 | } 44 | ] 45 | else 46 | [ 47 | { 48 | field: 'lastModifiedDate', 49 | operator: 'after', 50 | value: netsuite_timestamp 51 | } 52 | ] 53 | end + 54 | 55 | if [ NetSuite::Records::SalesOrder, NetSuite::Records::ItemFulfillment, NetSuite::Records::Invoice ].include?(record_class) 56 | [ 57 | { 58 | field: 'type', 59 | operator: 'anyOf', 60 | value: [ '_' + record_class.name.demodulize.lower_camelcase ] 61 | } 62 | ] 63 | else 64 | [] 65 | end 66 | ) 67 | } 68 | }) 69 | 70 | return nil if search.results.blank? 71 | 72 | if is_custom_record 73 | NetSuite::Utilities.backoff { NetSuite::Records::CustomRecord.get( 74 | internal_id: search.results.first.internal_id.to_i, 75 | type_id: record.class.netsuite_custom_record_type_id 76 | ) } 77 | else 78 | NetSuite::Utilities.backoff { record_class.get(search.results.first.internal_id.to_i) } 79 | end 80 | end 81 | 82 | # convenience method for inspecting objects in a live IRB session 83 | def netsuite_url(object) 84 | `open "#{NetSuiteRails::UrlHelper.netsuite_url(object)}"` 85 | end 86 | 87 | end 88 | end 89 | end 90 | 91 | RSpec.configure do |config| 92 | config.include NetSuiteRails::Spec::QueryHelpers 93 | end 94 | -------------------------------------------------------------------------------- /lib/netsuite_rails/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'netsuite' 2 | require 'savon/mock/spec_helper' 3 | 4 | require 'netsuite_rails/spec/query_helpers' 5 | require 'netsuite_rails/spec/disabler' 6 | 7 | RSpec.configure do |config| 8 | config.include Savon::SpecHelper 9 | end 10 | -------------------------------------------------------------------------------- /lib/netsuite_rails/sub_list_sync.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module SubListSync 3 | 4 | def self.included(klass) 5 | klass.send(:extend, ClassMethods) 6 | 7 | NetSuiteRails::SyncTrigger.attach(klass) 8 | end 9 | 10 | # one issue here is that sublist items dont' have an internal ID until 11 | # they are created, but they are created in the context of a parent record 12 | 13 | # some sublists don't have an internal ID at all, from the docs: 14 | # "...non-keyed sublists contain no referencing keys (or handles)" 15 | # "...Instead, you must interact with the sublist as a whole. 16 | # In non-keyed sublists, the replaceAll attribute is ignored and behaves as if 17 | # it were set to TRUE for all requests. Consequently, an update operation is 18 | # similar to the add operation with respect to non-keyed sublists." 19 | 20 | module ClassMethods 21 | def netsuite_sublist_parent(parent = nil) 22 | if parent.nil? 23 | @netsuite_sublist_parent 24 | else 25 | @netsuite_sublist_parent = parent 26 | end 27 | end 28 | end 29 | 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/netsuite_rails/sync_trigger.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module SyncTrigger 3 | extend self 4 | 5 | # TODO think about a flag to push to NS on after_validation vs after_commit 6 | # TODO think about background async record syncing (re: multiple sales order updates) 7 | # TODO need to add hook for custom proc to determine if data should be pushed to netsuite 8 | # if a model has a pending/complete state we might want to only push on complete 9 | 10 | def attach(klass) 11 | # don't attach to non-AR backed models 12 | # it is the user's responsibility to trigger `Model#netsuite_push` when ActiveRecord isn't used 13 | return if !defined?(::ActiveRecord) || !klass.ancestors.include?(::ActiveRecord::Base) 14 | 15 | if klass.include?(SubListSync) 16 | klass.after_save { SyncTrigger.sublist_trigger(self) } 17 | klass.after_destroy { SyncTrigger.sublist_trigger(self) } 18 | elsif klass.include?(RecordSync) 19 | 20 | # during the initial pull we don't want to push changes up 21 | klass.before_save do 22 | @netsuite_sync_record_import = self.new_record? && self.netsuite_id.present? 23 | 24 | if @netsuite_sync_record_import 25 | # pull the record down if it has't been pulled yet 26 | # this is useful when this is triggered by a save on a parent record which has this 27 | # record as a related record 28 | 29 | if !self.netsuite_pulled? && !self.netsuite_async_jobs? 30 | SyncTrigger.record_pull_trigger(self) 31 | end 32 | end 33 | 34 | # if false record will not save 35 | true 36 | end 37 | 38 | klass.after_save do 39 | # this conditional is implemented as a save hook 40 | # because the coordination class doesn't know about model persistence state 41 | 42 | if @netsuite_sync_record_import 43 | if !self.netsuite_pulled? && self.netsuite_async_jobs? 44 | SyncTrigger.record_pull_trigger(self) 45 | end 46 | else 47 | SyncTrigger.record_push_trigger(self) 48 | end 49 | 50 | @netsuite_sync_record_import = false 51 | end 52 | end 53 | 54 | # TODO think on NetSuiteRails::ListSync 55 | end 56 | 57 | def record_pull_trigger(local) 58 | return if NetSuiteRails::Configuration.netsuite_pull_disabled 59 | 60 | sync_options = local.netsuite_sync_options 61 | 62 | return if sync_options.has_key?(:pull_if) && !local.instance_exec(&sync_options[:pull_if]) 63 | 64 | record_trigger_action(local, :netsuite_pull) 65 | end 66 | 67 | def record_push_trigger(local) 68 | # don't update when fields are updated because of a netsuite_pull 69 | if local.netsuite_pulling? 70 | Rails.logger.info "NetSuite: Push Stopped. Record is pulling. " + 71 | "local_record=#{local.class}, local_record_id=#{local.id}" 72 | return 73 | end 74 | 75 | return if NetSuiteRails::Configuration.netsuite_push_disabled 76 | 77 | # don't update if a read only record 78 | return if local.netsuite_sync == :read 79 | 80 | sync_options = local.netsuite_sync_options 81 | 82 | # TODO should migrate to push_if for better self-documentation 83 | # :if option is a block that returns a boolean 84 | return if sync_options.has_key?(:if) && !local.instance_exec(&sync_options[:if]) 85 | 86 | record_trigger_action(local, :netsuite_push) 87 | end 88 | 89 | def record_trigger_action(local, action) 90 | sync_options = local.netsuite_sync_options 91 | 92 | action_options = { 93 | 94 | } 95 | 96 | if sync_options.has_key?(:credentials) 97 | action_options[:credentials] = local.instance_exec(&sync_options[:credentials]) 98 | end 99 | 100 | # TODO need to pass off the credentials to the NS push command 101 | 102 | # You can force sync mode in different envoirnments with the global configuration variables 103 | 104 | if !local.netsuite_async_jobs? 105 | local.send(action, action_options) 106 | else 107 | action_options[:modified_fields] = NetSuiteRails::RecordSync::PushManager.modified_local_fields(local).keys 108 | 109 | # TODO support ActiveJob 110 | 111 | if local.respond_to?(:delay) 112 | local.delay.send(action, action_options) 113 | else 114 | raise NetSuiteRails::Error, 'no supported delayed job method found' 115 | end 116 | end 117 | end 118 | 119 | def sublist_trigger(sublist_item_rep) 120 | # TODO don't trigger a push if the parent record is still pulling 121 | # often sublists are managed in a after_pull hook; we want to prevent auto-pushing 122 | # if sublist records are being updated. However, the netsuite_pulling? state is not persisted 123 | # so there is no gaurentee that it isn't being pulled by checking parent.netsuite_pulling? 124 | 125 | parent = sublist_item_rep.send(sublist_item_rep.class.netsuite_sublist_parent) 126 | 127 | if parent.class.include?(RecordSync) 128 | record_push_trigger(parent) 129 | end 130 | end 131 | 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/netsuite_rails/tasks/netsuite.rb: -------------------------------------------------------------------------------- 1 | namespace :netsuite do 2 | 3 | def generate_options 4 | opts = { 5 | skip_existing: ENV['SKIP_EXISTING'].present? && ENV['SKIP_EXISTING'] == "true" 6 | } 7 | 8 | if !ENV['RECORD_MODELS'].nil? 9 | opts[:record_models] = ENV['RECORD_MODELS'].split(',').map(&:constantize) 10 | end 11 | 12 | if !ENV['LIST_MODELS'].nil? 13 | opts[:list_models] = ENV['LIST_MODELS'].split(',').map(&:constantize) 14 | end 15 | 16 | # field values might change on import because of remote data structure changes 17 | # stop all pushes on sync & fresh_sync to avoid pushing up data that really hasn't 18 | # changed for each record 19 | 20 | # TODO make push disabled configurable 21 | NetSuiteRails::Configuration.netsuite_push_disabled true 22 | 23 | opts 24 | end 25 | 26 | desc "Sync all NetSuite records using import_all" 27 | task :fresh_sync => :environment do 28 | if ENV['SKIP_EXISTING'].blank? 29 | ENV['SKIP_EXISTING'] = "true" 30 | end 31 | 32 | opts = generate_options 33 | opts[:record_models].each do |record_model| 34 | NetSuiteRails::PollTimestamp.for_class(record_model).delete 35 | end 36 | 37 | Rake::Task["netsuite:sync"].invoke 38 | end 39 | 40 | desc "sync all netsuite records" 41 | task :sync => :environment do 42 | # need to eager load to ensure that all classes are loaded into the poll manager 43 | Rails.application.eager_load! 44 | 45 | NetSuiteRails::PollTrigger.sync(generate_options) 46 | end 47 | 48 | desc "sync all local netsuite records" 49 | task :sync_local => :environment do 50 | NetSuiteRails::PollTrigger.update_local_records(generate_options) 51 | end 52 | 53 | task field_usage_report: :environment do |t| 54 | Rails.application.eager_load! 55 | 56 | NetSuiteRails::PollTrigger.instance_variable_get('@record_models').each do |record_model| 57 | puts record_model.to_s 58 | 59 | # TODO add the ability to document which fields 60 | 61 | standard_fields = record_model.netsuite_field_map.values - [record_model.netsuite_field_map[:custom_field_list]] 62 | custom_fields = record_model.netsuite_field_map[:custom_field_list].values 63 | 64 | standard_fields.reject! { |f| f.is_a?(Proc) } 65 | custom_fields.reject! { |f| f.is_a?(Proc) } 66 | 67 | if custom_fields.present? 68 | puts "Custom Fields: #{custom_fields.join(', ')}" 69 | end 70 | 71 | if standard_fields.present? 72 | puts "Standard Fields: #{standard_fields.join(', ')}" 73 | end 74 | 75 | puts "" 76 | end 77 | end 78 | 79 | end 80 | -------------------------------------------------------------------------------- /lib/netsuite_rails/transformations.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module Transformations 3 | class << self 4 | 5 | def transform(type, value, direction) 6 | self.send(type, value, direction) 7 | end 8 | 9 | def memo(memo, direction = :push) 10 | if direction == :push 11 | memo[0..999] 12 | else 13 | memo 14 | end 15 | end 16 | 17 | # gift certificate codes have a maximum of 9 characters 18 | def gift_card_code(code, direction = :push) 19 | if direction == :push 20 | code[0..8] 21 | else 22 | code 23 | end 24 | end 25 | 26 | # company_name field has a 83 character limit 27 | def company_name(company_name, direction = :push) 28 | if direction == :push 29 | company_name[0..82] 30 | else 31 | company_name 32 | end 33 | end 34 | 35 | # NS limits firstname fields to 33 characters 36 | def firstname(firstname, direction = :push) 37 | if direction == :push 38 | firstname[0..33] 39 | else 40 | firstname 41 | end 42 | end 43 | 44 | # same limitations as firstname 45 | def lastname(lastname, direction = :push) 46 | firstname(lastname, direction) 47 | end 48 | 49 | def integer(number, direction = :push) 50 | number.to_i 51 | end 52 | 53 | # TODO consider adding precision? 54 | def float(number, direction = :push) 55 | number.to_f 56 | end 57 | 58 | def phone(phone, direction = :push) 59 | if direction == :push 60 | return nil if phone.nil? 61 | 62 | formatted_phone = phone. 63 | strip. 64 | gsub(/ext(ension)?/, 'x'). 65 | # remove anything that isn't a extension indicator or a number 66 | gsub(/[^0-9x]/, ''). 67 | # if the first part of the phone # is 10 characters long and starts with a 1 the 22 char error is thrown 68 | gsub(/^1([0-9]{10})/, '\1') 69 | 70 | # eliminate the extension if the number is still too long 71 | formatted_phone.gsub!(/x.*$/, '') if formatted_phone.size > 22 72 | 73 | # phone numbers less than 7 digits throw a fatal error 74 | if formatted_phone.size < 7 75 | return nil 76 | end 77 | 78 | formatted_phone 79 | else 80 | phone 81 | end 82 | end 83 | 84 | # NS will throw an error if whitespace bumpers the email string 85 | def email(email, direction = :push) 86 | if direction == :push 87 | # any whitespace will cause netsuite to throw a fatal error 88 | email = email.gsub(' ', '') 89 | 90 | # TODO consider throwing an exception instead of returning nil? 91 | # netsuite will throw a fatal error if a valid email address is not sent 92 | # http://stackoverflow.com/questions/742451/what-is-the-simplest-regular-expression-to-validate-emails-to-not-accept-them-bl 93 | if email !~ /.+@.+\..+/ 94 | return nil 95 | end 96 | 97 | email = email. 98 | # an error will be thrown if period is on the end of a sentence 99 | gsub(/[^A-Za-z]+$/, ''). 100 | # any commas in the email with throw an error 101 | gsub(',', ''). 102 | # double periods anywhere in the email cause issues 103 | gsub('..', '.'). 104 | # a period before the @ seems to cause issues 105 | gsub('.@', '@'). 106 | # backslashes in an email cause issues 107 | gsub("\\", '') 108 | 109 | while email.count('@') > 1 110 | email.sub!('@', '') 111 | end 112 | 113 | if email.split('@').last.include?('&') 114 | pieces = email.split('@') 115 | first, last = pieces.first, pieces.last 116 | last = last.sub('&', '') 117 | email = [first, last].join('@') 118 | end 119 | 120 | email 121 | else 122 | email 123 | end 124 | end 125 | 126 | # https://www.reinteractive.net/posts/168-dealing-with-timezones-effectively-in-rails 127 | # http://stackoverflow.com/questions/16818180/ruby-rails-how-do-i-change-the-timezone-of-a-time-without-changing-the-time 128 | # http://alwayscoding.ca/momentos/2013/08/22/handling-dates-and-timezones-in-ruby-and-rails/ 129 | 130 | def date(date, direction = :push) 131 | if direction == :push 132 | # setting the hour to noon eliminates the chance that some strange timezone offset 133 | # shifting would cause the date to drift into the next or previous day 134 | date.to_datetime.change(offset: "-07:00", hour: 12) 135 | else 136 | date.change(offset: Time.zone.formatted_offset) 137 | end 138 | end 139 | 140 | def datetime(datetime, direction = :push) 141 | if direction == :push 142 | datetime.change(offset: "-08:00", year: 1970, day: 01, month: 01) - (8 + NetSuiteRails::Configuration.netsuite_instance_time_zone_offset).hours 143 | else 144 | datetime = datetime.change(offset: Time.zone.formatted_offset) + (8 + NetSuiteRails::Configuration.netsuite_instance_time_zone_offset).hours 145 | datetime 146 | end 147 | end 148 | 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/netsuite_rails/url_helper.rb: -------------------------------------------------------------------------------- 1 | module NetSuiteRails 2 | module UrlHelper 3 | 4 | # TODO create a xxx_netsuite_url helper generator 5 | 6 | def self.netsuite_url(record = self) 7 | domain = NetSuite::Configuration.wsdl_domain.sub('webservices.', 'system.') 8 | prefix = "https://#{domain}/app" 9 | 10 | if record.class.to_s.start_with?('NetSuite::Records') 11 | record_class = record.class 12 | internal_id = record.internal_id 13 | is_custom_record = false 14 | else 15 | record_class = record.netsuite_record_class 16 | internal_id = record.netsuite_id 17 | is_custom_record = record.netsuite_custom_record? 18 | end 19 | 20 | # TODO support NS classes, should jump right to the list for the class 21 | 22 | # https://system.sandbox.netsuite.com/app/common/scripting/scriptrecordlist.nl 23 | # https://system.sandbox.netsuite.com/app/common/scripting/script.nl 24 | 25 | # dependent record links 26 | # https://system.na1.netsuite.com/core/pages/itemchildrecords.nl?id=12413&t=InvtItem%05ProjectCostCategory&rectype=-10 27 | # https://system.na1.netsuite.com/app/accounting/transactions/payments.nl?id=91964&label=Customer+Refund&type=custrfnd&alllinks=T 28 | 29 | # tax schedule: https://system.na1.netsuite.com/app/common/item/taxschedule.nl?id=1 30 | 31 | suffix = if is_custom_record 32 | "/common/custom/custrecordentry.nl?id=#{internal_id}&rectype=#{record.class.netsuite_custom_record_type_id}" 33 | elsif [ 34 | NetSuite::Records::InventoryItem, 35 | NetSuite::Records::NonInventorySaleItem, 36 | NetSuite::Records::AssemblyItem, 37 | NetSuite::Records::ServiceSaleItem, 38 | NetSuite::Records::DiscountItem, 39 | ].include?(record_class) 40 | "/common/item/item.nl?id=#{internal_id}" 41 | elsif record_class == NetSuite::Records::Task 42 | "/crm/calendar/task.nl?id=#{internal_id}" 43 | elsif record_class == NetSuite::Records::Roles 44 | "/setup/role.nl?id=#{internal_id}" 45 | elsif [ 46 | NetSuite::Records::Contact, 47 | NetSuite::Records::Customer, 48 | NetSuite::Records::Vendor, 49 | NetSuite::Records::Partner, 50 | NetSuite::Records::Employee 51 | ].include?(record_class) 52 | "/common/entity/entity.nl?id=#{internal_id}" 53 | elsif [ 54 | NetSuite::Records::SalesOrder, 55 | NetSuite::Records::Invoice, 56 | NetSuite::Records::CustomerRefund, 57 | NetSuite::Records::CashSale, 58 | NetSuite::Records::CashRefund, 59 | NetSuite::Records::ItemFulfillment, 60 | NetSuite::Records::CustomerDeposit, 61 | NetSuite::Records::CustomerPayment, 62 | NetSuite::Records::CreditMemo, 63 | NetSuite::Records::JournalEntry, 64 | NetSuite::Records::Deposit 65 | ].include?(record_class) 66 | "/accounting/transactions/transaction.nl?id=#{internal_id}" 67 | elsif NetSuite::Records::Account == record_class 68 | "/accounting/account/account.nl?id=#{internal_id}" 69 | elsif NetSuite::Records::Subsidiary == record_class 70 | "/common/otherlists/subsidiarytype.nl?id=#{internal_id}" 71 | elsif NetSuite::Records::PaymentMethod == record_class 72 | "/app/common/otherlists/accountingotherlist.nl?id=#{internal_id}" 73 | else 74 | # TODO unsupported record type error? 75 | end 76 | 77 | prefix + suffix 78 | end 79 | 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /netsuite_rails.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "netsuite_rails" 7 | s.version = "0.3.6" 8 | s.authors = ["Michael Bianco"] 9 | s.email = ["mike@cliffsidemedia.com"] 10 | s.summary = %q{Write Rails applications that integrate with NetSuite} 11 | s.homepage = "http://github.com/netsweet/netsuite_rails" 12 | s.license = "MIT" 13 | 14 | s.files = `git ls-files -z`.split("\x0") 15 | s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } 16 | s.test_files = s.files.grep(%r{^(test|spec|features)/}) 17 | s.require_paths = ["lib"] 18 | 19 | s.add_dependency 'netsuite', '>= 0.8.0' 20 | s.add_dependency 'rails', '>= 3.2.16' 21 | 22 | s.add_development_dependency "bundler", "~> 1.6" 23 | s.add_development_dependency "rake" 24 | s.add_development_dependency "rspec", '~> 3.1' 25 | end 26 | -------------------------------------------------------------------------------- /spec/models/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe NetSuiteRails::Configuration do 4 | it 'should disable netsuite push and pull' do 5 | NetSuiteRails::Configuration.netsuite_push_disabled true 6 | NetSuiteRails::Configuration.netsuite_pull_disabled false 7 | 8 | expect(NetSuiteRails::Configuration.netsuite_pull_disabled).to eq(false) 9 | expect(NetSuiteRails::Configuration.netsuite_push_disabled).to eq(true) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/models/poll_manager_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe NetSuiteRails::RecordSync::PollManager do 4 | include ExampleModels 5 | 6 | # TODO fake a couple items in the list 7 | 8 | let(:empty_search_results) { OpenStruct.new(results: [ OpenStruct.new(internal_id: 0) ]) } 9 | 10 | describe 'polling ranges' do 11 | let(:updated_after) { DateTime.new(2016, 01, 01) } 12 | let(:updated_before) { DateTime.new(2016, 01, 02) } 13 | 14 | it 'does not restrict poll by date if import_all is specified' do 15 | expect(NetSuite::Records::Customer).to receive(:search) 16 | .with(hash_including( 17 | criteria: hash_including( 18 | basic: [] 19 | ) 20 | )) 21 | .and_return(empty_search_results) 22 | 23 | StandardRecord.netsuite_poll(import_all: true) 24 | end 25 | 26 | it 'restricts polling within a range' do 27 | expect(NetSuite::Records::Customer).to receive(:search) 28 | .with(hash_including( 29 | criteria: hash_including( 30 | basic: array_including( 31 | { 32 | field: 'lastModifiedDate', 33 | type: 'SearchDateField', 34 | operator: 'within', 35 | value: [ 36 | updated_after, 37 | updated_before 38 | ] 39 | } 40 | ) 41 | ) 42 | )) 43 | .and_return(empty_search_results) 44 | 45 | StandardRecord.netsuite_poll( 46 | last_poll: updated_after, 47 | updated_before: updated_before 48 | ) 49 | end 50 | 51 | it 'restricts polling after a date' do 52 | expect(NetSuite::Records::Customer).to receive(:search) 53 | .with(hash_including( 54 | criteria: hash_including( 55 | basic: array_including( 56 | { 57 | field: 'lastModifiedDate', 58 | operator: 'after', 59 | value: updated_after 60 | } 61 | ) 62 | ) 63 | )) 64 | .and_return(empty_search_results) 65 | 66 | StandardRecord.netsuite_poll(last_poll: updated_after) 67 | end 68 | 69 | it 'allows the polling field to be customized' do 70 | expect(NetSuite::Records::Customer).to receive(:search) 71 | .with(hash_including( 72 | criteria: hash_including( 73 | basic: array_including( 74 | { 75 | field: 'lastQuantityAvailableChange', 76 | operator: 'after', 77 | value: updated_after 78 | } 79 | ) 80 | ) 81 | )) 82 | .and_return(empty_search_results) 83 | 84 | StandardRecord.netsuite_poll( 85 | last_poll: updated_after, 86 | netsuite_poll_field: 'lastQuantityAvailableChange' 87 | ) 88 | end 89 | end 90 | 91 | it 'allows search preferences to be customized' do 92 | expect(NetSuite::Records::Customer).to receive(:search) 93 | .with(hash_including( 94 | preferences: hash_including( 95 | body_fields_only: true, 96 | page_size: 13 97 | ) 98 | )) 99 | .and_return(empty_search_results) 100 | 101 | StandardRecord.netsuite_poll( 102 | import_all: true, 103 | body_fields_only: true, 104 | page_size: 13 105 | ) 106 | end 107 | 108 | skip "should poll and then get_list on saved search" do 109 | # TODO SS enabled record 110 | # TODO mock search to return one result 111 | # TODO mock out get_list 112 | end 113 | 114 | it "should poll list sync objects" do 115 | allow(NetSuite::Records::CustomList).to receive(:get) 116 | .and_return(OpenStruct.new(custom_value_list: OpenStruct.new(custom_value: []))) 117 | 118 | StandardList.netsuite_poll(import_all: true) 119 | 120 | expect(NetSuite::Records::CustomList).to have_received(:get) 121 | end 122 | 123 | it "should sync only available local records" do 124 | # disabling NS pull since this is treated as a record import (NS ID = exists, new_record? == true) 125 | NetSuiteRails::Configuration.netsuite_pull_disabled true 126 | StandardRecord.create! netsuite_id: 123 127 | NetSuiteRails::Configuration.netsuite_pull_disabled false 128 | 129 | allow(NetSuite::Records::Customer).to receive(:get_list).and_return([OpenStruct.new(internal_id: 123)]) 130 | allow(NetSuiteRails::RecordSync::PollManager).to receive(:process_search_result_item) 131 | 132 | NetSuiteRails::PollTrigger.update_local_records 133 | 134 | expect(NetSuite::Records::Customer).to have_received(:get_list) 135 | end 136 | 137 | it 'runs netsuite_pull on a newly created record with a netsuite_id defined' do 138 | record = StandardRecord.new netsuite_id: 123 139 | allow(record).to receive(:netsuite_pull) 140 | 141 | record.save! 142 | 143 | expect(record).to have_received(:netsuite_pull) 144 | end 145 | 146 | describe 'custom search criteria' do 147 | let(:basic_search_field) do 148 | { 149 | field: 'type', 150 | operator: 'anyOf', 151 | value: [ '_itemFulfillment' ] 152 | } 153 | end 154 | 155 | let(:advanced_search_criteria) do 156 | { 157 | createdFromJoin: [ 158 | { 159 | field: 'type', 160 | operator: 'anyOf', 161 | value: [ '_transferOrder' ] 162 | } 163 | ] 164 | } 165 | end 166 | 167 | it 'should merge basic search criteria' do 168 | expect(NetSuite::Records::Customer).to receive(:search) 169 | .with(hash_including( 170 | criteria: hash_including( 171 | basic: array_including( 172 | basic_search_field 173 | ) 174 | ) 175 | )) 176 | .and_return(OpenStruct.new(results: [])) 177 | 178 | NetSuiteRails::RecordSync::PollManager.poll(StandardRecord, 179 | criteria: [ basic_search_field ] 180 | ) 181 | end 182 | 183 | it 'merges advanced and basic criteria' do 184 | expect(NetSuite::Records::Customer).to receive(:search) 185 | .with(hash_including( 186 | criteria: { 187 | basic: array_including( 188 | basic_search_field 189 | ) 190 | }.merge(advanced_search_criteria) 191 | )) 192 | .and_return(OpenStruct.new(results: [])) 193 | 194 | NetSuiteRails::RecordSync::PollManager.poll(StandardRecord, 195 | criteria: { 196 | basic: [ basic_search_field ] 197 | }.merge(advanced_search_criteria) 198 | ) 199 | end 200 | 201 | it 'should merge join/advanced criteria' do 202 | expect(NetSuite::Records::Customer).to receive(:search) 203 | .with(hash_including(criteria: hash_including(advanced_search_criteria))) 204 | .and_return(OpenStruct.new(results: [])) 205 | 206 | NetSuiteRails::RecordSync::PollManager.poll(StandardRecord, 207 | criteria: advanced_search_criteria 208 | ) 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /spec/models/poll_trigger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe NetSuiteRails::PollTrigger do 4 | include ExampleModels 5 | 6 | it "should properly sync for the first time" do 7 | allow(NetSuiteRails::RecordSync::PollManager).to receive(:poll) 8 | 9 | NetSuiteRails::PollTrigger.sync list_models: [], record_models: [ StandardRecord ] 10 | 11 | expect(NetSuiteRails::RecordSync::PollManager).to have_received(:poll) 12 | end 13 | 14 | it "should trigger syncing is triggered from the model when time passed is greater than frequency" do 15 | allow(StandardRecord).to receive(:netsuite_poll) 16 | allow(StandardRecord.netsuite_record_class).to receive(:search).and_return(OpenStruct.new(results: [])) 17 | 18 | StandardRecord.netsuite_sync_options[:frequency] = 5.minutes 19 | 20 | timestamp = NetSuiteRails::PollTimestamp.for_class(StandardRecord) 21 | timestamp.value = DateTime.now - 6.minutes 22 | timestamp.save! 23 | 24 | NetSuiteRails::PollTrigger.sync list_models: [], record_models: [ StandardRecord ] 25 | 26 | expect(StandardRecord).to have_received(:netsuite_poll) 27 | end 28 | 29 | it 'should not change the poll timestamp when sync does not occur' do 30 | allow(StandardRecord).to receive(:netsuite_poll) 31 | 32 | StandardRecord.netsuite_sync_options[:frequency] = 5.minutes 33 | 34 | last_timestamp = DateTime.now - 3.minutes 35 | 36 | timestamp = NetSuiteRails::PollTimestamp.for_class(StandardRecord) 37 | timestamp.value = last_timestamp 38 | timestamp.save! 39 | 40 | NetSuiteRails::PollTrigger.sync list_models: [], record_models: [ StandardRecord ] 41 | 42 | expect(StandardRecord).to_not have_received(:netsuite_poll) 43 | timestamp = NetSuiteRails::PollTimestamp.find(timestamp.id) 44 | expect(timestamp.value).to eq(last_timestamp) 45 | end 46 | end -------------------------------------------------------------------------------- /spec/models/record_sync/push_manager_spec.rb: -------------------------------------------------------------------------------- 1 | describe NetSuiteRails::RecordSync::PushManager do 2 | include ExampleModels 3 | 4 | it 'should handle a modified field with a Proc instead of a netsuite field key' do 5 | record = StandardRecord.new netsuite_id: 234 6 | allow(record).to receive(:new_netsuite_record?).and_return(false) 7 | 8 | ns_record = record.netsuite_record_class.new 9 | 10 | NetSuiteRails::RecordSync::PushManager.push(record, { modified_fields: [ :company ] }) 11 | end 12 | 13 | it 'should ignore modified fields if the record has not yet been pushed to NetSuite' do 14 | record = StandardRecord.new 15 | 16 | expect(NetSuiteRails::RecordSync::PushManager).to receive(:push_add).once 17 | expect(NetSuiteRails::RecordSync::PushManager).to receive(:build_netsuite_record).with(instance_of(StandardRecord), hash_including({ 18 | :modified_fields => hash_including(:phone, :company) 19 | })) 20 | 21 | NetSuiteRails::RecordSync::PushManager.push(record, { modified_fields: [ :company ] }) 22 | end 23 | 24 | context "AR" do 25 | xit "should look at the NS ID of a has_one relationship on the record sync model" 26 | 27 | xit "should properly determine the changed attributes" 28 | end 29 | 30 | context "not AR" do 31 | xit "should execute properly for a simple active model class" 32 | 33 | end 34 | 35 | context 'record building' do 36 | it "should properly handle custom records" do 37 | custom = CustomRecord.new netsuite_id: 234 38 | record = NetSuiteRails::RecordSync::PushManager.build_netsuite_record_reference(custom) 39 | 40 | expect(record.internal_id).to eq(234) 41 | expect(record.rec_type.internal_id).to eq(123) 42 | end 43 | 44 | it "should properly handle records using external ID" do 45 | local = ExternalIdRecord.new(netsuite_id: 123, phone: "234") 46 | record = NetSuiteRails::RecordSync::PushManager.build_netsuite_record_reference(local, { use_external_id: true }) 47 | 48 | expect(record.external_id).to eq(local.netsuite_external_id) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/models/record_sync_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe NetSuiteRails::RecordSync do 4 | include ExampleModels 5 | 6 | context 'custom records' do 7 | it "should properly pull the NS rep" do 8 | allow(NetSuite::Records::CustomRecord).to receive(:get).with(hash_including(:internal_id => 234, type_id: 123)) 9 | 10 | custom_record = CustomRecord.new netsuite_id: 234 11 | custom_record.netsuite_pull_record 12 | 13 | expect(NetSuite::Records::CustomRecord).to have_received(:get) 14 | end 15 | end 16 | 17 | it 'properly extracts common record types' do 18 | fake_customer_data = { 19 | :is_inactive => false, 20 | :phone => "123 456 7891", 21 | :company_name => "Great Company", 22 | :email => nil 23 | } 24 | 25 | expect(NetSuite::Records::Customer).to receive(:get) 26 | .and_return(NetSuite::Records::Customer.new(fake_customer_data)) 27 | 28 | standard_record = StandardRecord.new netsuite_id: 123 29 | standard_record.netsuite_pull 30 | 31 | expect(standard_record.is_deleted).to eq(false) 32 | expect(standard_record.phone).to eq(fake_customer_data[:phone]) 33 | expect(standard_record.company).to eq(fake_customer_data[:company_name]) 34 | expect(standard_record.email).to eq(fake_customer_data[:email]) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/models/routines_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # TODO https://github.com/NetSweet/netsuite_rails/issues/16 4 | # there are still some unresolved issues with NS datetime/date conversions 5 | # the tests + implementation may still not be correct. 6 | 7 | describe NetSuiteRails::Routines do 8 | describe '#company_contact_match' do 9 | it "matches on first and last name first" do 10 | 11 | end 12 | 13 | it "matches on email if no name match is found" do 14 | 15 | end 16 | 17 | it "returns nil if no match is found" do 18 | 19 | end 20 | 21 | # TODO also handle updating contact information 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/models/spec_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'netsuite_rails/spec/spec_helper' 4 | 5 | describe 'netsuite_rails test helpers' do 6 | include ExampleModels 7 | 8 | let(:fake_search_results) { OpenStruct.new(results: [ OpenStruct.new(internal_id: 0) ]) } 9 | 10 | before do 11 | allow(NetSuite::Records::Customer).to receive(:search).and_return(fake_search_results) 12 | allow(NetSuite::Records::Customer).to receive(:get) 13 | end 14 | 15 | it "should accept a standard NS gem object" do 16 | get_last_netsuite_object(NetSuite::Records::Customer) 17 | 18 | expect(NetSuite::Records::Customer).to have_received(:search) 19 | expect(NetSuite::Records::Customer).to have_received(:get) 20 | end 21 | 22 | it "should accept a record sync enabled object" do 23 | get_last_netsuite_object(StandardRecord.new) 24 | 25 | expect(NetSuite::Records::Customer).to have_received(:search) 26 | expect(NetSuite::Records::Customer).to have_received(:get) 27 | end 28 | end -------------------------------------------------------------------------------- /spec/models/sync_trigger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe NetSuiteRails::SyncTrigger do 4 | include ExampleModels 5 | 6 | before do 7 | allow(NetSuiteRails::RecordSync::PushManager).to receive(:push_add) 8 | allow(NetSuiteRails::RecordSync::PushManager).to receive(:push_update) 9 | end 10 | 11 | context 'push' do 12 | it "should push new record when saved" do 13 | s = StandardRecord.new 14 | s.phone = Faker::PhoneNumber.phone_number 15 | s.save! 16 | 17 | expect(NetSuiteRails::RecordSync::PushManager).to have_received(:push_add) 18 | expect(NetSuiteRails::RecordSync::PushManager).to_not have_received(:push_update) 19 | end 20 | 21 | it "should not push update on a pull record" do 22 | s = StandardRecord.new netsuite_id: 123 23 | allow(s).to receive(:netsuite_pull) 24 | s.save! 25 | 26 | expect(s).to have_received(:netsuite_pull) 27 | expect(NetSuiteRails::RecordSync::PushManager).to_not have_received(:push_add) 28 | expect(NetSuiteRails::RecordSync::PushManager).to_not have_received(:push_update) 29 | end 30 | 31 | it "should push an update on an existing record" do 32 | s = StandardRecord.new netsuite_id: 123 33 | allow(s).to receive(:netsuite_pull) 34 | s.save! 35 | 36 | s.phone = Faker::PhoneNumber.phone_number 37 | s.save! 38 | 39 | expect(NetSuiteRails::RecordSync::PushManager).to_not have_received(:push_add) 40 | expect(NetSuiteRails::RecordSync::PushManager).to have_received(:push_update) 41 | end 42 | 43 | it "should push the modified attributes to the model" do 44 | s = StandardRecord.new netsuite_id: 123 45 | allow(s).to receive(:netsuite_pull) 46 | s.save! 47 | 48 | # delayed_job isn't included in this gem; hack it into the current record instance 49 | s.instance_eval { def delay; self; end } 50 | allow(s).to receive(:delay).and_return(s) 51 | 52 | NetSuiteRails::Configuration.netsuite_sync_mode :async 53 | 54 | s.phone = Faker::PhoneNumber.phone_number 55 | s.save! 56 | 57 | NetSuiteRails::Configuration.netsuite_sync_mode :sync 58 | 59 | expect(s).to have_received(:delay) 60 | expect(NetSuiteRails::RecordSync::PushManager).to have_received(:push_update).with(anything, anything, {:modified_fields=>{:phone=> :phone}}) 61 | end 62 | end 63 | 64 | context 'pull' do 65 | it 'should pull down a new record with a NS ID set on save' do 66 | s = StandardRecord.new netsuite_id: 123 67 | allow(s).to receive(:netsuite_pull_record).and_return(NetSuite::Records::Customer.new(phone: '1231231234')) 68 | allow(s).to receive(:netsuite_extract_from_record) 69 | 70 | s.save! 71 | 72 | expect(NetSuiteRails::RecordSync::PushManager).to_not have_received(:push_add) 73 | expect(NetSuiteRails::RecordSync::PushManager).to_not have_received(:push_update) 74 | expect(s).to have_received(:netsuite_extract_from_record) 75 | end 76 | end 77 | 78 | end -------------------------------------------------------------------------------- /spec/models/transformations_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # TODO https://github.com/NetSweet/netsuite_rails/issues/16 4 | # there are still some unresolved issues with NS datetime/date conversions 5 | # the tests + implementation may still not be correct. 6 | 7 | describe NetSuiteRails::Transformations do 8 | before do 9 | ENV['TZ'] = 'EST' 10 | Rails.configuration.time_zone = 'Eastern Time (US & Canada)' 11 | Time.zone = ActiveSupport::TimeZone[-5] 12 | 13 | NetSuiteRails::Configuration.netsuite_instance_time_zone_offset -6 14 | end 15 | 16 | it 'nils out short phone numbers' do 17 | short_phone_number = ' 301908 ' 18 | 19 | expect(NetSuiteRails::Transformations.phone(short_phone_number)).to be_nil 20 | end 21 | 22 | it 'handles very long phone numbers' do 23 | long_phone_number = '+1 (549)-880-4834 ext. 51077' 24 | 25 | expect(NetSuiteRails::Transformations.phone(long_phone_number)).to eq('5498804834x51077') 26 | 27 | weird_long_phone_number = '12933901964x89914' 28 | expect(NetSuiteRails::Transformations.phone(weird_long_phone_number)).to eq('2933901964x89914') 29 | end 30 | 31 | it "translates local date into NS date" do 32 | # from what I can tell, NetSuite stores dates with a -07:00 offset 33 | # and subtracts (PST - NS instance timezone) hours from the stored datetime 34 | 35 | local_date = DateTime.parse("2015-07-24T00:00:00.000-05:00") 36 | transformed_date = NetSuiteRails::Transformations.date(local_date, :push) 37 | expect(transformed_date.to_s).to eq("2015-07-24T12:00:00-07:00") 38 | end 39 | 40 | it "translates local datetime into NS datetime" do 41 | # TODO set local timezone 42 | local_date = DateTime.parse('Fri May 29 11:52:47 EDT 2015') 43 | NetSuiteRails::Configuration.netsuite_instance_time_zone_offset -6 44 | 45 | transformed_date = NetSuiteRails::Transformations.datetime(local_date, :push) 46 | # TODO this will break as PDT daylight savings is switched; need to freeze the system time for testing 47 | expect(transformed_date.to_s).to eq('1970-01-01T09:52:47-08:00') 48 | end 49 | 50 | it 'transforms a datetime value pulled from netsuite correctly' do 51 | # assuming that the date in CST is displayed as 5am 52 | # in the rails backend we want to store the date as EST with a CST hour 53 | 54 | netsuite_time = DateTime.parse('1970-01-01T03:00:00.000-08:00') 55 | transformed_netsuite_time = NetSuiteRails::Transformations.datetime(netsuite_time, :pull) 56 | expect(transformed_netsuite_time.to_s).to eq('1970-01-01T05:00:00-05:00') 57 | end 58 | 59 | it 'transforms a invalid email' do 60 | netsuite_email = ' hey@example.com. ' 61 | transformed_netsuite_email = NetSuiteRails::Transformations.email(netsuite_email, :push) 62 | expect(transformed_netsuite_email.to_s).to eq('hey@example.com') 63 | 64 | netsuite_email = ' example+second@example.family. ' 65 | transformed_netsuite_email = NetSuiteRails::Transformations.email(netsuite_email, :push) 66 | expect(transformed_netsuite_email.to_s).to eq('example+second@example.family') 67 | 68 | netsuite_email = ' example,second@example.com ' 69 | transformed_netsuite_email = NetSuiteRails::Transformations.email(netsuite_email, :push) 70 | expect(transformed_netsuite_email.to_s).to eq('examplesecond@example.com') 71 | 72 | netsuite_email = 'boom.@gmail.com' 73 | transformed_netsuite_email = NetSuiteRails::Transformations.email(netsuite_email, :push) 74 | expect(transformed_netsuite_email.to_s).to eq('boom@gmail.com') 75 | 76 | netsuite_email = 'boom&boo@gmail.com' 77 | transformed_netsuite_email = NetSuiteRails::Transformations.email(netsuite_email, :push) 78 | expect(transformed_netsuite_email.to_s).to eq('boom&boo@gmail.com') 79 | 80 | netsuite_email = 'boom@gmail&hotmail.com' 81 | transformed_netsuite_email = NetSuiteRails::Transformations.email(netsuite_email, :push) 82 | expect(transformed_netsuite_email.to_s).to eq('boom@gmailhotmail.com') 83 | 84 | netsuite_email = 'first@example.com,second@example.com,third@example.com,fourth@example.com' 85 | transformed_netsuite_email = NetSuiteRails::Transformations.email(netsuite_email, :push) 86 | expect(transformed_netsuite_email.to_s).to eq('firstexample.comsecondexample.comthirdexample.comfourth@example.com') 87 | 88 | netsuite_email = "some\\@example.com" 89 | transformed_netsuite_email = NetSuiteRails::Transformations.email(netsuite_email, :push) 90 | expect(transformed_netsuite_email.to_s).to eq('some@example.com') 91 | end 92 | 93 | it 'truncates gift card code' do 94 | code = Faker::Lorem.characters(10) 95 | expect(NetSuiteRails::Transformations.gift_card_code(code, :push).size).to eq(9) 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/models/url_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe NetSuiteRails::UrlHelper do 4 | include ExampleModels 5 | 6 | it 'should handle a netsuite record' do 7 | NetSuite::Configuration.sandbox = true 8 | c = NetSuite::Records::Customer.new internal_id: 123 9 | url = NetSuiteRails::UrlHelper.netsuite_url(c) 10 | 11 | expect(url).to eq('https://system.sandbox.netsuite.com/app/common/entity/entity.nl?id=123') 12 | end 13 | 14 | it "should handle a record sync enabled record" do 15 | NetSuite::Configuration.sandbox = true 16 | s = StandardRecord.new netsuite_id: 123 17 | url = NetSuiteRails::UrlHelper.netsuite_url(s) 18 | 19 | expect(url).to eq('https://system.sandbox.netsuite.com/app/common/entity/entity.nl?id=123') 20 | end 21 | 22 | xit "should handle a list sync enabled record" do 23 | 24 | end 25 | 26 | it 'should change the prefix URL when a non-sandbox datacenter is in use' do 27 | NetSuite::Configuration.sandbox = false 28 | 29 | s = StandardRecord.new netsuite_id: 123 30 | url = NetSuiteRails::UrlHelper.netsuite_url(s) 31 | 32 | expect(url).to eq('https://system.netsuite.com/app/common/entity/entity.nl?id=123') 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # https://circleci.com/docs/code-coverage 2 | if ENV['CIRCLE_ARTIFACTS'] 3 | require 'simplecov' 4 | dir = File.join("../../../..", ENV['CIRCLE_ARTIFACTS'], "coverage") 5 | SimpleCov.coverage_dir(dir) 6 | SimpleCov.start 7 | end 8 | 9 | require 'rails/all' 10 | 11 | require 'shoulda/matchers' 12 | require 'rspec/rails' 13 | require 'faker' 14 | require 'pry' 15 | 16 | require 'netsuite_rails' 17 | 18 | Dir[File.join(File.dirname(__FILE__), "support/**/*.rb")].each {|f| require f } 19 | 20 | TestApplication::Application.initialize! 21 | 22 | # TODO use DB cleaner instead 23 | NetSuiteRails::PollTimestamp.delete_all 24 | 25 | RSpec.configure do |config| 26 | config.color = true 27 | 28 | config.expect_with :rspec do |expectations| 29 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 30 | end 31 | 32 | config.mock_with :rspec do |mocks| 33 | mocks.verify_partial_doubles = true 34 | end 35 | 36 | config.before do 37 | NetSuiteRails.configure do 38 | reset! 39 | netsuite_sync_mode :sync 40 | end 41 | 42 | NetSuite::Configuration.reset! 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/support/config/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: sqlite3 3 | database: db/development.sqlite3 4 | pool: 5 5 | timeout: 5000 6 | 7 | test: 8 | adapter: sqlite3 9 | database: db/test.sqlite3 10 | pool: 5 11 | timeout: 5000 -------------------------------------------------------------------------------- /spec/support/dynamic_models/class_builder.rb: -------------------------------------------------------------------------------- 1 | # https://github.com/thoughtbot/shoulda-matchers/raw/2a4b9f1e163fa9c5b84f223962d5f8e099032420/spec/support/class_builder.rb 2 | 3 | module ClassBuilder 4 | def self.included(example_group) 5 | example_group.class_eval do 6 | after do 7 | teardown_defined_constants 8 | end 9 | end 10 | end 11 | 12 | def define_class(class_name, base = Object, &block) 13 | class_name = class_name.to_s.camelize 14 | 15 | if Object.const_defined?(class_name) 16 | Object.__send__(:remove_const, class_name) 17 | end 18 | 19 | # FIXME: ActionMailer 3.2 calls `name.underscore` immediately upon 20 | # subclassing. Class.new.name == nil. So, Class.new(ActionMailer::Base) 21 | # errors out since it's trying to do `nil.underscore`. This is very ugly but 22 | # allows us to test against ActionMailer 3.2.x. 23 | eval <<-A_REAL_CLASS_FOR_ACTION_MAILER_3_2 24 | class ::#{class_name} < #{base} 25 | end 26 | A_REAL_CLASS_FOR_ACTION_MAILER_3_2 27 | 28 | Object.const_get(class_name).tap do |constant_class| 29 | constant_class.unloadable 30 | 31 | if block_given? 32 | constant_class.class_eval(&block) 33 | end 34 | 35 | if constant_class.respond_to?(:reset_column_information) 36 | constant_class.reset_column_information 37 | end 38 | end 39 | end 40 | 41 | def teardown_defined_constants 42 | ActiveSupport::Dependencies.clear 43 | end 44 | end 45 | 46 | RSpec.configure do |config| 47 | config.include ClassBuilder 48 | end -------------------------------------------------------------------------------- /spec/support/dynamic_models/model_builder.rb: -------------------------------------------------------------------------------- 1 | # https://github.com/thoughtbot/shoulda-matchers/blob/master/spec/support/unit/helpers/model_builder.rb 2 | 3 | module ModelBuilder 4 | def self.included(example_group) 5 | example_group.class_eval do 6 | before do 7 | @created_tables ||= [] 8 | end 9 | 10 | after do 11 | drop_created_tables 12 | ActiveSupport::Dependencies.clear 13 | end 14 | end 15 | end 16 | 17 | def create_table(table_name, options = {}, &block) 18 | connection = ActiveRecord::Base.connection 19 | 20 | begin 21 | connection.execute("DROP TABLE IF EXISTS #{table_name}") 22 | connection.create_table(table_name, options, &block) 23 | @created_tables << table_name 24 | connection 25 | rescue Exception => e 26 | connection.execute("DROP TABLE IF EXISTS #{table_name}") 27 | raise e 28 | end 29 | end 30 | 31 | def define_model_class(class_name, &block) 32 | define_class(class_name, ActiveRecord::Base, &block) 33 | end 34 | 35 | def define_active_model_class(class_name, options = {}, &block) 36 | define_class(class_name) do 37 | include ActiveModel::Validations 38 | 39 | options[:accessors].each do |column| 40 | attr_accessor column.to_sym 41 | end 42 | 43 | if block_given? 44 | class_eval(&block) 45 | end 46 | end 47 | end 48 | 49 | def define_model(name, columns = {}, &block) 50 | class_name = name.to_s.pluralize.classify 51 | table_name = class_name.tableize 52 | table_block = lambda do |table| 53 | columns.each do |name, specification| 54 | if specification.is_a?(Hash) 55 | table.column name, specification[:type], specification[:options] 56 | else 57 | table.column name, specification 58 | end 59 | end 60 | end 61 | 62 | if columns.key?(:id) && columns[:id] == false 63 | columns.delete(:id) 64 | create_table(table_name, id: false, &table_block) 65 | else 66 | create_table(table_name, &table_block) 67 | end 68 | 69 | define_model_class(class_name, &block) 70 | end 71 | 72 | def drop_created_tables 73 | connection = ActiveRecord::Base.connection 74 | 75 | @created_tables.each do |table_name| 76 | connection.execute("DROP TABLE IF EXISTS #{table_name}") 77 | end 78 | end 79 | end 80 | 81 | RSpec.configure do |config| 82 | config.include ModelBuilder 83 | end -------------------------------------------------------------------------------- /spec/support/example_models.rb: -------------------------------------------------------------------------------- 1 | module ExampleModels 2 | 3 | def self.included(example_group) 4 | example_group.class_eval do 5 | before do 6 | 7 | define_model :standard_record, email: :string, company: :string, phone: :string, netsuite_id: :integer, is_deleted: :boolean do 8 | include NetSuiteRails::RecordSync 9 | 10 | netsuite_record_class NetSuite::Records::Customer 11 | netsuite_sync :read_write 12 | netsuite_field_map({ 13 | :is_deleted => :is_inactive, 14 | :phone => :phone, 15 | :company => Proc.new do |local, netsuite, direction| 16 | # NOTE this could be done by a simple mapping! 17 | if direction == :push 18 | netsuite.company_name = local.company 19 | else 20 | local.company = netsuite.company_name 21 | end 22 | end 23 | }) 24 | end 25 | 26 | define_model :custom_record, netsuite_id: :integer, value: :string do 27 | include NetSuiteRails::RecordSync 28 | 29 | netsuite_record_class NetSuite::Records::CustomRecord, 123 30 | netsuite_sync :read_write 31 | netsuite_field_map({ 32 | :custom_field_list => { 33 | :value => :custrecord_another_value 34 | } 35 | }) 36 | end 37 | 38 | define_model :standard_list, netsuite_id: :integer, value: :string do 39 | include NetSuiteRails::ListSync 40 | netsuite_list_id 86 41 | end 42 | 43 | define_model :external_id_record, netsuite_id: :integer, phone: :string do 44 | include NetSuiteRails::RecordSync 45 | 46 | netsuite_record_class NetSuite::Records::Customer 47 | netsuite_sync :read_write 48 | netsuite_field_map({ 49 | :phone => :phone 50 | }) 51 | 52 | def netsuite_external_id 53 | "phone-#{self.phone}" 54 | end 55 | end 56 | end 57 | 58 | after do 59 | NetSuiteRails::PollTrigger.instance_variable_set('@record_models', []) 60 | NetSuiteRails::PollTrigger.instance_variable_set('@list_models', []) 61 | end 62 | 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/support/test_application.rb: -------------------------------------------------------------------------------- 1 | # https://github.com/thoughtbot/clearance/blob/master/spec/spec_helper.rb 2 | 3 | require 'rails/all' 4 | 5 | module TestApplication 6 | APP_ROOT = File.expand_path('..', __FILE__).freeze 7 | 8 | def self.rails4? 9 | Rails::VERSION::MAJOR >= 4 10 | end 11 | 12 | I18n.enforce_available_locales = true 13 | 14 | class Application < Rails::Application 15 | config.action_controller.allow_forgery_protection = false 16 | config.action_controller.perform_caching = false 17 | config.action_dispatch.show_exceptions = false 18 | config.action_mailer.default_url_options = { host: 'localhost' } 19 | config.action_mailer.delivery_method = :test 20 | config.active_support.deprecation = :stderr 21 | config.assets.enabled = true 22 | config.cache_classes = true 23 | config.consider_all_requests_local = true 24 | config.eager_load = false 25 | config.encoding = 'utf-8' 26 | config.paths['app/controllers'] << "#{APP_ROOT}/app/controllers" 27 | config.paths['app/views'] << "#{APP_ROOT}/app/views" 28 | config.paths['config/database'] = "#{APP_ROOT}/config/database.yml" 29 | config.paths['log'] = 'tmp/log/development.log' 30 | config.secret_token = 'SECRET_TOKEN_IS_MIN_30_CHARS_LONG' 31 | config.active_support.test_order = :random 32 | 33 | if TestApplication.rails4? 34 | config.paths.add 'config/routes.rb', with: "#{APP_ROOT}/config/routes.rb" 35 | config.secret_key_base = 'SECRET_KEY_BASE' 36 | else 37 | config.paths.add 'config/routes', with: "#{APP_ROOT}/config/routes.rb" 38 | end 39 | 40 | def require_environment! 41 | initialize! 42 | end 43 | 44 | def initialize!(&block) 45 | FileUtils.mkdir_p(Rails.root.join('db').to_s) 46 | 47 | super unless @initialized 48 | 49 | unless ActiveRecord::Base.connection.table_exists?('netsuite_poll_timestamps') 50 | require "generators/netsuite_rails/templates/create_netsuite_poll_timestamps.rb" 51 | CreateNetsuitePollTimestamps.new.migrate(:up) 52 | end 53 | end 54 | end 55 | end 56 | --------------------------------------------------------------------------------