Compare commits

...

6 Commits

Author SHA1 Message Date
cd88ce6217 2026.3.5 2026-03-09 08:22:26 -04:00
b10665355d Allow Subclasses to override the page size 2026-03-08 23:34:37 -04:00
17ac19e435 2026.3.4 2026-03-08 21:59:47 -04:00
ef5089438c Fixed query_in_batches 2026-03-08 21:51:31 -04:00
1f64e36892 2026.3.3 2026-03-08 15:22:20 -04:00
643b15391b Added support for adding other QBO entities from othe plugins 2026-03-08 15:04:57 -04:00
5 changed files with 70 additions and 44 deletions

View File

@@ -117,12 +117,14 @@ Adds support for tracking **customer vehicles** associated with Issues.
Available hooks: Available hooks:
|Type|Hook| |Type|Hook|Note
|--|---| |--|--|--|
View Hook|:pdf\_left, { issue: issue } View Hook|:pdf_left, { issue: issue } | Used to add text to left side of PDF
View Hook|:pdf\_right, { issue: issue } View Hook|:pdf_right, { issue: issue } | Used to add text to right side of PDF
Hook|process\_invoice\_custom\_fields, { issue: issue, invoice: invoice } Hook|process_invoice_custom_fields, { issue: issue, invoice: invoice } | Used to process invoice custom fields
View Hook|:show\_customer\_view\_right, { customer: customer } View Hook|:show_customer_view_right, { customer: customer } | Used to show partials on right side of customer view
Hook| :qbo_additional_entities | Used to add additional entites to be processed by the WebhookProcessJob
Hook| :qbo_full_sync | Used to add a Class to be called by the QboSyncDispatcher
--- ---

View File

@@ -19,6 +19,23 @@ class QboSyncDispatcher
# Dispatches all synchronization jobs to perform a full sync of QuickBooks entities with the local database. Each job is enqueued with the `full_sync` flag set to true. # Dispatches all synchronization jobs to perform a full sync of QuickBooks entities with the local database. Each job is enqueued with the `full_sync` flag set to true.
def self.full_sync! def self.full_sync!
SYNC_JOBS.each { |job| job.perform_later(full_sync: true) }
jobs = SYNC_JOBS.dup
# Allow other plugins to add addtional sync jobs via Hooks
Redmine::Hook.call_hook( :qbo_full_sync ).each do |context|
next unless context
jobs.push context
log "Added additionals QBO Sync Job for #{contex.to_s}"
end
jobs.each { |job| job.perform_later(full_sync: true) }
end end
private
def self.log(msg)
Rails.logger.info "[QboSyncDispatcher] #{msg}"
end
end end

View File

@@ -43,7 +43,14 @@ class WebhookProcessJob < ActiveJob::Base
name = entity['name'] name = entity['name']
id = entity['id']&.to_i id = entity['id']&.to_i
return unless ALLOWED_ENTITIES.include?(name) entitys = ALLOWED_ENTITIES.dup
# Allow other plugins to add addtional qbo entities via Hooks
Redmine::Hook.call_hook( :qbo_additional_entities ).each do |context|
next unless context
entitys.push context
log "Added additional QBO entities: #{context}"
end
return unless entitys.include?(name)
model = name.safe_constantize model = name.safe_constantize
return unless model return unless model

View File

@@ -16,6 +16,12 @@ class SyncServiceBase
raise "No QBO configuration found" unless qbo raise "No QBO configuration found" unless qbo
@qbo = qbo @qbo = qbo
@entity = self.class.model_class @entity = self.class.model_class
@page_size = self.class.page_size
end
# Subclasses can implement this to overide the default page size
def self.page_size
@page_size = PAGE_SIZE
end end
# Subclasses must implement this to specify which local model they sync (e.g. Customer, Invoice) # Subclasses must implement this to specify which local model they sync (e.g. Customer, Invoice)
@@ -25,34 +31,35 @@ 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" log "Starting #{full_sync ? 'full' : 'incremental'} #{@entity.name} sync with page size of: #{@page_size}"
@qbo.perform_authenticated_request do |access_token| @qbo.perform_authenticated_request do |access_token|
service_class = "Quickbooks::Service::#{@entity.name}".constantize service_class = "Quickbooks::Service::#{@entity.name}".constantize
service = service_class.new(company_id: @qbo.realm_id, access_token: access_token) service = service_class.new(company_id: @qbo.realm_id, access_token: access_token)
page = 1 query = build_query(full_sync)
loop do
collection = fetch_page(service, page, full_sync)
entries = Array(collection&.entries)
break if entries.empty?
entries.each { |remote| persist(remote) } service.query_in_batches(query, per_page: @page_size) do |batch|
entries = Array(batch)
log "Processing batch of #{entries.size} #{@entity.name}"
break if entries.size < PAGE_SIZE entries.each do |remote|
page += 1 persist(remote)
end
end end
end end
log "#{@entity.name} sync complete" log "#{@entity.name} sync complete"
end end
# Sync a single entity by its QBO ID, used for webhook updates # 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}"
@qbo.perform_authenticated_request do |access_token| @qbo.perform_authenticated_request do |access_token|
service_class = "Quickbooks::Service::#{@entity.name}".constantize service_class = "Quickbooks::Service::#{@entity.name}".constantize
service = service_class.new(company_id: @qbo.realm_id, access_token: access_token) 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
@@ -60,6 +67,21 @@ class SyncServiceBase
private private
# Builds a QBO query for retrieving entities
def build_query(full_sync)
if full_sync
"SELECT * FROM #{@entity.name} ORDER BY Id"
else
last_update = @entity.maximum(:updated_at) || 1.year.ago
<<~SQL.squish
SELECT * FROM #{@entity.name}
WHERE MetaData.LastUpdatedTime > '#{last_update.utc.iso8601}'
ORDER BY MetaData.LastUpdatedTime
SQL
end
end
def attach_documents(local, remote) def attach_documents(local, remote)
# Override in subclasses if the entity has attachments (e.g. Invoice) # Override in subclasses if the entity has attachments (e.g. Invoice)
end end
@@ -74,24 +96,6 @@ class SyncServiceBase
Rails.logger.info "[#{@entity.name}SyncService] #{msg}" Rails.logger.info "[#{@entity.name}SyncService] #{msg}"
end end
# Fetch a page of entities, either all or only those updated since the last sync
def fetch_page(service, page, full_sync)
log "Fetching page #{page} of #{@entity.name} from QBO (#{full_sync ? 'full' : 'incremental'} sync)"
start_position = (page - 1) * PAGE_SIZE + 1
if full_sync
service.query("SELECT * FROM #{@entity.name} STARTPOSITION #{start_position} MAXRESULTS #{PAGE_SIZE}")
else
last_update = @entity.maximum(:updated_at) || 1.year.ago
service.query(<<~SQL.squish)
SELECT * FROM #{@entity.name}
WHERE MetaData.LastUpdatedTime > '#{last_update.utc.iso8601}'
STARTPOSITION #{start_position}
MAXRESULTS #{PAGE_SIZE}
SQL
end
end
# 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)
@@ -104,24 +108,20 @@ class SyncServiceBase
return return
end end
# Map remote attributes to local model fields, this should be implemented in subclasses
process_attributes(local, remote) process_attributes(local, remote)
if local.changed? if local.changed?
local.save! local.save!
log "Updated #{@entity.name} #{remote.id}" log "Updated #{@entity.name} #{remote.id}"
# Handle attaching documents if applicable to invoices
attach_documents(local, remote) attach_documents(local, remote)
end end
rescue => e rescue => e
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 # This method should be implemented in subclasses to map remote attributes to local model
def process_attributes(local, remote) def process_attributes(local, remote)
raise NotImplementedError, "Subclasses must implement process_attributes" raise NotImplementedError, "Subclasses must implement process_attributes"
end end
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.3.2' version '2026.3.5'
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'