Compare commits

...

23 Commits

Author SHA1 Message Date
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
03109d5775 Refactor invoice processing and synchronization; implement InvoiceSyncJob and related services for improved background processing and logging 2026-02-27 22:33:04 -05:00
a1cbf9a0a9 Refactor logging in CustomerSyncJob to use a centralized log method; enhance consistency and readability of log messages 2026-02-27 22:32:42 -05:00
9c0f153518 Refactor logging across controllers and jobs to use a centralized log method; improve consistency and readability of log messages 2026-02-27 22:32:07 -05:00
f32b48296d Refactor estimate synchronization to use EstimateSyncJob; remove direct sync logic from Estimate model for improved background processing 2026-02-27 08:29:52 -05:00
3d37f01bff Added timestamps to estiamtes and invoices 2026-02-27 08:08:39 -05:00
889e9bf31f Refactor customer synchronization to use CustomerSyncJob; remove direct sync logic from Customer model for improved background processing 2026-02-27 08:00:38 -05:00
31 changed files with 926 additions and 369 deletions

View File

@@ -225,4 +225,8 @@ class CustomersController < ApplicationController
return string
end
def log(msg)
Rails.logger.info "[CustomersController] #{msg}"
end
end

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? }
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]
@@ -25,7 +26,7 @@ class EstimateController < ApplicationController
Estimate.sync_by_doc_number(params[:search])
e = Estimate.find_by_doc_number(params[:search])
rescue
logger.info "Estimate.find_by_doc_number failed"
log "Estimate.find_by_doc_number failed"
end
end
@@ -35,7 +36,7 @@ class EstimateController < ApplicationController
Estimate.sync_by_id(params[:id])
e = Estimate.find_by_id(params[:id])
rescue
logger.info "Estimate.find_by_id failed"
log "Estimate.find_by_id failed"
end
end
@@ -68,4 +69,10 @@ class EstimateController < ApplicationController
end
end
private
def log(msg)
Rails.logger.info "[EstimateController] #{msg}"
end
end

View File

@@ -19,7 +19,7 @@ class InvoiceController < ApplicationController
# Downloads and forwards the invoice pdf
#
def show
logger.info("Processing request for URL: #{request.original_url}")
log "Processing request for URL: #{request.original_url}"
begin
qbo = Qbo.first
qbo.perform_authenticated_request do |access_token|
@@ -27,10 +27,10 @@ class InvoiceController < ApplicationController
# If multiple id's then pull each pdf & combine them
if params[:invoice_ids]
logger.info("Grabbing pdfs for " + params[:invoice_ids].join(', '))
log "Grabbing pdfs for " + params[:invoice_ids].join(', ')
ref = ""
params[:invoice_ids].each do |i|
logger.info("processing " + i)
log "processing " + i
invoice = service.fetch_by_id(i)
ref += " #{invoice.doc_number}"
@pdf << CombinePDF.parse(service.pdf(invoice)) unless @pdf.nil?
@@ -51,4 +51,10 @@ class InvoiceController < ApplicationController
redirect_to :back, flash: { error: I18n.t(:notice_invoice_not_found) }
end
end
private
def log(msg)
Rails.logger.info "[InvoiceController] #{msg}"
end
end

View File

@@ -26,7 +26,7 @@ class QboController < ApplicationController
#
def authenticate
redirect_uri = "#{Setting.protocol}://#{Setting.host_name + qbo_oauth_callback_path}"
logger.info "redirect_uri: #{redirect_uri}"
log "redirect_uri: #{redirect_uri}"
oauth2_client = Qbo.construct_oauth2_client
grant_url = oauth2_client.auth_code.authorize_url(redirect_uri: redirect_uri, response_type: "code", state: SecureRandom.hex(12), scope: "com.intuit.quickbooks.accounting")
redirect_to grant_url
@@ -91,27 +91,19 @@ class QboController < ApplicationController
# Synchronizes the QboCustomer table with QBO
#
def sync
logger.info "Syncing EVERYTHING"
# Update info in background
Thread.new do
if Qbo.exists?
Customer.sync
Invoice.sync
Employee.sync
Estimate.sync
# Record the last sync time
Qbo.update_time_stamp
end
ActiveRecord::Base.connection.close
end
log "Syncing EVERYTHING"
CustomerSyncJob.perform_later(full_sync: true)
EstimateSyncJob.perform_later(full_sync: true)
InvoiceSyncJob.perform_later(full_sync: true)
EmployeeSyncJob.perform_later(full_sync: true)
redirect_to :home, flash: { notice: I18n.t(:label_syncing) }
end
# QuickBooks Webhook Callback
def webhook
logger.info "QBO: Webhook received"
log "Webhook received"
signature = request.headers['intuit-signature']
key = Setting.plugin_redmine_qbo['settingsWebhookToken']
@@ -121,7 +113,7 @@ class QboController < ApplicationController
computed = Base64.strict_encode64(OpenSSL::HMAC.digest(digest, key, body))
unless secure_compare(computed, signature)
logger.warn "QBO: Invalid webhook signature"
log "Invalid webhook signature"
head :unauthorized
return
end
@@ -133,8 +125,13 @@ class QboController < ApplicationController
private
# Securely compare two strings to prevent timing attacks. Returns false if either string is blank or if they do not match.
def secure_compare(a, b)
return false if a.blank? || b.blank?
ActiveSupport::SecurityUtils.secure_compare(a, b)
end
def log(msg)
Rails.logger.info "[QboController] #{msg}"
end
end

View File

@@ -11,10 +11,11 @@
class BillIssueTimeJob < ActiveJob::Base
queue_as :default
# 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)
issue = Issue.find(issue_id)
Rails.logger.debug "QBO: Starting billing for issue ##{issue.id}"
log "Starting billing for issue ##{issue.id}"
issue.with_lock do
unbilled_entries = issue.time_entries.where(billed: [false, nil]).lock
@@ -23,6 +24,7 @@ class BillIssueTimeJob < ActiveJob::Base
totals = aggregate_hours(unbilled_entries)
return if totals.blank?
log "Aggregated hours for billing: #{totals.inspect}"
qbo = Qbo.first
raise "No QBO configuration found" unless qbo
@@ -35,14 +37,16 @@ class BillIssueTimeJob < ActiveJob::Base
unbilled_entries.update_all(billed: true)
end
Rails.logger.debug "QBO: Completed billing for issue ##{issue.id}"
log "Completed billing for issue ##{issue.id}"
Qbo.update_time_stamp
rescue => e
Rails.logger.error "QBO: Billing failed for issue ##{issue_id} - #{e.message}"
log "Billing failed for issue ##{issue_id} - #{e.message}"
raise e
end
private
# Aggregate time entries by activity name and sum their hours
def aggregate_hours(entries)
entries.includes(:activity)
.group_by { |e| e.activity&.name }
@@ -50,7 +54,9 @@ class BillIssueTimeJob < ActiveJob::Base
.compact
end
# Create TimeActivity records in QBO for each activity type with the appropriate hours and link them to the issue's assigned employee and customer
def create_time_activities(issue, totals, access_token, qbo)
log "Creating TimeActivity records in QBO for issue ##{issue.id}"
time_service = Quickbooks::Service::TimeActivity.new(
company_id: qbo.realm_id,
access_token: access_token
@@ -82,12 +88,13 @@ class BillIssueTimeJob < ActiveJob::Base
time_entry.hourly_rate = item.unit_price
time_entry.item_id = item.id
Rails.logger.debug "QBO: Creating TimeActivity for #{activity_name} (#{hours}h #{minutes}m)"
log "Creating TimeActivity for #{activity_name} (#{hours}h #{minutes}m)"
time_service.create(time_entry)
end
end
# Convert a decimal hours float into separate hours and minutes components for QBO TimeActivity
def convert_hours(hours_float)
total_minutes = (hours_float.to_f * 60).round
hours = total_minutes / 60
@@ -95,14 +102,20 @@ class BillIssueTimeJob < ActiveJob::Base
[hours, minutes]
end
# Build a descriptive string for the TimeActivity based on the issue's tracker, ID, subject, and completion status
def build_description(issue)
base = "#{issue.tracker} ##{issue.id}: #{issue.subject}"
return base if issue.closed?
"#{base} (Partial @ #{issue.done_ratio}%)"
end
# Find an item in QBO by name, escaping single quotes to prevent query issues. Returns nil if not found.
def find_item(item_service, name)
safe = name.gsub("'", "\\\\'")
item_service.query("SELECT * FROM Item WHERE Name = '#{safe}'").first
end
def log(msg)
Rails.logger.info "[BillIssueTimeJob] #{msg}"
end
end

View File

@@ -0,0 +1,36 @@
#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 CustomerSyncJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: 5.minutes, attempts: 5
# 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
log "Starting #{full_sync ? 'full' : 'incremental'} sync for customer ##{id || 'all'}..."
service = CustomerSyncService.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 "[CustomerSyncJob] #{msg}"
end
end

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

@@ -0,0 +1,37 @@
#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 EstimateSyncJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: 5.minutes, attempts: 5
def perform(full_sync: false, id: nil, doc_number: nil)
qbo = Qbo.first
return unless qbo
log "Starting #{full_sync ? 'full' : 'incremental'} sync for estimate ##{id || doc_number || 'all'}..."
service = EstimateSyncService.new(qbo: qbo)
if id.present?
service.sync_by_id(id)
elsif doc_number.present?
service.sync_by_doc(doc_number)
else
service.sync(full_sync: full_sync)
end
end
private
def log(msg)
Rails.logger.info "[EstimateSyncJob] #{msg}"
end
end

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 InvoiceSyncJob < 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 invoice ##{id || 'all'}..."
service = InvoiceSyncService.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 "[InvoiceSyncJob] #{msg}"
end
end

View File

@@ -15,9 +15,12 @@ class WebhookProcessJob < ActiveJob::Base
Customer
Invoice
Estimate
Employee
].freeze
# Process incoming QBO webhook notifications and sync relevant data to Redmine
def perform(raw_body)
log "Received webhook: #{raw_body}"
data = JSON.parse(raw_body)
data.fetch('eventNotifications', []).each do |notification|
@@ -33,7 +36,9 @@ class WebhookProcessJob < ActiveJob::Base
private
# Process a single entity from the webhook payload and sync it to Redmine if it's an allowed type
def process_entity(entity)
log "Processing entity: #{entity}"
name = entity['name']
id = entity['id']&.to_i
@@ -43,17 +48,20 @@ class WebhookProcessJob < ActiveJob::Base
return unless model
if entity['deletedId']
model.destroy(entity['deletedId'])
model.delete(entity['deletedId'])
return
end
if entity['operation'] == "Delete"
model.destroy(id)
model.delete(id)
else
model.sync_by_id(id)
end
rescue => e
Rails.logger.error "QBO Webhook entity processing failed"
Rails.logger.error e.message
log "#{e.message}"
end
def log(msg)
Rails.logger.info "[WebhookProcessJob] #{msg}"
end
end

View File

@@ -13,12 +13,13 @@ module QuickbooksOauth
#== Instance Methods
# This method will attempt to execute the block and if it encounters an OAuth2::Error or Quickbooks::AuthorizationFailure it will attempt to refresh the token and retry the block. It will try this up to 3 times before giving up and raising an exception.
def perform_authenticated_request(&block)
attempts = 0
begin
yield oauth_access_token
rescue OAuth2::Error, Quickbooks::AuthorizationFailure => ex
Rails.logger.error("QuickbooksOauth.perform: #{ex.message}")
log "perform_authenticated_request: #{ex.message}"
# to prevent an infinite loop here keep a counter and bail out after N times...
attempts += 1
@@ -32,8 +33,9 @@ module QuickbooksOauth
end
end
# This method will attempt to refresh the access token and update the record with the new access token, refresh token and their respective expiration times. If the refresh token expires in more than 0 seconds then we will set the refresh token expiration time to that value, otherwise we will set it to 100 days from now.
def refresh_token!
Rails.logger.info("QuickbooksOauth.refresh_token!")
log "refresh_token!"
t = oauth_access_token
refreshed = t.refresh!
@@ -43,7 +45,7 @@ module QuickbooksOauth
oauth2_refresh_token_expires_at = 100.days.from_now
end
Rails.logger.info("QuickbooksOauth.refresh_token!: #{oauth2_refresh_token_expires_at}")
log "refresh_token!: #{oauth2_refresh_token_expires_at}"
update!(
oauth2_access_token: refreshed.token,
@@ -53,20 +55,24 @@ module QuickbooksOauth
)
end
# This method will return an instance of the OAuth2::Client class that is configured with the consumer key, consumer secret and the appropriate URLs for the Intuit OAuth2 service. It will also set the sandbox mode based on the plugin settings.
def oauth_client
self.class.construct_oauth2_client
end
# This method will return an instance of the OAuth2::AccessToken class that is configured with the current access token, refresh token and the OAuth2 client. This access token can be used to make authenticated requests to the Intuit API.
def oauth_access_token
OAuth2::AccessToken.new(oauth_client, oauth2_access_token, refresh_token: oauth2_refresh_token)
end
# This method is an alias for the oauth_access_token method and is used to provide a more intuitive name for the access token when making authenticated requests.
def consumer
oauth_access_token
end
module ClassMethods
# This method will construct and return an instance of the OAuth2::Client class that is configured with the consumer key, consumer secret and the appropriate URLs for the Intuit OAuth2 service. It will also set the sandbox mode based on the plugin settings. This method is used by the instance method oauth_client to create a new OAuth2 client for each instance of the model that includes this concern.
def construct_oauth2_client
oauth_consumer_key = Setting.plugin_redmine_qbo['settingsOAuthConsumerKey']
@@ -74,7 +80,7 @@ module QuickbooksOauth
# Are we are playing in the sandbox?
Quickbooks.sandbox_mode = Setting.plugin_redmine_qbo[:sandbox] ? true : false
logger.info "Sandbox mode: #{Quickbooks.sandbox_mode}"
log "Sandbox mode: #{Quickbooks.sandbox_mode}"
options = {
site: "https://appcenter.intuit.com/connect/oauth2",
@@ -85,4 +91,11 @@ module QuickbooksOauth
end
end
private
def log(msg)
Rails.logger.info "[QuickbooksOauth] #{msg}"
end
end

View File

@@ -149,37 +149,6 @@ class Customer < ActiveRecord::Base
end
end
end
# proforms a bruteforce sync operation
# This needs to be simplified
def self.sync
# Sync ALL customers if the database is empty
qbo = Qbo.first
customers = qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Customer.new(company_id: qbo.realm_id, access_token: access_token)
service.all
end
return unless customers
customers.each do |c|
logger.info "Processing customer #{c.id}"
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 c.new_record?
customer.delete
end
end
end
end
# Seach for customers by name or phone number
def self.search(search)
@@ -202,30 +171,13 @@ class Customer < ActiveRecord::Base
end
# 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)
qbo = Qbo.first
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
CustomerSyncJob.perform_later(id: id)
end
# returns a human readable string

View File

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

View File

@@ -12,38 +12,17 @@ class Employee < ActiveRecord::Base
has_many :users
validates_presence_of :id, :name
def self.sync
qbo = Qbo.first
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
self.primary_key = :id
# Sync all employees, typically triggered by a scheduled task or manual sync request
def self.sync
EmployeeSyncJob.perform_later(full_sync: true)
end
# Sync a single employee by ID, typically triggered by a webhook notification or manual sync request
def self.sync_by_id(id)
qbo = Qbo.first
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
return unless employee
employee = find_or_create_by(id: employee.id)
employee.name = employee.display_name
employee.id = employee.id
employee.save!
EmployeeSyncJob.perform_later(id: id)
end
end

View File

@@ -22,72 +22,22 @@ class Estimate < ActiveRecord::Base
# sync all estimates
def self.sync
logger.info "Syncing ALL estimates"
qbo = Qbo.first
estimates = qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
service.all
end
return unless estimates
estimates.each { |estimate|
process_estimate(estimate)
}
#remove deleted estimates
where.not(estimates.map(&:id)).destroy_all
EstimateSyncJob.perform_later(full_sync: false)
end
# sync only one estimate
def self.sync_by_id(id)
logger.info "Syncing estimate #{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
EstimateSyncJob.perform_later(id: id)
end
# sync only one estimate
def self.sync_by_doc_number(number)
logger.info "Syncing estimate by 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)
logger.info "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!
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)
@@ -118,6 +68,7 @@ class Estimate < ActiveRecord::Base
# pull the details
def pull
log "Pulling details for estimate ##{self.id}..."
begin
raise Exception unless self.id
qbo = Qbo.first
@@ -129,5 +80,9 @@ class Estimate < ActiveRecord::Base
@details = Quickbooks::Model::Estimate.new
end
end
def log(msg)
Rails.logger.info "[Estimate] #{msg}"
end
end

View File

@@ -9,200 +9,27 @@
#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 Invoice < ActiveRecord::Base
has_and_belongs_to_many :issues
belongs_to :customer
validates_presence_of :doc_number, :id, :customer_id, :txn_date
belongs_to :customer
validates :id, presence: true, uniqueness: true
validates :doc_number, :txn_date, presence: true
self.primary_key = :id
# returns a human readable string
# Return the invoice's document number as its string representation
def to_s
return self[:doc_number]
doc_number
end
# sync ALL the invoices
# Sync all invoices from QuickBooks, typically triggered by a scheduled task or manual sync request
def self.sync
logger.info "Syncing all invoices"
last = Qbo.first.last_sync
query = "SELECT Id, DocNumber FROM Invoice"
query << " WHERE Metadata.LastUpdatedTime >= '#{last.iso8601}' " if last
# TODO actually do something with the above query
# .all() is never called since count is never initialized
qbo = Qbo.first
invoices = qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Invoice.new(company_id: qbo.realm_id, access_token: access_token)
service.all
end
return unless invoices
invoices.each { | invoice |
process_invoice invoice
}
InvoiceSyncJob.perform_later(full_sync: true)
end
#sync by invoice ID
# Sync a single invoice by ID, typically triggered by a webhook notification or manual sync request
def self.sync_by_id(id)
logger.info "Syncing invoice #{id}"
qbo = Qbo.first
qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Invoice.new(company_id: qbo.realm_id, access_token: access_token)
invoice = service.fetch_by_id(id)
process_invoice invoice
end
end
private
# Attach the invoice to the issue
def self.attach_to_issue(issue, invoice)
return if issue.nil?
# skip this issue if the issue customer is not the same as the invoice customer
return if issue.customer_id != invoice.customer_ref.value.to_i
logger.info "Attaching invoice #{invoice.id} to issue #{issue.id}"
invoice = Invoice.find_or_create_by(id: invoice.id)
unless issue.invoices.include?(invoice)
issue.invoices << invoice
issue.save!
end
compare_custom_fields(issue, invoice)
end
# processes the invoice into the database
def self.process_invoice(i)
logger.info "Processing invoice #{i.id}"
# Load the invoice into the database
invoice = Invoice.find_or_create_by(id: i.id)
invoice.doc_number = i.doc_number
invoice.id = i.id
invoice.customer_id = i.customer_ref
invoice.txn_date = i.txn_date
invoice.save!
# Scan the private notes for hashtags and attach to the applicable issues
if not i.private_note.nil?
i.private_note.scan(/#(\w+)/).flatten.each { |issue|
attach_to_issue(Issue.find_by_id(issue.to_i), invoice)
}
end
# Scan the line items for hashtags and attach to the applicable issues
i.line_items.each { |line|
if line.description
line.description.scan(/#(\w+)/).flatten.each { |issue|
attach_to_issue(Issue.find_by_id(issue.to_i), invoice)
}
end
}
end
# compares the custome fields on invoices & issues and updates the invoice as needed
#
# the issue here is when two or more issues share an invoice with the same custom field, but diffrent values
# this condions causes an infinite loop as the webhook is called when an invoice is updated
# TODO maybe add a cf_sync_confict flag to invoices
def self.compare_custom_fields(issue, invoice)
logger.info "Comparing custom fields"
# TODO break if Invoice.find(invoice.id).cf_sync_confict
is_changed = false
logger.debug "Calling :process_invoice_custom_fields hook"
context = Redmine::Hook.call_hook :process_invoice_custom_fields, { issue: issue, invoice: invoice }
# Process updates from the hooks
context.each do |c|
unless c.nil?
logger.debug "Invoice.compare_custom_fields: We have a responce from a hook"
push_updates c[:invoice] if c[:is_changed]
end
end
# Process Issue Custom Values
begin
value = issue.custom_values.find_by(custom_field_id: CustomField.find_by_name(cf.name).id)
# Check to see if the value is blank...
if not value.value.to_s.blank?
# Check to see if the value is diffrent
if not cf.string_value.to_s.eql? value.value.to_s
# update the custom field on the invoice
cf.string_value = value.value.to_s
is_changed = true
end
end
rescue
# Nothing to do here, there is no match
end
push_updates invoice if is_changed
InvoiceSyncJob.perform_later(id: id)
end
# pushes invoice updates
def self.push_updates(invoice)
begin
logger.info "Invoice.push_updates"
qbo = Qbo.first
qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Invoice.new(company_id: qbo.realm_id, access_token: access_token)
service.update invoice
end
rescue
# Do nothing, probaly custome field sync confict on the invoice.
# This is a problem with how it's billed
# TODO Add notes in memo area
# TODO flag Invoice.cf_sync_confict here
logger.error "Failed to update invoice"
end
end
# download the pdf from quickbooks
def pdf
qbo = Qbo.first
qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Invoice.new(company_id: qbo.realm_id, access_token: access_token)
invoice = service.fetch_by_id(id)
return service.pdf(invoice)
end
end
# Magic Method
# Maps Get/Set methods to QBO invoice object
def method_missing(sym, *arguments)
# Check to see if the method exists
if Quickbooks::Model::Invoice.method_defined?(sym)
# download details if required
pull unless @details
method_name = sym.to_s
# Setter
if method_name[-1, 1] == "="
@details.method(method_name).call(arguments[0])
# Getter
else
return @details.method(method_name).call
end
end
end
# pull the details from quickbooks
def pull
begin
raise Exception unless self.id
qbo = Qbo.first
@details = qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Invoice.new(company_id: qbo.realm_id, access_token: access_token)
service.fetch_by_id(self.id)
end
rescue Exception => e
@details = Quickbooks::Model::Invoice.new
end
end
end
end

View File

@@ -16,7 +16,7 @@ class Qbo < ActiveRecord::Base
# Updates last sync time stamp
def self.update_time_stamp
date = DateTime.now
logger.info "Updating QBO timestamp to #{date}"
log "Updating QBO timestamp to #{date}"
qbo = Qbo.first
qbo.last_sync = date
qbo.save
@@ -25,4 +25,11 @@ class Qbo < ActiveRecord::Base
def self.last_sync
format_time(Qbo.first.last_sync)
end
private
def self.log(msg)
logger.info "[QBO] #{msg}"
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

@@ -0,0 +1,62 @@
#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 InvoiceAttachmentService
def initialize(invoice, remote)
@invoice = invoice
@remote = remote
end
# Attach invoice to issues based on issue IDs found in the invoice's private note and line descriptions
def attach
extract_issue_ids.each do |issue_id|
log "Processing issue ##{issue_id} for invoice ##{@invoice.doc_number}"
issue = Issue.find_by(id: issue_id)
next unless issue
next unless issue.customer&.id == @invoice.customer&.id
unless issue.invoices.exists?(@invoice.id)
issue.invoices << @invoice
issue.save! if issue.changed?
log "Attached invoice ##{@invoice.id} to issue ##{issue.id}"
end
InvoiceCustomFieldSyncService.new(issue, @invoice, @remote).sync
end
end
private
# Extract issue IDs from the invoice's private note and line descriptions
def extract_issue_ids
ids = []
if @remote.private_note.present?
ids += scan(@remote.private_note)
end
Array(@remote.line_items).each do |line|
ids += scan(line.description.to_s)
end
ids.uniq
end
# Scan text for issue IDs in the format #123
def scan(text)
text.scan(/#(\d+)/).flatten.map(&:to_i)
end
def log(msg)
Rails.logger.info "[InvoiceAttachmentService] #{msg}"
end
end

View File

@@ -0,0 +1,69 @@
#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 InvoiceCustomFieldSyncService
def initialize(issue, invoice, remote)
@issue = issue
@invoice = invoice
@remote = remote
end
# Sync custom fields on the issue based on the invoice data, then push changes to QBO if any fields were updated
def sync
return if @invoice.qbo_sync_locked?
log "Syncing custom fields for issue ##{@issue.id} based on invoice ##{@invoice.doc_number}"
changed = false
# Process Invoice Custom Fields via Hooks
Redmine::Hook.call_hook(
:process_invoice_custom_fields,
issue: @issue,
invoice: @remote
).each do |context|
next unless context
changed ||= context[:is_changed]
log "Custom fields updated by hook, marking invoice for push to QBO" if context[:is_changed]
end
# Process Issue Custom Values from any issue custom fields that match the invoice custom fields
begin
value = @issue.custom_values.find_by(custom_field_id: CustomField.find_by_name(cf.name).id)
# Check to see if the value is blank...
if not value.value.to_s.blank?
# Check to see if the value is diffrent
if not cf.string_value.to_s.eql? value.value.to_s
# update the custom field on the invoice
cf.string_value = value.value.to_s
is_changed = true
end
end
rescue
# Nothing to do here, there is no match
end
push_if_changed if changed
end
private
# If any custom fields were changed during the sync process, this method will trigger a push of the invoice data to QuickBooks Online to ensure that the remote data stays in sync with the local changes. It uses the InvoicePushService to handle the actual communication with QBO.
def push_if_changed
InvoicePushService.new(@invoice).push
end
def log(msg)
Rails.logger.info "[InvoiceCustomFieldSyncService] #{msg}"
end
end

View File

@@ -0,0 +1,47 @@
#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 InvoicePushService
def initialize(invoice)
@invoice = invoice
end
# Push invoice changes to QBO if the invoice is linked to any issues with custom field changes that need to be synced
def push
return if @invoice.qbo_sync_locked?
log "Pushing invoice ##{@invoice.id} to QBO due to linked issue custom field changes"
@invoice.update_column(:qbo_sync_locked, true)
qbo = Qbo.first
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(@invoice.id)
# modify remote object here if needed
service.update(remote)
end
rescue => e
Rails.logger.error "[InvoicePushService] #{e.message}"
ensure
@invoice.update_column(:qbo_sync_locked, false)
end
private
def log(msg)
Rails.logger.info "[InvoicePushService] #{msg}"
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 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
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
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)
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}"
end
end

View File

@@ -98,7 +98,7 @@ en:
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_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_forbidden: "You do not have permission to access this resource."
notice_invoice_created: "Invoice created in QuickBooks"

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 AddDocTimestamp < ActiveRecord::Migration[5.1]
def change
add_timestamps(:invoices, null: true)
add_timestamps(:estimates, null: true)
end
end

View File

@@ -0,0 +1,24 @@
#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 AddInvoiceFields < ActiveRecord::Migration[7.0]
def change
change_table :invoices, bulk: true do |t|
t.date :due_date
t.decimal :total_amount, precision: 15, scale: 2
t.decimal :balance, precision: 15, scale: 2
t.datetime :qbo_updated_at
t.boolean :qbo_sync_locked, null: false, default: false
end
add_index :invoices, :qbo_updated_at
add_index :invoices, :qbo_sync_locked
end
end

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

@@ -17,12 +17,12 @@ module RedmineQbo
# Edit Issue Form
# Here we build the required form components before passing them to a partial view formatting.
def view_issues_form_details_bottom(context={})
Rails.logger.debug "RedmineQbo::Hooks::IssuesHookListener.view_issues_form_details_bottom: Building form components for quickbooks customer, estimate, and invoice data"
log "view_issues_form_details_bottom: Building form components for quickbooks customer, estimate, and invoice data"
f = context[:form]
issue = context[:issue]
project = context[:project]
Rails.logger.debug issue.inspect
Rails.logger.debug project.inspect
log issue.inspect
log project.inspect
# Customer Name Text Box with database backed autocomplete
# onchange event will update the hidden customer_id field
@@ -44,7 +44,7 @@ module RedmineQbo
#
# If this is not handled correctly, it leads to a 422 error when creating a new issue and selecting a customer.
js_path = "updateIssueFrom('#{escape_javascript update_issue_form_path(project, issue)}', this)"
Rails.logger.debug js_path
log js_path
# This hidden field is used for the customer ID for the issue
# the onchange event will reload the issue form via ajax to update the available estimates
@@ -93,6 +93,12 @@ module RedmineQbo
}
})
end
private
def log(msg)
Rails.logger.info "[IssuesHookListener] #{msg}"
end
end
end

View File

@@ -27,6 +27,7 @@ module RedmineQbo
before_save :titlize_subject
after_commit :enqueue_billing, on: :update
end
end
module ClassMethods
@@ -35,20 +36,22 @@ module RedmineQbo
module InstanceMethods
# 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
Rails.logger.debug "QBO: Checking if issue needs to be billed for issue ##{id}"
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
Rails.logger.debug "QBO: Enqueuing billing for issue ##{id}"
log "Enqueuing billing for issue ##{id}"
BillIssueTimeJob.perform_later(id)
end
# Titlize the subject of the issue before saving to ensure consistent formatting for billing descriptions in Quickbooks
def titlize_subject
Rails.logger.debug "QBO: Titlizing subject for issue ##{id}"
log "Titlizing subject for issue ##{id}"
self.subject = subject.split(/\s+/).map do |word|
if word =~ /[A-Z]/ && word =~ /[0-9]/
@@ -60,9 +63,16 @@ module RedmineQbo
end
end
# This method is used to generate a shareable token for the customer associated with this issue, which can be used to link the issue to the corresponding customer in Quickbooks for billing and tracking purposes.
def share_token
CustomerToken.get_token(self)
end
private
def log(msg)
Rails.logger.info "[IssuePatch] #{msg}"
end
end
Issue.send(:include, IssuePatch)

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_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 }
unless left_hook_output.nil?
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(: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 }
unless right_hook_output.nil?
right_hook_output.each do |r|
@@ -269,6 +269,13 @@ module RedmineQbo
end
end
private
def log(msg)
Rails.logger.info "[PdfPatch] #{msg}"
end
end
Redmine::Export::PDF::IssuesPdfHelper.send(:include, PdfPatch)