Compare commits

...

12 Commits

18 changed files with 349 additions and 417 deletions

View File

@@ -8,71 +8,72 @@
#
#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 EstimateController < ApplicationController
include AuthHelper
before_action :require_user, unless: -> { session[:token].nil? }
skip_before_action :verify_authenticity_token, :check_if_login_required, unless: -> { session[:token].nil? }
before_action :load_estimate, only: [:show, :doc]
before_action :require_user, 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
log "Searching for estimate with params: #{params.inspect}"
e = Estimate.find_by_doc_number(params[:search]) if params[:search]
e = Estimate.find_by_id(params[:id]) if params[:id]
# Force sync for estimate by doc number if not found
if e.nil? && params[:search]
begin
Estimate.sync_by_doc_number(params[:search])
e = Estimate.find_by_doc_number(params[:search])
rescue
log "Estimate.find_by_doc_number failed"
end
end
# Force sync for estimate by id if not found
if e.nil? && params[:id]
begin
Estimate.sync_by_id(params[:id])
e = Estimate.find_by_id(params[:id])
rescue
log "Estimate.find_by_id failed"
end
end
return e
end
#
# Downloads and forwards the estimate pdf
#
def show
estimate = get_estimate
begin
send_data estimate.pdf, filename: "estimate #{estimate.doc_number}.pdf", disposition: :inline, type: "application/pdf"
rescue
redirect_to :back, flash: { error: I18n.t(:notice_estimate_not_found) }
end
end
#
# Downloads estimate by document number
#
# Displays the estimate PDF in the browser or redirects with an error if not found.
def doc
estimate = get_estimate
begin
send_data estimate.pdf, filename: "estimate #{estimate.doc_number}.pdf", disposition: :inline, type: "application/pdf"
rescue
redirect_to :back, flash: { error: I18n.t(:notice_estimate_not_found) }
end
render_pdf(@estimate)
end
# Displays the estimate PDF in the browser or redirects with an error if not found.
def show
render_pdf(@estimate)
end
private
# Loads the estimate based on ID or doc number, with a fallback to sync if not found locally.
def load_estimate
log "Attempting to load Estimate with params: #{params.inspect}"
@estimate = find_estimate || sync_and_find_estimate
unless @estimate
redirect_back fallback_location: root_path, flash: { error: I18n.t(:notice_estimate_not_found) }
end
end
# Attempts to find the estimate locally by ID or doc number.
def find_estimate
return Estimate.find_by(doc_number: params[:search]) if params[:search].present?
return Estimate.find_by(id: params[:id]) if params[:id].present?
end
# If the estimate is not found locally, attempts to sync it from the source and find it again.
def sync_and_find_estimate
if params[:search].present?
log "Estimate #{params[:search]} not found locally. Syncing by doc number."
Estimate.sync_by_doc_number(params[:search])
return Estimate.find_by(doc_number: params[:search])
end
if params[:id].present?
log "Estimate #{params[:id]} not found locally. Syncing by ID."
Estimate.sync_by_id(params[:id])
return Estimate.find_by(id: params[:id])
end
nil
rescue StandardError => e
log "Estimate sync failed: #{e.message}"
nil
end
# Renders the estimate PDF or redirects with an error if rendering fails.
def render_pdf(estimate)
pdf, ref = EstimatePdfService.new(qbo: Qbo.first).fetch_pdf(doc_ids: [estimate.id])
send_data( pdf, filename: "estimate #{ref}.pdf", disposition: :inline, type: "application/pdf" )
rescue StandardError => e
log "PDF render failed for Estimate #{estimate&.id}: #{e.message}"
redirect_back fallback_location: root_path, flash: { error: I18n.t(:notice_estimate_not_found) }
end
# Logs messages with a consistent prefix for easier debugging.
def log(msg)
Rails.logger.info "[EstimateController] #{msg}"
end
end
end

View File

@@ -8,53 +8,29 @@
#
#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 InvoiceController < ApplicationController
include AuthHelper
require 'combine_pdf'
before_action :require_user, unless: proc {|c| session[:token].nil? }
skip_before_action :verify_authenticity_token, :check_if_login_required, unless: proc {|c| session[:token].nil? }
#
# Downloads and forwards the invoice pdf
#
before_action :require_user, unless: -> { session[:token].nil? }
skip_before_action :verify_authenticity_token, :check_if_login_required, unless: -> { session[:token].nil? }
# Displays the invoice PDF in the browser or redirects with an error if not found.
def show
log "Processing request for URL: #{request.original_url}"
begin
qbo = Qbo.first
qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Invoice.new(company_id: qbo.realm_id, access_token: access_token)
# If multiple id's then pull each pdf & combine them
if params[:invoice_ids]
log "Grabbing pdfs for " + params[:invoice_ids].join(', ')
ref = ""
params[:invoice_ids].each do |i|
log "processing " + i
invoice = service.fetch_by_id(i)
ref += " #{invoice.doc_number}"
@pdf << CombinePDF.parse(service.pdf(invoice)) unless @pdf.nil?
if @pdf.nil?
@pdf = CombinePDF.parse(service.pdf(invoice))
end
end
@pdf = @pdf.to_pdf
else
invoice = service.fetch_by_id(params[:id])
@pdf = service.pdf(invoice)
ref = invoice.doc_number
end
log "Processing request for #{request.original_url}"
send_data @pdf, filename: "invoice #{ref}.pdf", disposition: :inline, type: "application/pdf"
end
rescue
redirect_to :back, flash: { error: I18n.t(:notice_invoice_not_found) }
end
invoice_ids = Array(params[:invoice_ids] || params[:id])
pdf, ref = InvoicePdfService.new(qbo: Qbo.first).fetch_pdf(doc_ids: invoice_ids)
send_data pdf, filename: "invoice #{ref}.pdf", disposition: :inline, type: "application/pdf"
rescue StandardError => e
log "Invoice PDF failure: #{e.message}"
redirect_back fallback_location: root_path, flash: { error: I18n.t(:notice_invoice_not_found) }
end
private
# Logs messages with a consistent prefix for easier debugging.
def log(msg)
Rails.logger.info "[InvoiceController] #{msg}"
end
end
end

View File

@@ -10,6 +10,7 @@
class BillIssueTimeJob < ActiveJob::Base
queue_as :default
retry_on StandardError, wait: 5.minutes, attempts: 5
# Perform billing of unbilled time entries for a given issue by creating corresponding TimeActivity records in QuickBooks Online, and then marking those entries as billed in Redmine. This job is typically triggered after an invoice is created or updated to ensure all relevant time is captured for billing.
def perform(issue_id)

View File

@@ -15,7 +15,7 @@ class CustomerSyncJob < ApplicationJob
# 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
return unless qbo
raise "No QBO configuration found" unless qbo
log "Starting #{full_sync ? 'full' : 'incremental'} sync for customer ##{id || 'all'}..."

View File

@@ -12,9 +12,10 @@ class EmployeeSyncJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: 5.minutes, attempts: 5
# Performs a sync of employees from QuickBooks Online.
def perform(full_sync: false, id: nil)
qbo = Qbo.first
return unless qbo
raise "No QBO configuration found" unless qbo
log "Starting #{full_sync ? 'full' : 'incremental'} sync for employee ##{id || 'all'}..."

View File

@@ -12,9 +12,10 @@ class EstimateSyncJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: 5.minutes, attempts: 5
# Performs a sync of estimates from QuickBooks Online.
def perform(full_sync: false, id: nil, doc_number: nil)
qbo = Qbo.first
return unless qbo
raise "No QBO configuration found" unless qbo
log "Starting #{full_sync ? 'full' : 'incremental'} sync for estimate ##{id || doc_number || 'all'}..."

View File

@@ -12,9 +12,10 @@ class InvoiceSyncJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: 5.minutes, attempts: 5
# Performs a sync of invoices from QuickBooks Online.
def perform(full_sync: false, id: nil)
qbo = Qbo.first
return unless qbo
raise "No QBO configuration found" unless qbo
log "Starting #{full_sync ? 'full' : 'incremental'} sync for invoice ##{id || 'all'}..."

View File

@@ -10,6 +10,7 @@
class WebhookProcessJob < ActiveJob::Base
queue_as :default
retry_on StandardError, wait: 5.minutes, attempts: 5
ALLOWED_ENTITIES = %w[
Customer

View File

@@ -35,17 +35,6 @@ class Estimate < ActiveRecord::Base
EstimateSyncJob.perform_later(doc_number: number)
end
# download the pdf from quickbooks
def pdf
log "Downloading PDF for estimate ##{self.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)
estimate = service.fetch_by_id(id)
service.pdf(estimate)
end
end
# Magic Method
# Maps Get/Set methods to QBO estimate object
def method_missing(sym, *arguments)

View File

@@ -8,88 +8,25 @@
#
#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
class CustomerSyncService < SyncServiceBase
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
# Specify the local model this service syncs
def self.model_class
Customer
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}"
# Determine if the remote entity should be deleted locally (e.g. if it's marked inactive in QBO)
def destroy_remote?(remote)
!remote.active?
end
def log(msg)
Rails.logger.info "[CustomerSyncService] #{msg}"
# 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
end

View File

@@ -8,86 +8,23 @@
#
#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
class EmployeeSyncService < SyncServiceBase
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
# Specify the local model this service syncs
def self.model_class
Employee
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}"
# Determine if the remote entity should be deleted locally (e.g. if it's marked inactive in QBO)
def destroy_remote?(remote)
!remote.active?
end
def log(msg)
Rails.logger.info "[EmployeeSyncService] #{msg}"
# Map relevant attributes from the QBO Employee to the local Employee model
def process_attributes(local, remote)
local.name = remote.display_name
end
end

View File

@@ -0,0 +1,16 @@
#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 EstimatePdfService < PdfServiceBase
def self.model_class
Estimate
end
end

View File

@@ -8,95 +8,20 @@
#
#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
class EstimateSyncService < SyncServiceBase
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
# Specify the local model this service syncs
def self.model_class
Estimate
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)
# 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)
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

@@ -0,0 +1,16 @@
#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 InvoicePdfService < PdfServiceBase
def self.model_class
Invoice
end
end

View File

@@ -8,88 +8,28 @@
#
#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 InvoiceSyncService
PAGE_SIZE = 1000
def initialize(qbo:)
@qbo = qbo
end
# Sync all invoices, or only those updated since the last sync
def sync(full_sync: false)
log "Starting #{full_sync ? 'full' : 'incremental'} invoice sync"
@qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Invoice.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 "Invoice sync complete"
end
# Sync a single invoice by its QBO ID, used for webhook updates
def sync_by_id(id)
@qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Invoice.new(company_id: @qbo.realm_id, access_token: access_token)
remote = service.fetch_by_id(id)
persist(remote)
end
end
class InvoiceSyncService < SyncServiceBase
private
# Fetch a page of invoices, 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 Invoice STARTPOSITION #{start_position} MAXRESULTS #{PAGE_SIZE}")
else
last_update = Invoice.maximum(:qbo_updated_at) || 1.year.ago
service.query(<<~SQL.squish)
SELECT * FROM Invoice
WHERE MetaData.LastUpdatedTime > '#{last_update.utc.iso8601}'
STARTPOSITION #{start_position}
MAXRESULTS #{PAGE_SIZE}
SQL
end
# Specify the local model this service syncs
def self.model_class
Invoice
end
# Create or update a local Invoice record based on the QBO remote data
def persist(remote)
local = Invoice.find_or_initialize_by(id: remote.id)
# 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)
if local.changed?
local.save
log "Updated invoice #{remote.doc_number} (#{remote.id})"
end
InvoiceAttachmentService.new(local, remote).attach
rescue => e
log "Failed to sync invoice #{remote.doc_number} (#{remote.id}): #{e.message}"
end
def log(msg)
Rails.logger.info "[InvoiceSyncService] #{msg}"
# Attach QBO Invoices to the local Issues
def attach_documents(local, remote)
InvoiceAttachmentService.new(local, remote).attach
end
end

View File

@@ -0,0 +1,66 @@
#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 PdfServiceBase
require 'combine_pdf'
# Subclasses should initialize with a QBO client instance
def initialize(qbo:)
@qbo = qbo
@entity = self.class.model_class
end
# Subclasses must implement this to specify which document model to download pdf (e.g. Estimate, Invoice)
def self.model_class
raise NotImplementedError
end
# Fetches the PDF for the given entity IDs. If multiple IDs are provided, their PDFs are combined into a single document.
def fetch_pdf(doc_ids:)
log "Fetching PDFs for #{@entity} IDs: #{doc_ids.join(', ')}"
@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)
return single_pdf(service, doc_ids.first) if doc_ids.size == 1
combined_pdf(service, doc_ids)
end
end
private
# Fetches a single PDF for the given invoice ID.
def single_pdf(service, id)
log "Fetching PDF for #{@entity} ID: #{id}"
entity = service.fetch_by_id(id)
[service.pdf(entity), entity.doc_number]
end
# Combines PDFs for multiple entity IDs into a single PDF document and returns it along with a reference string.
def combined_pdf(service, ids)
log "Combining PDFs for #{@entity} IDs: #{ids.join(', ')}"
pdf = CombinePDF.new
ref = []
ids.each do |id|
entity = service.fetch_by_id(id)
ref << entity.doc_number
pdf << CombinePDF.parse(service.pdf(entity))
end
[pdf.to_pdf, ref.join(" ")]
end
# Logs messages with a consistent prefix for easier debugging.
def log(msg)
Rails.logger.info "[#{@entity}PdfService] #{msg}"
end
end

View File

@@ -0,0 +1,126 @@
#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 SyncServiceBase
PAGE_SIZE = 1000
# Subclasses should initialize with a QBO client instance
def initialize(qbo:)
@qbo = qbo
@entity = self.class.model_class
end
# Subclasses must implement this to specify which local model they sync (e.g. Customer, Invoice)
def self.model_class
raise NotImplementedError
end
# 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"
@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)
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 "#{@entity.name} sync complete"
end
# Sync a single entity by its QBO ID, used for webhook updates
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)
remote = service.fetch_by_id(id)
persist(remote)
end
end
private
def attach_documents(local, remote)
# Override in subclasses if the entity has attachments (e.g. Invoice)
end
# Determine if a remote entity should be deleted locally (e.g. if it's marked inactive in QBO)
def destroy_remote?(remote)
false
end
# Log messages with the entity type for better traceability
def log(msg)
Rails.logger.info "[#{@entity.name}SyncService] #{msg}"
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
def persist(remote)
local = @entity.find_or_initialize_by(id: remote.id)
if destroy_remote?(remote)
if local.persisted?
local.destroy
log "Deleted #{@entity.name} #{remote.id}"
end
return
end
# Map remote attributes to local model fields, this should be implemented in subclasses
process_attributes(local, remote)
if local.changed?
local.save!
log "Updated #{@entity.name} #{remote.id}"
end
# Handle attaching documents if applicable to invoices
attach_documents(local, remote)
rescue => e
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
def process_attributes(local, remote)
raise NotImplementedError, "Subclasses must implement process_attributes"
end
end

View File

@@ -39,11 +39,9 @@ module RedmineQbo
# Enqueue a background job to bill the time spent on this issue to the associated customer in Quickbooks, if the issue is closed and has a customer assigned.
def enqueue_billing
log "Checking if issue needs to be billed for issue ##{id}"
#return unless saved_change_to_status_id?
return unless closed?
return unless customer.present?
return unless assigned_to&.employee_id.present?
return unless Qbo.first
log "Enqueuing billing for issue ##{id}"
BillIssueTimeJob.perform_later(id)