mirror of
https://github.com/rickbarrette/redmine_qbo.git
synced 2026-04-02 08:21:57 -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
|
||||
before_validation :normalize_phone_numbers
|
||||
self.primary_key = :id
|
||||
qbo_sync push: true
|
||||
|
||||
acts_as_searchable columns: %w[name phone_number mobile_phone_number ],
|
||||
scope: ->(_context) { left_joins(:project) },
|
||||
|
||||
@@ -13,5 +13,6 @@ class Employee < QboBaseModel
|
||||
has_many :users
|
||||
validates_presence_of :id, :name
|
||||
self.primary_key = :id
|
||||
qbo_sync push: false
|
||||
|
||||
end
|
||||
@@ -14,6 +14,7 @@ class Estimate < QboBaseModel
|
||||
belongs_to :customer
|
||||
validates_presence_of :doc_number, :id
|
||||
self.primary_key = :id
|
||||
qbo_sync push: false
|
||||
|
||||
# returns a human readable string
|
||||
def to_s
|
||||
|
||||
@@ -15,6 +15,7 @@ class Invoice < QboBaseModel
|
||||
validates :id, presence: true, uniqueness: true
|
||||
validates :doc_number, :txn_date, presence: true
|
||||
self.primary_key = :id
|
||||
qbo_sync push: false
|
||||
|
||||
# Return the invoice's document number as its string representation
|
||||
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.
|
||||
def self.last_sync
|
||||
qbo = QboConnectionService.current!
|
||||
return I18n.t(:label_qbo_never_synced) unless qbo&.last_sync
|
||||
format_time(qbo.last_sync)
|
||||
rescue
|
||||
return I18n.t(:label_qbo_never_synced)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -14,10 +14,10 @@ class QboBaseModel < ActiveRecord::Base
|
||||
|
||||
self.abstract_class = true
|
||||
validates_presence_of :id
|
||||
|
||||
class_attribute :qbo_push_enabled, default: true
|
||||
attr_accessor :skip_qbo_push
|
||||
before_validation :push_to_qbo, on: :create
|
||||
after_commit :push_to_qbo, on: :update, unless: :skip_qbo_push?
|
||||
before_validation :push_to_qbo, on: :create, if: :push_to_qbo?
|
||||
after_commit :push_to_qbo, on: :update, if: :push_to_qbo?
|
||||
|
||||
# Returns the details of the entity.
|
||||
# If the details have already been fetched, it returns the cached version.
|
||||
@@ -55,7 +55,14 @@ class QboBaseModel < ActiveRecord::Base
|
||||
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.
|
||||
def respond_to_missing?(method_name, include_private = false)
|
||||
qbo_model_class.method_defined?(method_name) || super
|
||||
@@ -73,12 +80,19 @@ class QboBaseModel < ActiveRecord::Base
|
||||
job.perform_later(id: id)
|
||||
end
|
||||
|
||||
# Flag used to update local without pushing to QBO.
|
||||
# This is used to prevent loops with the webhook
|
||||
def skip_qbo_push?
|
||||
!!skip_qbo_push
|
||||
!!skip_qbo_push
|
||||
end
|
||||
|
||||
def self.qbo_sync(push: true)
|
||||
self.qbo_push_enabled = push
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Log messages with a standarized prefix
|
||||
def log(msg)
|
||||
Rails.logger.info "[#{model_name.name}] #{msg}"
|
||||
end
|
||||
@@ -90,6 +104,7 @@ class QboBaseModel < ActiveRecord::Base
|
||||
service_class.new(qbo: qbo, local: self).pull()
|
||||
end
|
||||
|
||||
# Pushs the entity's details from QuickBooks Online.
|
||||
def push_to_qbo
|
||||
log "Starting push for #{model_name.name} ##{id}..."
|
||||
qbo = QboConnectionService.current!
|
||||
@@ -98,10 +113,12 @@ class QboBaseModel < ActiveRecord::Base
|
||||
return reslut
|
||||
end
|
||||
|
||||
# Dynamically get the Quickbooks Model Class
|
||||
def qbo_model_class
|
||||
@qbo_model_class ||= "Quickbooks::Model::#{model_name.name}".constantize
|
||||
end
|
||||
|
||||
# Dynamically get the Service Class
|
||||
def service_class
|
||||
@service_class ||= "#{model_name.name}Service".constantize
|
||||
end
|
||||
|
||||
@@ -17,16 +17,13 @@ class CustomerSyncService < SyncServiceBase
|
||||
Customer
|
||||
end
|
||||
|
||||
# Determine if the remote entity should be deleted locally (e.g. if it's marked inactive in QBO)
|
||||
def destroy_remote?(remote)
|
||||
# Determine if the local entity should be deleted (e.g. if it's marked inactive in QBO)
|
||||
def destroy_local?(remote)
|
||||
!remote.active?
|
||||
end
|
||||
|
||||
# Map relevant attributes from the QBO Customer to the local Customer model
|
||||
def process_attributes(local, remote)
|
||||
local.name = remote.display_name
|
||||
local.phone_number = remote.primary_phone&.free_form_number&.gsub(/\D/, '')
|
||||
local.mobile_phone_number = remote.mobile_phone&.free_form_number&.gsub(/\D/, '')
|
||||
end
|
||||
map_attribute :name, :display_name
|
||||
map_phone :phone_number, :primary_phone
|
||||
map_phone :mobile_phone_number, :mobile_phone
|
||||
|
||||
end
|
||||
@@ -17,14 +17,11 @@ class EmployeeSyncService < SyncServiceBase
|
||||
Employee
|
||||
end
|
||||
|
||||
# Determine if the remote entity should be deleted locally (e.g. if it's marked inactive in QBO)
|
||||
def destroy_remote?(remote)
|
||||
# Determine if the local entity should be deleted (e.g. if it's marked inactive in QBO)
|
||||
def destroy_local?(remote)
|
||||
!remote.active?
|
||||
end
|
||||
|
||||
# Map relevant attributes from the QBO Employee to the local Employee model
|
||||
def process_attributes(local, remote)
|
||||
local.name = remote.display_name
|
||||
end
|
||||
map_attribute :name, :display_name
|
||||
|
||||
end
|
||||
@@ -17,11 +17,7 @@ class EstimateSyncService < SyncServiceBase
|
||||
Estimate
|
||||
end
|
||||
|
||||
# Map relevant attributes from the QBO Estimate to the local Estimate model
|
||||
def process_attributes(local, remote)
|
||||
local.doc_number = remote.doc_number
|
||||
local.txn_date = remote.txn_date
|
||||
local.customer = Customer.find_by(id: remote.customer_ref&.value)
|
||||
end
|
||||
map_attributes :doc_number, :txn_date
|
||||
map_belongs_to :customer
|
||||
|
||||
end
|
||||
@@ -16,20 +16,15 @@ class InvoiceSyncService < SyncServiceBase
|
||||
def self.model_class
|
||||
Invoice
|
||||
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
|
||||
def attach_documents(local, remote)
|
||||
InvoiceAttachmentService.new(local, remote).attach
|
||||
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
|
||||
@@ -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.
|
||||
# If no local is provided, the service will not perform any operations.
|
||||
def initialize(qbo:, local: nil)
|
||||
@entity = local.class.name
|
||||
raise "No QBO configuration found" unless qbo
|
||||
raise "#{@entity} record is required for push operation" unless local
|
||||
@qbo = qbo
|
||||
@entity = local.class.name
|
||||
@local = local
|
||||
end
|
||||
|
||||
@@ -31,13 +31,7 @@ class ServiceBase
|
||||
return build_qbo_remote unless @local.present?
|
||||
return build_qbo_remote unless @local.id
|
||||
log "Fetching details for #{@entity} ##{@local.id} from QBO..."
|
||||
qbo = QboConnectionService.current!
|
||||
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
|
||||
)
|
||||
with_qbo_service do |service|
|
||||
service.fetch_by_id(@local.id)
|
||||
end
|
||||
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.
|
||||
def push
|
||||
log "Pushing #{@entity} ##{@local.id} to QBO..."
|
||||
|
||||
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
|
||||
)
|
||||
remote = with_qbo_service do |service|
|
||||
if @local.id.present?
|
||||
log "Updating #{@entity}"
|
||||
service.update(@local.details)
|
||||
@@ -66,17 +54,29 @@ class ServiceBase
|
||||
service.create(@local.details)
|
||||
end
|
||||
end
|
||||
|
||||
@local.id = remote.id unless @local.persisted?
|
||||
log "Push for remote ##{@local.id} completed."
|
||||
return @local
|
||||
@local
|
||||
end
|
||||
|
||||
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
|
||||
def log(msg)
|
||||
Rails.logger.info "[#{@entity}Service] #{msg}"
|
||||
end
|
||||
|
||||
# Dynamically get the Quickbooks Service Class
|
||||
def service_class
|
||||
@service_class ||= "Quickbooks::Service::#{@entity}".constantize
|
||||
end
|
||||
|
||||
end
|
||||
@@ -32,17 +32,11 @@ class SyncServiceBase
|
||||
# Sync all entities, or only those updated since the last sync
|
||||
def sync(full_sync: false)
|
||||
log "Starting #{full_sync ? 'full' : 'incremental'} #{@entity.name} sync with page size of: #{@page_size}"
|
||||
|
||||
@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)
|
||||
|
||||
query = build_query(full_sync)
|
||||
|
||||
with_qbo_service do |service|
|
||||
query = build_query(full_sync)
|
||||
service.query_in_batches(query, per_page: @page_size) do |batch|
|
||||
entries = Array(batch)
|
||||
log "Processing batch of #{entries.size} #{@entity.name}"
|
||||
|
||||
entries.each do |remote|
|
||||
persist(remote)
|
||||
end
|
||||
@@ -55,10 +49,7 @@ class SyncServiceBase
|
||||
# Sync a single entity by its QBO ID (webhook usage)
|
||||
def sync_by_id(id)
|
||||
log "Syncing #{@entity.name} with ID #{id}"
|
||||
|
||||
@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)
|
||||
with_qbo_service do |service|
|
||||
remote = service.fetch_by_id(id)
|
||||
persist(remote)
|
||||
end
|
||||
@@ -66,6 +57,10 @@ class SyncServiceBase
|
||||
|
||||
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
|
||||
def build_query(full_sync)
|
||||
if full_sync
|
||||
@@ -81,13 +76,125 @@ class SyncServiceBase
|
||||
end
|
||||
end
|
||||
|
||||
def attach_documents(local, remote)
|
||||
# Override in subclasses if the entity has attachments (e.g. Invoice)
|
||||
# Determine if a remote entity should be deleted locally (e.g. if it's marked inactive in QBO)
|
||||
def destroy_local?(remote)
|
||||
false
|
||||
end
|
||||
|
||||
# Determine if a remote entity should be deleted locally (e.g. if it's marked inactive in QBO)
|
||||
def destroy_remote?(remote)
|
||||
false
|
||||
def extract_value(remote, remote_attr)
|
||||
case remote_attr
|
||||
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
|
||||
|
||||
# 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
|
||||
def persist(remote)
|
||||
local = @entity.find_or_initialize_by(id: remote.id)
|
||||
|
||||
if destroy_remote?(remote)
|
||||
|
||||
if destroy_local?(remote)
|
||||
if local.persisted?
|
||||
local.destroy
|
||||
log "Deleted #{@entity.name} #{remote.id}"
|
||||
@@ -108,11 +215,12 @@ class SyncServiceBase
|
||||
end
|
||||
|
||||
process_attributes(local, remote)
|
||||
|
||||
if local.changed?
|
||||
|
||||
if local.new_record? || local.changed?
|
||||
was_new = local.new_record?
|
||||
local.skip_qbo_push = true
|
||||
local.save
|
||||
log "Updated #{@entity.name} #{remote.id}"
|
||||
local.save!
|
||||
log "#{was_new ? 'Created' : 'Updated'} #{@entity.name} #{remote.id}"
|
||||
attach_documents(local, remote)
|
||||
end
|
||||
|
||||
@@ -120,8 +228,29 @@ class SyncServiceBase
|
||||
log "Failed to sync #{@entity.name} #{remote.id}: #{e.message}"
|
||||
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)
|
||||
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
|
||||
2
init.rb
2
init.rb
@@ -14,7 +14,7 @@ Redmine::Plugin.register :redmine_qbo do
|
||||
name 'Redmine QBO plugin'
|
||||
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'
|
||||
version '2026.3.6'
|
||||
version '2026.3.8'
|
||||
url 'https://github.com/rickbarrette/redmine_qbo'
|
||||
author_url 'https://barrettefabrication.com'
|
||||
settings default: {empty: true}, partial: 'qbo/settings'
|
||||
|
||||
Reference in New Issue
Block a user