mirror of
https://github.com/rickbarrette/redmine_qbo.git
synced 2026-04-03 16:51:58 -04:00
Compare commits
11 Commits
40f7a3335c
...
2026.3.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fd7140e4a | |||
| a6c8923ea9 | |||
| 6fc8a18e93 | |||
| 8abc95c21e | |||
| 2bcb1840a4 | |||
| c87e18810b | |||
| eb6954ddf1 | |||
| be1a69217f | |||
| 99669f7baa | |||
| 29530e2c95 | |||
| beb4a66a93 |
@@ -19,6 +19,7 @@ class Customer < QboBaseModel
|
|||||||
validates_presence_of :name
|
validates_presence_of :name
|
||||||
before_validation :normalize_phone_numbers
|
before_validation :normalize_phone_numbers
|
||||||
self.primary_key = :id
|
self.primary_key = :id
|
||||||
|
qbo_sync push: true
|
||||||
|
|
||||||
acts_as_searchable columns: %w[name phone_number mobile_phone_number ],
|
acts_as_searchable columns: %w[name phone_number mobile_phone_number ],
|
||||||
scope: ->(_context) { left_joins(:project) },
|
scope: ->(_context) { left_joins(:project) },
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ class Employee < QboBaseModel
|
|||||||
has_many :users
|
has_many :users
|
||||||
validates_presence_of :id, :name
|
validates_presence_of :id, :name
|
||||||
self.primary_key = :id
|
self.primary_key = :id
|
||||||
|
qbo_sync push: false
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -14,6 +14,7 @@ class Estimate < QboBaseModel
|
|||||||
belongs_to :customer
|
belongs_to :customer
|
||||||
validates_presence_of :doc_number, :id
|
validates_presence_of :doc_number, :id
|
||||||
self.primary_key = :id
|
self.primary_key = :id
|
||||||
|
qbo_sync push: false
|
||||||
|
|
||||||
# returns a human readable string
|
# returns a human readable string
|
||||||
def to_s
|
def to_s
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class Invoice < QboBaseModel
|
|||||||
validates :id, presence: true, uniqueness: true
|
validates :id, presence: true, uniqueness: true
|
||||||
validates :doc_number, :txn_date, presence: true
|
validates :doc_number, :txn_date, presence: true
|
||||||
self.primary_key = :id
|
self.primary_key = :id
|
||||||
|
qbo_sync push: false
|
||||||
|
|
||||||
# Return the invoice's document number as its string representation
|
# Return the invoice's document number as its string representation
|
||||||
def to_s
|
def to_s
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ class Qbo < ActiveRecord::Base
|
|||||||
# Returns the last sync time formatted for display. If no sync has occurred, returns a default message.
|
# Returns the last sync time formatted for display. If no sync has occurred, returns a default message.
|
||||||
def self.last_sync
|
def self.last_sync
|
||||||
qbo = QboConnectionService.current!
|
qbo = QboConnectionService.current!
|
||||||
return I18n.t(:label_qbo_never_synced) unless qbo&.last_sync
|
|
||||||
format_time(qbo.last_sync)
|
format_time(qbo.last_sync)
|
||||||
|
rescue
|
||||||
|
return I18n.t(:label_qbo_never_synced)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ class QboBaseModel < ActiveRecord::Base
|
|||||||
|
|
||||||
self.abstract_class = true
|
self.abstract_class = true
|
||||||
validates_presence_of :id
|
validates_presence_of :id
|
||||||
|
class_attribute :qbo_push_enabled, default: true
|
||||||
attr_accessor :skip_qbo_push
|
attr_accessor :skip_qbo_push
|
||||||
before_validation :push_to_qbo, on: :create
|
before_validation :push_to_qbo, on: :create, if: :push_to_qbo?
|
||||||
after_commit :push_to_qbo, on: :update, unless: :skip_qbo_push?
|
after_commit :push_to_qbo, on: :update, if: :push_to_qbo?
|
||||||
|
|
||||||
# Returns the details of the entity.
|
# Returns the details of the entity.
|
||||||
# If the details have already been fetched, it returns the cached version.
|
# If the details have already been fetched, it returns the cached version.
|
||||||
@@ -55,7 +55,14 @@ class QboBaseModel < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Repsonds to missing methods by delegating to the QBO customer details object if the method is defined there.
|
def push_to_qbo?
|
||||||
|
log "qbo_push_enabled #{self.class.qbo_push_enabled}"
|
||||||
|
log "skip_qbo_push #{skip_qbo_push}"
|
||||||
|
|
||||||
|
self.class.qbo_push_enabled && skip_qbo_push != true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Repsonds to missing methods by delegating to the QBO entity calss if the method is defined there.
|
||||||
# This allows for dynamic access to any attributes or methods of the QBO customer without having to explicitly define them in the Subclass model, providing flexibility and reducing boilerplate code.
|
# This allows for dynamic access to any attributes or methods of the QBO customer without having to explicitly define them in the Subclass model, providing flexibility and reducing boilerplate code.
|
||||||
def respond_to_missing?(method_name, include_private = false)
|
def respond_to_missing?(method_name, include_private = false)
|
||||||
qbo_model_class.method_defined?(method_name) || super
|
qbo_model_class.method_defined?(method_name) || super
|
||||||
@@ -73,12 +80,19 @@ class QboBaseModel < ActiveRecord::Base
|
|||||||
job.perform_later(id: id)
|
job.perform_later(id: id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Flag used to update local without pushing to QBO.
|
||||||
|
# This is used to prevent loops with the webhook
|
||||||
def skip_qbo_push?
|
def skip_qbo_push?
|
||||||
!!skip_qbo_push
|
!!skip_qbo_push
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.qbo_sync(push: true)
|
||||||
|
self.qbo_push_enabled = push
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# Log messages with a standarized prefix
|
||||||
def log(msg)
|
def log(msg)
|
||||||
Rails.logger.info "[#{model_name.name}] #{msg}"
|
Rails.logger.info "[#{model_name.name}] #{msg}"
|
||||||
end
|
end
|
||||||
@@ -90,6 +104,7 @@ class QboBaseModel < ActiveRecord::Base
|
|||||||
service_class.new(qbo: qbo, local: self).pull()
|
service_class.new(qbo: qbo, local: self).pull()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Pushs the entity's details from QuickBooks Online.
|
||||||
def push_to_qbo
|
def push_to_qbo
|
||||||
log "Starting push for #{model_name.name} ##{id}..."
|
log "Starting push for #{model_name.name} ##{id}..."
|
||||||
qbo = QboConnectionService.current!
|
qbo = QboConnectionService.current!
|
||||||
@@ -98,10 +113,12 @@ class QboBaseModel < ActiveRecord::Base
|
|||||||
return reslut
|
return reslut
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Dynamically get the Quickbooks Model Class
|
||||||
def qbo_model_class
|
def qbo_model_class
|
||||||
@qbo_model_class ||= "Quickbooks::Model::#{model_name.name}".constantize
|
@qbo_model_class ||= "Quickbooks::Model::#{model_name.name}".constantize
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Dynamically get the Service Class
|
||||||
def service_class
|
def service_class
|
||||||
@service_class ||= "#{model_name.name}Service".constantize
|
@service_class ||= "#{model_name.name}Service".constantize
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,16 +17,13 @@ class CustomerSyncService < SyncServiceBase
|
|||||||
Customer
|
Customer
|
||||||
end
|
end
|
||||||
|
|
||||||
# Determine if the remote entity should be deleted locally (e.g. if it's marked inactive in QBO)
|
# Determine if the local entity should be deleted (e.g. if it's marked inactive in QBO)
|
||||||
def destroy_remote?(remote)
|
def destroy_local?(remote)
|
||||||
!remote.active?
|
!remote.active?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Map relevant attributes from the QBO Customer to the local Customer model
|
map_attribute :name, :display_name
|
||||||
def process_attributes(local, remote)
|
map_phone :phone_number, :primary_phone
|
||||||
local.name = remote.display_name
|
map_phone :mobile_phone_number, :mobile_phone
|
||||||
local.phone_number = remote.primary_phone&.free_form_number&.gsub(/\D/, '')
|
|
||||||
local.mobile_phone_number = remote.mobile_phone&.free_form_number&.gsub(/\D/, '')
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -17,14 +17,11 @@ class EmployeeSyncService < SyncServiceBase
|
|||||||
Employee
|
Employee
|
||||||
end
|
end
|
||||||
|
|
||||||
# Determine if the remote entity should be deleted locally (e.g. if it's marked inactive in QBO)
|
# Determine if the local entity should be deleted (e.g. if it's marked inactive in QBO)
|
||||||
def destroy_remote?(remote)
|
def destroy_local?(remote)
|
||||||
!remote.active?
|
!remote.active?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Map relevant attributes from the QBO Employee to the local Employee model
|
map_attribute :name, :display_name
|
||||||
def process_attributes(local, remote)
|
|
||||||
local.name = remote.display_name
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -17,11 +17,7 @@ class EstimateSyncService < SyncServiceBase
|
|||||||
Estimate
|
Estimate
|
||||||
end
|
end
|
||||||
|
|
||||||
# Map relevant attributes from the QBO Estimate to the local Estimate model
|
map_attributes :doc_number, :txn_date
|
||||||
def process_attributes(local, remote)
|
map_belongs_to :customer
|
||||||
local.doc_number = remote.doc_number
|
|
||||||
local.txn_date = remote.txn_date
|
|
||||||
local.customer = Customer.find_by(id: remote.customer_ref&.value)
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -16,20 +16,15 @@ class InvoiceSyncService < SyncServiceBase
|
|||||||
def self.model_class
|
def self.model_class
|
||||||
Invoice
|
Invoice
|
||||||
end
|
end
|
||||||
|
|
||||||
# Map relevant attributes from the QBO Invoice to the local Invoice model
|
|
||||||
def process_attributes(local, remote)
|
|
||||||
local.doc_number = remote.doc_number
|
|
||||||
local.txn_date = remote.txn_date
|
|
||||||
local.due_date = remote.due_date
|
|
||||||
local.total_amount = remote.total
|
|
||||||
local.balance = remote.balance
|
|
||||||
local.qbo_updated_at = remote.meta_data&.last_updated_time
|
|
||||||
local.customer = Customer.find_by(id: remote.customer_ref&.value)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Attach QBO Invoices to the local Issues
|
# Attach QBO Invoices to the local Issues
|
||||||
def attach_documents(local, remote)
|
def attach_documents(local, remote)
|
||||||
InvoiceAttachmentService.new(local, remote).attach
|
InvoiceAttachmentService.new(local, remote).attach
|
||||||
end
|
end
|
||||||
|
|
||||||
|
map_attributes :balance, :doc_number, :due_date, :txn_date
|
||||||
|
map_attribute :total_amount, :total
|
||||||
|
map_attribute :qbo_updated_at, "meta_data.last_updated_time"
|
||||||
|
map_belongs_to :customer
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -14,10 +14,10 @@ class ServiceBase
|
|||||||
# The QBO client is used to communicate with QuickBooks Online, while the local record contains the data that needs to be pushed to QBO.
|
# The QBO client is used to communicate with QuickBooks Online, while the local record contains the data that needs to be pushed to QBO.
|
||||||
# If no local is provided, the service will not perform any operations.
|
# If no local is provided, the service will not perform any operations.
|
||||||
def initialize(qbo:, local: nil)
|
def initialize(qbo:, local: nil)
|
||||||
|
@entity = local.class.name
|
||||||
raise "No QBO configuration found" unless qbo
|
raise "No QBO configuration found" unless qbo
|
||||||
raise "#{@entity} record is required for push operation" unless local
|
raise "#{@entity} record is required for push operation" unless local
|
||||||
@qbo = qbo
|
@qbo = qbo
|
||||||
@entity = local.class.name
|
|
||||||
@local = local
|
@local = local
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -31,13 +31,7 @@ class ServiceBase
|
|||||||
return build_qbo_remote unless @local.present?
|
return build_qbo_remote unless @local.present?
|
||||||
return build_qbo_remote unless @local.id
|
return build_qbo_remote unless @local.id
|
||||||
log "Fetching details for #{@entity} ##{@local.id} from QBO..."
|
log "Fetching details for #{@entity} ##{@local.id} from QBO..."
|
||||||
qbo = QboConnectionService.current!
|
with_qbo_service do |service|
|
||||||
qbo.perform_authenticated_request do |access_token|
|
|
||||||
service_class = "Quickbooks::Service::#{@entity}".constantize
|
|
||||||
service = service_class.new(
|
|
||||||
company_id: qbo.realm_id,
|
|
||||||
access_token: access_token
|
|
||||||
)
|
|
||||||
service.fetch_by_id(@local.id)
|
service.fetch_by_id(@local.id)
|
||||||
end
|
end
|
||||||
rescue => e
|
rescue => e
|
||||||
@@ -51,13 +45,7 @@ class ServiceBase
|
|||||||
# If the push is successful, it returns the remote record; otherwise, it logs the error and returns false.
|
# If the push is successful, it returns the remote record; otherwise, it logs the error and returns false.
|
||||||
def push
|
def push
|
||||||
log "Pushing #{@entity} ##{@local.id} to QBO..."
|
log "Pushing #{@entity} ##{@local.id} to QBO..."
|
||||||
|
remote = with_qbo_service do |service|
|
||||||
remote = @qbo.perform_authenticated_request do |access_token|
|
|
||||||
service_class = "Quickbooks::Service::#{@entity}".constantize
|
|
||||||
service = service_class.new(
|
|
||||||
company_id: @qbo.realm_id,
|
|
||||||
access_token: access_token
|
|
||||||
)
|
|
||||||
if @local.id.present?
|
if @local.id.present?
|
||||||
log "Updating #{@entity}"
|
log "Updating #{@entity}"
|
||||||
service.update(@local.details)
|
service.update(@local.details)
|
||||||
@@ -66,17 +54,29 @@ class ServiceBase
|
|||||||
service.create(@local.details)
|
service.create(@local.details)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@local.id = remote.id unless @local.persisted?
|
@local.id = remote.id unless @local.persisted?
|
||||||
log "Push for remote ##{@local.id} completed."
|
log "Push for remote ##{@local.id} completed."
|
||||||
return @local
|
@local
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# Performs authenticaed requests with QBO service
|
||||||
|
def with_qbo_service
|
||||||
|
@qbo.perform_authenticated_request do |access_token|
|
||||||
|
service = service_class.new( company_id: @qbo.realm_id, access_token: access_token )
|
||||||
|
yield service
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Log messages with the entity type for better traceability
|
# Log messages with the entity type for better traceability
|
||||||
def log(msg)
|
def log(msg)
|
||||||
Rails.logger.info "[#{@entity}Service] #{msg}"
|
Rails.logger.info "[#{@entity}Service] #{msg}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Dynamically get the Quickbooks Service Class
|
||||||
|
def service_class
|
||||||
|
@service_class ||= "Quickbooks::Service::#{@entity}".constantize
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -32,17 +32,11 @@ class SyncServiceBase
|
|||||||
# Sync all entities, or only those updated since the last sync
|
# Sync all entities, or only those updated since the last sync
|
||||||
def sync(full_sync: false)
|
def sync(full_sync: false)
|
||||||
log "Starting #{full_sync ? 'full' : 'incremental'} #{@entity.name} sync with page size of: #{@page_size}"
|
log "Starting #{full_sync ? 'full' : 'incremental'} #{@entity.name} sync with page size of: #{@page_size}"
|
||||||
|
with_qbo_service do |service|
|
||||||
@qbo.perform_authenticated_request do |access_token|
|
query = build_query(full_sync)
|
||||||
service_class = "Quickbooks::Service::#{@entity.name}".constantize
|
|
||||||
service = service_class.new(company_id: @qbo.realm_id, access_token: access_token)
|
|
||||||
|
|
||||||
query = build_query(full_sync)
|
|
||||||
|
|
||||||
service.query_in_batches(query, per_page: @page_size) do |batch|
|
service.query_in_batches(query, per_page: @page_size) do |batch|
|
||||||
entries = Array(batch)
|
entries = Array(batch)
|
||||||
log "Processing batch of #{entries.size} #{@entity.name}"
|
log "Processing batch of #{entries.size} #{@entity.name}"
|
||||||
|
|
||||||
entries.each do |remote|
|
entries.each do |remote|
|
||||||
persist(remote)
|
persist(remote)
|
||||||
end
|
end
|
||||||
@@ -55,10 +49,7 @@ class SyncServiceBase
|
|||||||
# Sync a single entity by its QBO ID (webhook usage)
|
# Sync a single entity by its QBO ID (webhook usage)
|
||||||
def sync_by_id(id)
|
def sync_by_id(id)
|
||||||
log "Syncing #{@entity.name} with ID #{id}"
|
log "Syncing #{@entity.name} with ID #{id}"
|
||||||
|
with_qbo_service do |service|
|
||||||
@qbo.perform_authenticated_request do |access_token|
|
|
||||||
service_class = "Quickbooks::Service::#{@entity.name}".constantize
|
|
||||||
service = service_class.new(company_id: @qbo.realm_id, access_token: access_token)
|
|
||||||
remote = service.fetch_by_id(id)
|
remote = service.fetch_by_id(id)
|
||||||
persist(remote)
|
persist(remote)
|
||||||
end
|
end
|
||||||
@@ -66,6 +57,10 @@ class SyncServiceBase
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def attach_documents(local, remote)
|
||||||
|
# Override in subclasses if the entity has attachments (e.g. Invoice)
|
||||||
|
end
|
||||||
|
|
||||||
# Builds a QBO query for retrieving entities
|
# Builds a QBO query for retrieving entities
|
||||||
def build_query(full_sync)
|
def build_query(full_sync)
|
||||||
if full_sync
|
if full_sync
|
||||||
@@ -81,13 +76,125 @@ class SyncServiceBase
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def attach_documents(local, remote)
|
# Determine if a remote entity should be deleted locally (e.g. if it's marked inactive in QBO)
|
||||||
# Override in subclasses if the entity has attachments (e.g. Invoice)
|
def destroy_local?(remote)
|
||||||
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
# Determine if a remote entity should be deleted locally (e.g. if it's marked inactive in QBO)
|
def extract_value(remote, remote_attr)
|
||||||
def destroy_remote?(remote)
|
case remote_attr
|
||||||
false
|
when Proc
|
||||||
|
remote_attr.call(remote)
|
||||||
|
else
|
||||||
|
remote.public_send(remote_attr)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Attribute Mapping DSL
|
||||||
|
#
|
||||||
|
# This DSL defines how attributes from a QuickBooks Online (QBO) entity
|
||||||
|
# are mapped onto a local ActiveRecord model during synchronization.
|
||||||
|
#
|
||||||
|
# Each mapping registers a lambda in `attribute_map`. When a remote QBO
|
||||||
|
# object is processed, the lambda is executed to extract and transform
|
||||||
|
# the value that will be assigned to the local model attribute.
|
||||||
|
#
|
||||||
|
# The DSL supports several mapping patterns:
|
||||||
|
#
|
||||||
|
# 1. Direct attribute mapping (same name)
|
||||||
|
#
|
||||||
|
# map_attribute :doc_number
|
||||||
|
#
|
||||||
|
# Equivalent to:
|
||||||
|
#
|
||||||
|
# local.doc_number = remote.doc_number
|
||||||
|
#
|
||||||
|
# 2. Renamed attribute mapping
|
||||||
|
#
|
||||||
|
# map_attribute :total_amount, :total
|
||||||
|
#
|
||||||
|
# Equivalent to:
|
||||||
|
#
|
||||||
|
# local.total_amount = remote.total
|
||||||
|
#
|
||||||
|
# 3. Custom transformation logic
|
||||||
|
#
|
||||||
|
# map_attribute :qbo_updated_at do |remote|
|
||||||
|
# remote.meta_data&.last_updated_time
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# Useful for nested fields or computed values.
|
||||||
|
#
|
||||||
|
# 4. Bulk attribute mapping
|
||||||
|
#
|
||||||
|
# map_attributes :doc_number, :txn_date, :due_date
|
||||||
|
#
|
||||||
|
# Convenience helper that maps multiple attributes with identical names.
|
||||||
|
#
|
||||||
|
# 5. Foreign key / reference mapping
|
||||||
|
#
|
||||||
|
# map_belongs_to :customer
|
||||||
|
#
|
||||||
|
# Resolves a QBO reference object (e.g. `customer_ref.value`) and finds
|
||||||
|
# the associated local ActiveRecord model.
|
||||||
|
#
|
||||||
|
# 6. Specialized helpers
|
||||||
|
#
|
||||||
|
# map_phone :phone_number, :primary_phone
|
||||||
|
#
|
||||||
|
# Extracts and normalizes phone numbers by stripping non-digit characters.
|
||||||
|
#
|
||||||
|
# Internally, the mappings are stored in `attribute_map` and executed by the
|
||||||
|
# SyncService during `process_attributes`, which iterates through each mapping
|
||||||
|
# and assigns the computed value to the local record.
|
||||||
|
#
|
||||||
|
# This design keeps synchronization services declarative, readable, and easy
|
||||||
|
# to extend while centralizing transformation logic in a single DSL.
|
||||||
|
class << self
|
||||||
|
|
||||||
|
def map_attributes(*attrs)
|
||||||
|
attrs.each do |attr|
|
||||||
|
map_attribute(attr)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
def map_attribute(local_attr, remote_attr = nil, &block)
|
||||||
|
attribute_map[local_attr] =
|
||||||
|
if block_given?
|
||||||
|
block
|
||||||
|
elsif remote_attr
|
||||||
|
->(remote) do
|
||||||
|
remote_attr.to_s.split('.').reduce(remote) do |obj, method|
|
||||||
|
obj&.public_send(method)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
->(remote) { remote.public_send(local_attr) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def attribute_map
|
||||||
|
@attribute_map ||= {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def map_belongs_to(local_attr, ref: nil, model: nil)
|
||||||
|
ref ||= "#{local_attr}_ref"
|
||||||
|
model ||= local_attr.to_s.classify.constantize
|
||||||
|
|
||||||
|
attribute_map[local_attr] = lambda do |remote|
|
||||||
|
ref_obj = remote.public_send(ref)
|
||||||
|
id = ref_obj&.value
|
||||||
|
id ? model.find_by(id: id) : nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def map_phone(local_attr, remote_attr)
|
||||||
|
attribute_map[local_attr] = lambda do |remote|
|
||||||
|
phone = remote.public_send(remote_attr)
|
||||||
|
phone&.free_form_number&.gsub(/\D/, '')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Log messages with the entity type for better traceability
|
# Log messages with the entity type for better traceability
|
||||||
@@ -98,8 +205,8 @@ class SyncServiceBase
|
|||||||
# Create or update a local entity record based on the QBO remote data
|
# Create or update a local entity record based on the QBO remote data
|
||||||
def persist(remote)
|
def persist(remote)
|
||||||
local = @entity.find_or_initialize_by(id: remote.id)
|
local = @entity.find_or_initialize_by(id: remote.id)
|
||||||
|
|
||||||
if destroy_remote?(remote)
|
if destroy_local?(remote)
|
||||||
if local.persisted?
|
if local.persisted?
|
||||||
local.destroy
|
local.destroy
|
||||||
log "Deleted #{@entity.name} #{remote.id}"
|
log "Deleted #{@entity.name} #{remote.id}"
|
||||||
@@ -108,11 +215,12 @@ class SyncServiceBase
|
|||||||
end
|
end
|
||||||
|
|
||||||
process_attributes(local, remote)
|
process_attributes(local, remote)
|
||||||
|
|
||||||
if local.changed?
|
if local.new_record? || local.changed?
|
||||||
|
was_new = local.new_record?
|
||||||
local.skip_qbo_push = true
|
local.skip_qbo_push = true
|
||||||
local.save
|
local.save!
|
||||||
log "Updated #{@entity.name} #{remote.id}"
|
log "#{was_new ? 'Created' : 'Updated'} #{@entity.name} #{remote.id}"
|
||||||
attach_documents(local, remote)
|
attach_documents(local, remote)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -120,8 +228,29 @@ class SyncServiceBase
|
|||||||
log "Failed to sync #{@entity.name} #{remote.id}: #{e.message}"
|
log "Failed to sync #{@entity.name} #{remote.id}: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# This method should be implemented in subclasses to map remote attributes to local model
|
# Maps remote attributes to local model
|
||||||
def process_attributes(local, remote)
|
def process_attributes(local, remote)
|
||||||
raise NotImplementedError, "Subclasses must implement process_attributes"
|
log "Processing #{@entity} ##{remote.id}"
|
||||||
|
|
||||||
|
self.class.attribute_map.each do |local_attr, mapper|
|
||||||
|
value = mapper.call(remote)
|
||||||
|
|
||||||
|
if local.public_send(local_attr) != value
|
||||||
|
local.public_send("#{local_attr}=", value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Dynamically get the Quickbooks Service Class
|
||||||
|
def service_class
|
||||||
|
@service_class ||= "Quickbooks::Service::#{@entity}".constantize
|
||||||
|
end
|
||||||
|
|
||||||
|
# Performs authenticaed requests with QBO service
|
||||||
|
def with_qbo_service
|
||||||
|
@qbo.perform_authenticated_request do |access_token|
|
||||||
|
service = service_class.new( company_id: @qbo.realm_id, access_token: access_token )
|
||||||
|
yield service
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
2
init.rb
2
init.rb
@@ -14,7 +14,7 @@ Redmine::Plugin.register :redmine_qbo do
|
|||||||
name 'Redmine QBO plugin'
|
name 'Redmine QBO plugin'
|
||||||
author 'Rick Barrette'
|
author 'Rick Barrette'
|
||||||
description 'A pluging for Redmine to connect with QuickBooks Online to create Time Activity Entries for billable hours logged when an Issue is closed'
|
description 'A pluging for Redmine to connect with QuickBooks Online to create Time Activity Entries for billable hours logged when an Issue is closed'
|
||||||
version '2026.3.6'
|
version '2026.3.8'
|
||||||
url 'https://github.com/rickbarrette/redmine_qbo'
|
url 'https://github.com/rickbarrette/redmine_qbo'
|
||||||
author_url 'https://barrettefabrication.com'
|
author_url 'https://barrettefabrication.com'
|
||||||
settings default: {empty: true}, partial: 'qbo/settings'
|
settings default: {empty: true}, partial: 'qbo/settings'
|
||||||
|
|||||||
Reference in New Issue
Block a user