Compare commits

..

18 Commits

Author SHA1 Message Date
0a2d38a927 Update plugin version to 2026.2.16
Updated to use active jobs & services for all background work
2026-02-28 09:29:50 -05:00
b80dbaa015 Fix: Update last_update query to use correct timestamp field for customer sync 2026-02-28 09:11:01 -05:00
9e399b934b Fix: Update last_update query to use correct timestamp field for employee sync 2026-02-28 09:10:55 -05:00
cc6fd07435 Update notice for missing estimate to include syncing information with QuickBooks 2026-02-28 08:53:46 -05:00
7a50df24d9 Add logging to get_estimate method for better debugging 2026-02-28 08:53:38 -05:00
ca02ead9f9 Use delete not destroy 2026-02-28 08:35:51 -05:00
9089adaba0 removed uneeded comments 2026-02-28 08:35:35 -05:00
dc6eba8566 Refactor logging in PdfPatch to use custom log method for better clarity and consistency 2026-02-28 08:29:10 -05:00
19911b7940 Refactor EstimateSyncJob to support syncing by ID and document number; add EstimateSyncService for handling estimate synchronization 2026-02-28 08:24:25 -05:00
a80f59cc45 Refactor sync_by_id method in Estimate model to use EstimateSyncJob for syncing 2026-02-28 07:50:23 -05:00
eee99e4d83 Implement CustomerSyncService for customer synchronization and update CustomerSyncJob to support syncing by ID 2026-02-28 07:50:07 -05:00
b3f01bd372 Refactor persist method in InvoiceSyncService to use 'local' variable for clarity and add logging for updates 2026-02-28 07:41:22 -05:00
d1ba93d61a Refactor persist method in EmployeeSyncService to use 'local' variable for clarity 2026-02-28 07:41:05 -05:00
9a688c4841 Set primary_key 2026-02-27 23:39:34 -05:00
e94352e2c4 Added comment 2026-02-27 23:19:44 -05:00
ea0f42b68e Added comment 2026-02-27 23:18:40 -05:00
5a31c194a5 Added comments 2026-02-27 23:15:37 -05:00
6f8af9bba8 Implement Employee synchronization; add EmployeeSyncJob and EmployeeSyncService for improved background processing and logging 2026-02-27 23:07:12 -05:00
19 changed files with 411 additions and 263 deletions

View File

@@ -15,6 +15,7 @@ class EstimateController < ApplicationController
skip_before_action :verify_authenticity_token, :check_if_login_required, unless: proc {|c| session[:token].nil? } skip_before_action :verify_authenticity_token, :check_if_login_required, unless: proc {|c| session[:token].nil? }
def get_estimate def get_estimate
log "Searching for estimate with params: #{params.inspect}"
e = Estimate.find_by_doc_number(params[:search]) if params[:search] e = Estimate.find_by_doc_number(params[:search]) if params[:search]
e = Estimate.find_by_id(params[:id]) if params[:id] e = Estimate.find_by_id(params[:id]) if params[:id]

View File

@@ -96,17 +96,7 @@ class QboController < ApplicationController
CustomerSyncJob.perform_later(full_sync: true) CustomerSyncJob.perform_later(full_sync: true)
EstimateSyncJob.perform_later(full_sync: true) EstimateSyncJob.perform_later(full_sync: true)
InvoiceSyncJob.perform_later(full_sync: true) InvoiceSyncJob.perform_later(full_sync: true)
EmployeeSyncJob.perform_later(full_sync: true)
# Update info in background
Thread.new do
if Qbo.exists?
Employee.sync
# Record the last sync time
Qbo.update_time_stamp
end
ActiveRecord::Base.connection.close
end
redirect_to :home, flash: { notice: I18n.t(:label_syncing) } redirect_to :home, flash: { notice: I18n.t(:label_syncing) }
end end

View File

@@ -12,84 +12,20 @@ class CustomerSyncJob < ApplicationJob
queue_as :default queue_as :default
retry_on StandardError, wait: 5.minutes, attempts: 5 retry_on StandardError, wait: 5.minutes, attempts: 5
def perform(full_sync: false) # Perform a full sync of all customers, or an incremental sync of only those updated since the last sync
def perform(full_sync: false, id: nil)
qbo = Qbo.first qbo = Qbo.first
return unless qbo return unless qbo
log "Starting #{full_sync ? 'full' : 'incremental'} sync..." log "Starting #{full_sync ? 'full' : 'incremental'} sync for customer ##{id || 'all'}..."
qbo = Qbo.first service = CustomerSyncService.new(qbo: qbo)
qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Customer.new(company_id: qbo.realm_id, access_token: access_token) if id.present?
service.sync_by_id(id)
page = 1
loop do
collection = fetch_customers(service, page, full_sync)
entries = Array(collection&.entries)
break if entries.empty?
entries.each { |c| sync_customer(c) }
page += 1
break if entries.size < 1000
end
log "Completed sync."
Qbo.update_time_stamp
end
rescue => e
log "Fatal error: #{e.message}"
log e.backtrace.join("\n")
raise # allows retry
end
private
# Fetch either all or incremental customers
def fetch_customers(service, page, full_sync)
start_position = (page - 1) * 1000 + 1
if full_sync
service.query("SELECT * FROM Customer STARTPOSITION #{start_position} MAXRESULTS 1000")
else else
last_update = Customer.maximum(:updated_at) || 1.year.ago service.sync(full_sync: full_sync)
service.query(<<~SQL.squish)
SELECT * FROM Customer
WHERE MetaData.LastUpdatedTime > '#{last_update.utc.iso8601}'
STARTPOSITION #{start_position}
MAXRESULTS 1000
SQL
end end
rescue => e
log "Failed to fetch page #{page}: #{e.message}"
nil
end
# Sync a single customer record
def sync_customer(c)
log "Processing customer #{c.id} / #{c.display_name} (active=#{c.active?})"
customer = Customer.find_or_initialize_by(id: c.id)
if c.active?
customer.name = c.display_name
customer.phone_number = c.primary_phone&.free_form_number&.gsub(/\D/, '')
customer.mobile_phone_number = c.mobile_phone&.free_form_number&.gsub(/\D/, '')
if customer.changed?
customer.save_without_push
log "Updated customer #{c.id}"
end
else
if customer.persisted? && customer.active?
customer.destroy
log "Deleted customer #{c.id}"
end
end
rescue => e
log "Failed to sync customer #{c.id}: #{e.message}"
end end
private private

View File

@@ -0,0 +1,35 @@
#The MIT License (MIT)
#
#Copyright (c) 2016 - 2026 rick barrette
#
#Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
#
#The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
#
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class EmployeeSyncJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: 5.minutes, attempts: 5
def perform(full_sync: false, id: nil)
qbo = Qbo.first
return unless qbo
log "Starting #{full_sync ? 'full' : 'incremental'} sync for employee ##{id || 'all'}..."
service = EmployeeSyncService.new(qbo: qbo)
if id.present?
service.sync_by_id(id)
else
service.sync(full_sync: full_sync)
end
end
private
def log(msg)
Rails.logger.info "[EmployeeSyncJob] #{msg}"
end
end

View File

@@ -12,84 +12,25 @@ class EstimateSyncJob < ApplicationJob
queue_as :default queue_as :default
retry_on StandardError, wait: 5.minutes, attempts: 5 retry_on StandardError, wait: 5.minutes, attempts: 5
# Sync estimates from QuickBooks Online to local database def perform(full_sync: false, id: nil, doc_number: nil)
def perform(full_sync: false)
qbo = Qbo.first qbo = Qbo.first
return unless qbo return unless qbo
log "Starting #{full_sync ? 'full' : 'incremental'} sync..." log "Starting #{full_sync ? 'full' : 'incremental'} sync for estimate ##{id || doc_number || 'all'}..."
qbo = Qbo.first service = EstimateSyncService.new(qbo: qbo)
qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token) if id.present?
service.sync_by_id(id)
page = 1 elsif doc_number.present?
loop do service.sync_by_doc(doc_number)
collection = fetch_estimates(service, page, full_sync) else
entries = Array(collection&.entries) service.sync(full_sync: full_sync)
break if entries.empty?
entries.each { |c| sync_estimates(c) }
page += 1
break if entries.size < 1000
end end
log "Completed sync."
Qbo.update_time_stamp
end
rescue => e
log "Fatal error: #{e.message}"
log e.backtrace.join("\n")
raise # allows retry
end end
private private
# Fetch either all or incremental estimates
def fetch_estimates(service, page, full_sync)
start_position = (page - 1) * 1000 + 1
if full_sync
service.query("SELECT * FROM Estimate STARTPOSITION #{start_position} MAXRESULTS 1000")
else
last_update = Estimate.maximum(:updated_at) || 1.year.ago
service.query(<<~SQL.squish)
SELECT * FROM Estimate
WHERE MetaData.LastUpdatedTime > '#{last_update.utc.iso8601}'
STARTPOSITION #{start_position}
MAXRESULTS 1000
SQL
end
rescue => e
log "Failed to fetch page #{page}: #{e.message}"
nil
end
# Sync a single estimate record
def sync_estimates(e)
log "Processing estimate #{e.id} doc=#{e.doc_number} status=#{e.txn_status}"
estimate = Estimate.find_or_initialize_by(id: e.id)
estimate.doc_number = e.doc_number
estimate.estimate_id = e.estimate_ref.value
estimate.txn_date = e.txn_date
if estimate.changed?
estimate.save
log "Updated estimate #{e.id}"
end
end
# TODO remove deleted estimates
rescue => error
log "Failed to sync estimate #{e.id}: #{error.message}"
end
def log(msg) def log(msg)
Rails.logger.info "[EstimateSyncJob] #{msg}" Rails.logger.info "[EstimateSyncJob] #{msg}"
end end

View File

@@ -15,6 +15,7 @@ class WebhookProcessJob < ActiveJob::Base
Customer Customer
Invoice Invoice
Estimate Estimate
Employee
].freeze ].freeze
# Process incoming QBO webhook notifications and sync relevant data to Redmine # Process incoming QBO webhook notifications and sync relevant data to Redmine
@@ -47,12 +48,12 @@ class WebhookProcessJob < ActiveJob::Base
return unless model return unless model
if entity['deletedId'] if entity['deletedId']
model.destroy(entity['deletedId']) model.delete(entity['deletedId'])
return return
end end
if entity['operation'] == "Delete" if entity['operation'] == "Delete"
model.destroy(id) model.delete(id)
else else
model.sync_by_id(id) model.sync_by_id(id)
end end

View File

@@ -150,12 +150,6 @@ class Customer < ActiveRecord::Base
end end
end end
# proforms a bruteforce sync operation
# This needs to be simplified
def self.sync
CustomerSyncJob.perform_later(full_sync: false)
end
# Seach for customers by name or phone number # Seach for customers by name or phone number
def self.search(search) def self.search(search)
search = sanitize_sql_like(search) search = sanitize_sql_like(search)
@@ -177,30 +171,13 @@ class Customer < ActiveRecord::Base
end end
# proforms a bruteforce sync operation # proforms a bruteforce sync operation
# This needs to be simplified def self.sync
CustomerSyncJob.perform_later(full_sync: false)
end
# proforms a bruteforce sync operation
def self.sync_by_id(id) def self.sync_by_id(id)
qbo = Qbo.first CustomerSyncJob.perform_later(id: id)
c = qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Customer.new(company_id: qbo.realm_id, access_token: access_token)
service.fetch_by_id(id)
end
return unless c
customer = Customer.find_or_create_by(id: c.id)
if c.active?
#if not customer.name.eql? c.display_name
customer.name = c.display_name
customer.id = c.id
customer.phone_number = c.primary_phone.free_form_number.tr('^0-9', '') unless c.primary_phone.nil?
customer.mobile_phone_number = c.mobile_phone.free_form_number.tr('^0-9', '') unless c.mobile_phone.nil?
customer.save_without_push
#end
else
if not customer.new_record?
customer.delete
end
end
end end
# returns a human readable string # returns a human readable string

View File

@@ -21,14 +21,17 @@ class CustomerToken < ApplicationRecord
TOKEN_EXPIRATION = 1.month TOKEN_EXPIRATION = 1.month
# Check if the token has expired
def expired? def expired?
expires_at.present? && expires_at <= Time.current expires_at.present? && expires_at <= Time.current
end end
# Remove expired tokens from the database
def self.remove_expired_tokens def self.remove_expired_tokens
where("expires_at <= ?", Time.current).delete_all where("expires_at <= ?", Time.current).delete_all
end end
# Get or create a token for the given issue
def self.get_token(issue) def self.get_token(issue)
return unless issue return unless issue
return unless User.current.allowed_to?(:view_issues, issue.project) return unless User.current.allowed_to?(:view_issues, issue.project)
@@ -41,10 +44,12 @@ class CustomerToken < ApplicationRecord
private private
# Generate a unique token for the customer
def generate_token def generate_token
self.token ||= SecureRandom.urlsafe_base64(32) self.token ||= SecureRandom.urlsafe_base64(32)
end end
# Generate an expiration date for the token
def generate_expire_date def generate_expire_date
self.expires_at ||= Time.current + TOKEN_EXPIRATION self.expires_at ||= Time.current + TOKEN_EXPIRATION
end end

View File

@@ -13,37 +13,16 @@ class Employee < ActiveRecord::Base
has_many :users has_many :users
validates_presence_of :id, :name validates_presence_of :id, :name
self.primary_key = :id
# Sync all employees, typically triggered by a scheduled task or manual sync request
def self.sync def self.sync
qbo = Qbo.first EmployeeSyncJob.perform_later(full_sync: true)
employees = qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Employee.new(company_id: qbo.realm_id, access_token: access_token)
service.all
end
return unless employees
transaction do
employees.each { |e|
logger.info "Processing employee #{e.id}"
employee = find_or_create_by(id: e.id)
employee.name = e.display_name
employee.id = e.id
employee.save!
}
end
end end
# Sync a single employee by ID, typically triggered by a webhook notification or manual sync request
def self.sync_by_id(id) def self.sync_by_id(id)
qbo = Qbo.first EmployeeSyncJob.perform_later(id: id)
employee = qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Employee.new(company_id: qbo.realm_id, access_token: access_token)
service.fetch_by_id(id)
end end
return unless employee
employee = find_or_create_by(id: employee.id)
employee.name = employee.display_name
employee.id = employee.id
employee.save!
end
end end

View File

@@ -27,53 +27,17 @@ class Estimate < ActiveRecord::Base
# sync only one estimate # sync only one estimate
def self.sync_by_id(id) def self.sync_by_id(id)
log "Syncing estimate #{id}" EstimateSyncJob.perform_later(id: id)
qbo = Qbo.first
qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
process_estimate(service.fetch_by_id(id))
end
end end
# sync only one estimate # sync only one estimate
def self.sync_by_doc_number(number) def self.sync_by_doc_number(number)
log "Syncing estimate by doc number #{number}" EstimateSyncJob.perform_later(doc_number: number)
qbo = Qbo.first
qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
process_estimate(service.find_by( :doc_number, number).first)
end
end
# update an estimate
def self.update(id)
qbo = Qbo.first
estimate = qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
service.fetch_by_id(id)
end
return unless estimate
e = find_or_create_by(id: id)
e.doc_number = estimate.doc_number
e.txn_date = estimate.txn_date
e.save!
end
# process an estimate into the database
def self.process_estimate(qbo_estimate)
log "Processing estimate #{qbo_estimate.id}"
estimate = find_or_create_by(id: qbo_estimate.id)
estimate.doc_number = qbo_estimate.doc_number
estimate.customer_id = qbo_estimate.customer_ref.value
estimate.id = qbo_estimate.id
estimate.txn_date = qbo_estimate.txn_date
estimate.save!
end end
# download the pdf from quickbooks # download the pdf from quickbooks
def pdf def pdf
log "Downloading PDF for estimate ##{self.id}..."
qbo = Qbo.first qbo = Qbo.first
qbo.perform_authenticated_request do |access_token| qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token) service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
@@ -104,6 +68,7 @@ class Estimate < ActiveRecord::Base
# pull the details # pull the details
def pull def pull
log "Pulling details for estimate ##{self.id}..."
begin begin
raise Exception unless self.id raise Exception unless self.id
qbo = Qbo.first qbo = Qbo.first

View File

@@ -22,6 +22,7 @@ class Invoice < ActiveRecord::Base
doc_number doc_number
end end
# Sync all invoices from QuickBooks, typically triggered by a scheduled task or manual sync request
def self.sync def self.sync
InvoiceSyncJob.perform_later(full_sync: true) InvoiceSyncJob.perform_later(full_sync: true)
end end

View File

@@ -0,0 +1,95 @@
#The MIT License (MIT)
#
#Copyright (c) 2016 - 2026 rick barrette
#
#Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
#
#The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
#
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class CustomerSyncService
PAGE_SIZE = 1000
def initialize(qbo:)
@qbo = qbo
end
# Sync all customers, or only those updated since the last sync
def sync(full_sync: false)
log "Starting #{full_sync ? 'full' : 'incremental'} customer sync"
@qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Customer.new(company_id: @qbo.realm_id, access_token: access_token)
page = 1
loop do
collection = fetch_page(service, page, full_sync)
entries = Array(collection&.entries)
break if entries.empty?
entries.each { |remote| persist(remote) }
break if entries.size < PAGE_SIZE
page += 1
end
end
log "Customer sync complete"
end
# Sync a single customer by its QBO ID, used for webhook updates
def sync_by_id(id)
@qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Customer.new(company_id: @qbo.realm_id, access_token: access_token)
remote = service.fetch_by_id(id)
persist(remote)
end
end
private
# Fetch a page of customers, either all or only those updated since the last sync
def fetch_page(service, page, full_sync)
start_position = (page - 1) * PAGE_SIZE + 1
if full_sync
service.query("SELECT * FROM Customer STARTPOSITION #{start_position} MAXRESULTS #{PAGE_SIZE}")
else
last_update = Customer.maximum(:updated_at) || 1.year.ago
service.query(<<~SQL.squish)
SELECT * FROM Customer
WHERE MetaData.LastUpdatedTime > '#{last_update.utc.iso8601}'
STARTPOSITION #{start_position}
MAXRESULTS #{PAGE_SIZE}
SQL
end
end
# Create or update a local Customer record based on the QBO remote data
def persist(remote)
local = Customer.find_or_initialize_by(id: remote.id)
if remote.active?
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/, '')
if local.changed?
local.save
log "Updated customer #{remote.id}"
end
else
if local.persisted?
local.destroy
log "Deleted customer #{remote.id}"
end
end
rescue => e
log "Failed to sync customer #{remote.id}: #{e.message}"
end
def log(msg)
Rails.logger.info "[CustomerSyncService] #{msg}"
end
end

View File

@@ -0,0 +1,93 @@
#The MIT License (MIT)
#
#Copyright (c) 2016 - 2026 rick barrette
#
#Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
#
#The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
#
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class EmployeeSyncService
PAGE_SIZE = 1000
def initialize(qbo:)
@qbo = qbo
end
# Sync all employees, or only those updated since the last sync
def sync(full_sync: false)
log "Starting #{full_sync ? 'full' : 'incremental'} employee sync"
@qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Employee.new(company_id: @qbo.realm_id, access_token: access_token)
page = 1
loop do
collection = fetch_page(service, page, full_sync)
entries = Array(collection&.entries)
break if entries.empty?
entries.each { |remote| persist(remote) }
break if entries.size < PAGE_SIZE
page += 1
end
end
log "Employee sync complete"
end
# Sync a single employee by its QBO ID, used for webhook updates
def sync_by_id(id)
@qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Employee.new(company_id: @qbo.realm_id, access_token: access_token)
remote = service.fetch_by_id(id)
persist(remote)
end
end
private
# Fetch a page of employees, either all or only those updated since the last sync
def fetch_page(service, page, full_sync)
start_position = (page - 1) * PAGE_SIZE + 1
if full_sync
service.query("SELECT * FROM Employee STARTPOSITION #{start_position} MAXRESULTS #{PAGE_SIZE}")
else
last_update = Employee.maximum(:updated_at) || 1.year.ago
service.query(<<~SQL.squish)
SELECT * FROM Employee
WHERE MetaData.LastUpdatedTime > '#{last_update.utc.iso8601}'
STARTPOSITION #{start_position}
MAXRESULTS #{PAGE_SIZE}
SQL
end
end
# Create or update a local Employee record based on the QBO remote data
def persist(remote)
local = Employee.find_or_initialize_by(id: remote.id)
if remote.active?
local.name = remote.display_name
if local.changed?
local.save
log "Updated employee #{remote.id}"
end
else
if local.persisted?
local.destroy
log "Deleted employee #{remote.id}"
end
end
rescue => e
log "Failed to sync employee #{remote.id}: #{e.message}"
end
def log(msg)
Rails.logger.info "[EmployeeSyncService] #{msg}"
end
end

View File

@@ -0,0 +1,102 @@
#The MIT License (MIT)
#
#Copyright (c) 2016 - 2026 rick barrette
#
#Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
#
#The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
#
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class EstimateSyncService
PAGE_SIZE = 1000
def initialize(qbo:)
@qbo = qbo
end
# Sync all estimates, or only those updated since the last sync
def sync(full_sync: false)
log "Starting #{full_sync ? 'full' : 'incremental'} estimate sync"
@qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Estimate.new(company_id: @qbo.realm_id, access_token: access_token)
page = 1
loop do
collection = fetch_page(service, page, full_sync)
entries = Array(collection&.entries)
break if entries.empty?
entries.each { |remote| persist(remote) }
break if entries.size < PAGE_SIZE
page += 1
end
end
log "Estimate sync complete"
end
# Sync a single estimate by its QBO ID, used for webhook updates
def sync_by_doc(doc_number)
log "Syncing estimate by doc_number: #{doc_number}"
@qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Estimate.new(company_id: @qbo.realm_id, access_token: access_token)
remote = service.find_by( :doc_number, doc_number).first
log "Found estimate with ID #{remote.id} for doc_number #{doc_number}" if remote
persist(remote)
end
end
# Sync a single estimate by its QBO ID, used for webhook updates
def sync_by_id(id)
log "Syncing estimate by ID: #{id}"
@qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Estimate.new(company_id: @qbo.realm_id, access_token: access_token)
remote = service.fetch_by_id(id)
persist(remote)
end
end
private
# Fetch a page of estimates, either all or only those updated since the last sync
def fetch_page(service, page, full_sync)
log "Fetching page #{page} of estimates (full_sync: #{full_sync})"
start_position = (page - 1) * PAGE_SIZE + 1
if full_sync
service.query("SELECT * FROM Estimate STARTPOSITION #{start_position} MAXRESULTS #{PAGE_SIZE}")
else
last_update = Estimate.maximum(:updated_at) || 1.year.ago
service.query(<<~SQL.squish)
SELECT * FROM Estimate
WHERE MetaData.LastUpdatedTime > '#{last_update.utc.iso8601}'
STARTPOSITION #{start_position}
MAXRESULTS #{PAGE_SIZE}
SQL
end
end
# Create or update a local Estimate record based on the QBO remote data
def persist(remote)
log "Persisting estimate #{remote.id}"
local = Estimate.find_or_initialize_by(id: remote.id)
local.doc_number = remote.doc_number
local.txn_date = remote.txn_date
local.customer = Customer.find_by(id: remote.customer_ref&.value)
if local.changed?
local.save
log "Updated estimate #{remote.id}"
end
rescue => e
log "Failed to sync estimate #{remote.id}: #{e.message}"
end
def log(msg)
Rails.logger.info "[EstimateSyncService] #{msg}"
end
end

View File

@@ -68,20 +68,25 @@ class InvoiceSyncService
# Create or update a local Invoice record based on the QBO remote data # Create or update a local Invoice record based on the QBO remote data
def persist(remote) def persist(remote)
invoice = Invoice.find_or_initialize_by(id: remote.id) local = Invoice.find_or_initialize_by(id: remote.id)
invoice.doc_number = remote.doc_number local.doc_number = remote.doc_number
invoice.txn_date = remote.txn_date local.txn_date = remote.txn_date
invoice.due_date = remote.due_date local.due_date = remote.due_date
invoice.total_amount = remote.total local.total_amount = remote.total
invoice.balance = remote.balance local.balance = remote.balance
invoice.qbo_updated_at = remote.meta_data&.last_updated_time local.qbo_updated_at = remote.meta_data&.last_updated_time
invoice.customer = Customer.find_by(id: remote.customer_ref&.value) local.customer = Customer.find_by(id: remote.customer_ref&.value)
invoice.save! if local.changed?
local.save
log "Updated invoice #{remote.doc_number} (#{remote.id})"
end
InvoiceAttachmentService.new(invoice, remote).attach InvoiceAttachmentService.new(local, remote).attach
rescue => e
log "Failed to sync invoice #{remote.doc_number} (#{remote.id}): #{e.message}"
end end
def log(msg) def log(msg)

View File

@@ -98,7 +98,7 @@ en:
notice_error_project_nil: "The issue's project is nil. Set project to:" notice_error_project_nil: "The issue's project is nil. Set project to:"
notice_error_tracker_nil: "The issue's tracker is nil. Set tracker to:" notice_error_tracker_nil: "The issue's tracker is nil. Set tracker to:"
notice_estimate_created: "Estimate created in QuickBooks" notice_estimate_created: "Estimate created in QuickBooks"
notice_estimate_not_found: "Estimate not found" notice_estimate_not_found: "Estimate not found, we are syncing with QuickBooks to find it. Please check back shortly."
notice_estimate_updated: "Estimate updated in QuickBooks" notice_estimate_updated: "Estimate updated in QuickBooks"
notice_forbidden: "You do not have permission to access this resource." notice_forbidden: "You do not have permission to access this resource."
notice_invoice_created: "Invoice created in QuickBooks" notice_invoice_created: "Invoice created in QuickBooks"

View File

@@ -0,0 +1,15 @@
#The MIT License (MIT)
#
#Copyright (c) 2016 - 2026 rick barrette
#
#Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
#
#The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
#
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class AddEmployeeTimestamp < ActiveRecord::Migration[7.0]
def change
add_timestamps(:employees, null: true)
end
end

View File

@@ -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.2.15' version '2026.2.16'
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'

View File

@@ -58,7 +58,7 @@ module RedmineQbo
#left << [l(:field_category), issue.category] unless issue.disabled_core_fields.include?(:category_id) #left << [l(:field_category), issue.category] unless issue.disabled_core_fields.include?(:category_id)
#left << [l(:field_fixed_version), issue.fixed_version] unless issue.disabled_core_fields.include?(:fixed_version_id) #left << [l(:field_fixed_version), issue.fixed_version] unless issue.disabled_core_fields.include?(:fixed_version_id)
logger.debug "Calling :pdf_left hook" log "Calling :pdf_left hook"
left_hook_output = Redmine::Hook.call_hook :pdf_left, { issue: issue } left_hook_output = Redmine::Hook.call_hook :pdf_left, { issue: issue }
unless left_hook_output.nil? unless left_hook_output.nil?
left_hook_output.each do |l| left_hook_output.each do |l|
@@ -73,7 +73,7 @@ module RedmineQbo
right << [l(:field_estimated_hours), l_hours(issue.estimated_hours)] unless issue.disabled_core_fields.include?(:estimated_hours) right << [l(:field_estimated_hours), l_hours(issue.estimated_hours)] unless issue.disabled_core_fields.include?(:estimated_hours)
right << [l(:label_spent_time), l_hours(issue.total_spent_hours)] if User.current.allowed_to?(:view_time_entries, issue.project) right << [l(:label_spent_time), l_hours(issue.total_spent_hours)] if User.current.allowed_to?(:view_time_entries, issue.project)
logger.debug "Calling :pdf_right hook" log "Calling :pdf_right hook"
right_hook_output = Redmine::Hook.call_hook :pdf_right, { issue: issue } right_hook_output = Redmine::Hook.call_hook :pdf_right, { issue: issue }
unless right_hook_output.nil? unless right_hook_output.nil?
right_hook_output.each do |r| right_hook_output.each do |r|
@@ -269,6 +269,13 @@ module RedmineQbo
end end
end end
private
def log(msg)
Rails.logger.info "[PdfPatch] #{msg}"
end
end end
Redmine::Export::PDF::IssuesPdfHelper.send(:include, PdfPatch) Redmine::Export::PDF::IssuesPdfHelper.send(:include, PdfPatch)