Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a2d38a927 | |||
| b80dbaa015 | |||
| 9e399b934b | |||
| cc6fd07435 | |||
| 7a50df24d9 | |||
| ca02ead9f9 | |||
| 9089adaba0 | |||
| dc6eba8566 | |||
| 19911b7940 | |||
| a80f59cc45 | |||
| eee99e4d83 | |||
| b3f01bd372 | |||
| d1ba93d61a | |||
| 9a688c4841 | |||
| e94352e2c4 | |||
| ea0f42b68e | |||
| 5a31c194a5 | |||
| 6f8af9bba8 | |||
| 03109d5775 | |||
| a1cbf9a0a9 | |||
| 9c0f153518 | |||
| f32b48296d | |||
| 3d37f01bff | |||
| 889e9bf31f | |||
| 208e839e6a | |||
| 4f55751500 | |||
| a64016eb95 | |||
| 5d858ae186 | |||
| b38f850df3 | |||
| 138e55933b | |||
| 5fbc169ade | |||
| d6737a6747 | |||
| 65db8f00a8 | |||
| 0197dc2a30 | |||
| cd1caa502d | |||
| 4b45d24a75 | |||
| 64a4526aa4 | |||
| 3514401808 | |||
| 3deafd8a6d | |||
| a54de28db5 | |||
| 6434eea906 | |||
| 9b656534ae | |||
| 659a1fbcf0 | |||
| 4dc1f5d0bd | |||
| 02f34582f4 | |||
| 2f9ef6304f | |||
| 886d5f4ace | |||
| 1ade938eb3 | |||
| 3111f391f3 | |||
| d2b9113914 | |||
| 447e048819 | |||
| e7dfc3f2ad | |||
| 139f5dd618 | |||
| 9c11704d03 | |||
| 2ae53adf08 | |||
| 877c1b78a5 | |||
| 1d47703206 | |||
| a069556ed9 | |||
| 359c582e22 | |||
| e63b9e4217 | |||
| 6fd355d8cc | |||
| e6b57392d1 | |||
| 9cf72821b0 |
BIN
Screenshots/issue.png
Normal file
|
After Width: | Height: | Size: 724 KiB |
BIN
Screenshots/issue_form.png
Normal file
|
After Width: | Height: | Size: 520 KiB |
|
Before Width: | Height: | Size: 346 KiB After Width: | Height: | Size: 672 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 538 KiB |
|
Before Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 512 KiB |
@@ -30,8 +30,6 @@ class CustomersController < ApplicationController
|
|||||||
before_action :view_customer, except: [:new, :view]
|
before_action :view_customer, except: [:new, :view]
|
||||||
skip_before_action :verify_authenticity_token, :check_if_login_required, only: [:view]
|
skip_before_action :verify_authenticity_token, :check_if_login_required, only: [:view]
|
||||||
|
|
||||||
default_search_scope :names
|
|
||||||
|
|
||||||
autocomplete :customer, :name, full: true, extra_data: [:id]
|
autocomplete :customer, :name, full: true, extra_data: [:id]
|
||||||
|
|
||||||
def allowed_params
|
def allowed_params
|
||||||
@@ -53,7 +51,7 @@ class CustomersController < ApplicationController
|
|||||||
# display a list of all customers
|
# display a list of all customers
|
||||||
def index
|
def index
|
||||||
if params[:search]
|
if params[:search]
|
||||||
@customers = Customer.search(params[:search]).paginate(page: params[:page])
|
@customers = Customer.search(params[:search]).order(:name).paginate(page: params[:page])
|
||||||
if only_one_non_zero?(@customers)
|
if only_one_non_zero?(@customers)
|
||||||
redirect_to @customers.first
|
redirect_to @customers.first
|
||||||
end
|
end
|
||||||
@@ -136,38 +134,44 @@ class CustomersController < ApplicationController
|
|||||||
|
|
||||||
# creates new customer view tokens, removes expired tokens & redirects to newly created customer view with new token.
|
# creates new customer view tokens, removes expired tokens & redirects to newly created customer view with new token.
|
||||||
def share
|
def share
|
||||||
|
issue = Issue.find(params[:id])
|
||||||
|
|
||||||
Thread.new do
|
token = issue.share_token
|
||||||
logger.info "Removing expired customer tokens"
|
redirect_to view_path(token.token)
|
||||||
CustomerToken.remove_expired_tokens
|
|
||||||
ActiveRecord::Base.connection.close
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
rescue ActiveRecord::RecordNotFound
|
||||||
issue = Issue.find_by_id(params[:id])
|
flash[:error] = t(:notice_issue_not_found)
|
||||||
redirect_to view_path issue.share_token.token
|
|
||||||
rescue
|
|
||||||
flash[:error] = t :notice_issue_not_found
|
|
||||||
render_404
|
render_404
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
# displays an issue for a customer with a provided security CustomerToken
|
# displays an issue for a customer with a provided security CustomerToken
|
||||||
def view
|
def view
|
||||||
|
User.current = User.anonymous
|
||||||
|
|
||||||
User.current = User.find_by lastname: 'Anonymous'
|
# Load only active, non-expired token
|
||||||
|
@token = CustomerToken.active.find_by(token: params[:token])
|
||||||
|
return render_403 unless @token
|
||||||
|
|
||||||
@token = CustomerToken.find_by token: params[:token]
|
# Load associated issue
|
||||||
begin
|
@issue = @token.issue
|
||||||
@token.destroy if @token.expired?
|
return render_403 unless @issue
|
||||||
raise "Token Expired" if @token.destroyed
|
|
||||||
|
|
||||||
|
# Optional: enforce token belongs to the issue's customer
|
||||||
|
return render_403 unless @issue.customer_id == @token.issue.customer_id
|
||||||
|
|
||||||
|
# Store token in session for subsequent requests if needed
|
||||||
session[:token] = @token.token
|
session[:token] = @token.token
|
||||||
@issue = Issue.find @token.issue_id
|
|
||||||
@journals = @issue.journals.
|
load_issue_data
|
||||||
preload(:details).
|
rescue ActiveRecord::RecordNotFound
|
||||||
preload(user: :email_address).
|
render_403
|
||||||
reorder(:created_on, :id).to_a
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_issue_data
|
||||||
|
@journals = @issue.journals.preload(:details).preload(user: :email_address).reorder(:created_on, :id).to_a
|
||||||
|
|
||||||
@journals.each_with_index { |j, i| j.indice = i + 1 }
|
@journals.each_with_index { |j, i| j.indice = i + 1 }
|
||||||
@journals.reject!(&:private_notes?) unless User.current.allowed_to?(:view_private_notes, @issue.project)
|
@journals.reject!(&:private_notes?) unless User.current.allowed_to?(:view_private_notes, @issue.project)
|
||||||
Journal.preload_journals_details_custom_fields(@journals)
|
Journal.preload_journals_details_custom_fields(@journals)
|
||||||
@@ -177,18 +181,12 @@ class CustomersController < ApplicationController
|
|||||||
@changesets = @issue.changesets.visible.preload(:repository, :user).to_a
|
@changesets = @issue.changesets.visible.preload(:repository, :user).to_a
|
||||||
@changesets.reverse! if User.current.wants_comments_in_reverse_order?
|
@changesets.reverse! if User.current.wants_comments_in_reverse_order?
|
||||||
|
|
||||||
@relations = @issue.relations.select {|r| r.other_issue(@issue) && r.other_issue(@issue).visible? }
|
@relations = @issue.relations.select { |r| r.other_issue(@issue)&.visible? }
|
||||||
@allowed_statuses = @issue.new_statuses_allowed_to(User.current)
|
@allowed_statuses = @issue.new_statuses_allowed_to(User.current)
|
||||||
@priorities = IssuePriority.active
|
@priorities = IssuePriority.active
|
||||||
@time_entry = TimeEntry.new(issue: @issue, project: @issue.project)
|
@time_entry = TimeEntry.new(issue: @issue, project: @issue.project)
|
||||||
@relation = IssueRelation.new
|
@relation = IssueRelation.new
|
||||||
rescue
|
|
||||||
flash[:error] = t :notice_forbidden
|
|
||||||
render_403
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
# redmine permission - add customers
|
# redmine permission - add customers
|
||||||
def add_customer
|
def add_customer
|
||||||
@@ -227,4 +225,8 @@ class CustomersController < ApplicationController
|
|||||||
return string
|
return string
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def log(msg)
|
||||||
|
Rails.logger.info "[CustomersController] #{msg}"
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,18 +15,32 @@ class EstimateController < ApplicationController
|
|||||||
skip_before_action :verify_authenticity_token, :check_if_login_required, unless: proc {|c| session[:token].nil? }
|
skip_before_action :verify_authenticity_token, :check_if_login_required, unless: proc {|c| session[:token].nil? }
|
||||||
|
|
||||||
def get_estimate
|
def get_estimate
|
||||||
|
log "Searching for estimate with params: #{params.inspect}"
|
||||||
|
|
||||||
|
e = Estimate.find_by_doc_number(params[:search]) if params[:search]
|
||||||
|
e = Estimate.find_by_id(params[:id]) if params[:id]
|
||||||
|
|
||||||
# Force sync for estimate by doc number if not found
|
# Force sync for estimate by doc number if not found
|
||||||
if Estimate.find_by_doc_number(params[:search]).nil?
|
if e.nil? && params[:search]
|
||||||
begin
|
begin
|
||||||
Estimate.sync_by_doc_number(params[:search]) if params[:search]
|
Estimate.sync_by_doc_number(params[:search])
|
||||||
|
e = Estimate.find_by_doc_number(params[:search])
|
||||||
rescue
|
rescue
|
||||||
logger.info "Estimate.find_by_doc_number failed"
|
log "Estimate.find_by_doc_number failed"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
estimate = Estimate.find_by_id(params[:id]) if params[:id]
|
# Force sync for estimate by id if not found
|
||||||
estimate = Estimate.find_by_doc_number(params[:search]) if params[:search]
|
if e.nil? && params[:id]
|
||||||
return estimate
|
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
|
end
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -55,4 +69,10 @@ class EstimateController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def log(msg)
|
||||||
|
Rails.logger.info "[EstimateController] #{msg}"
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class InvoiceController < ApplicationController
|
|||||||
# Downloads and forwards the invoice pdf
|
# Downloads and forwards the invoice pdf
|
||||||
#
|
#
|
||||||
def show
|
def show
|
||||||
logger.info("Processing request for URL: #{request.original_url}")
|
log "Processing request for URL: #{request.original_url}"
|
||||||
begin
|
begin
|
||||||
qbo = Qbo.first
|
qbo = Qbo.first
|
||||||
qbo.perform_authenticated_request do |access_token|
|
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 multiple id's then pull each pdf & combine them
|
||||||
if params[:invoice_ids]
|
if params[:invoice_ids]
|
||||||
logger.info("Grabbing pdfs for " + params[:invoice_ids].join(', '))
|
log "Grabbing pdfs for " + params[:invoice_ids].join(', ')
|
||||||
ref = ""
|
ref = ""
|
||||||
params[:invoice_ids].each do |i|
|
params[:invoice_ids].each do |i|
|
||||||
logger.info("processing " + i)
|
log "processing " + i
|
||||||
invoice = service.fetch_by_id(i)
|
invoice = service.fetch_by_id(i)
|
||||||
ref += " #{invoice.doc_number}"
|
ref += " #{invoice.doc_number}"
|
||||||
@pdf << CombinePDF.parse(service.pdf(invoice)) unless @pdf.nil?
|
@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) }
|
redirect_to :back, flash: { error: I18n.t(:notice_invoice_not_found) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def log(msg)
|
||||||
|
Rails.logger.info "[InvoiceController] #{msg}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class QboController < ApplicationController
|
|||||||
#
|
#
|
||||||
def authenticate
|
def authenticate
|
||||||
redirect_uri = "#{Setting.protocol}://#{Setting.host_name + qbo_oauth_callback_path}"
|
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
|
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")
|
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
|
redirect_to grant_url
|
||||||
@@ -62,96 +62,76 @@ class QboController < ApplicationController
|
|||||||
|
|
||||||
# Manual Billing
|
# Manual Billing
|
||||||
def bill
|
def bill
|
||||||
i = Issue.find_by_id params[:id]
|
issue = Issue.find_by(id: params[:id])
|
||||||
if i.customer
|
return render_404 unless issue
|
||||||
i.bill_time
|
|
||||||
redirect_to i, flash: { notice: I18n.t(:label_billed_success) + i.customer.name }
|
unless issue.customer
|
||||||
else
|
redirect_to issue, flash: { error: I18n.t(:label_billing_error_no_customer) }
|
||||||
redirect_to i, flash: { error: I18n.t(:label_billing_error) }
|
return
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Quickbooks Webhook Callback
|
unless issue.assigned_to&.employee_id.present?
|
||||||
def webhook
|
redirect_to issue, flash: { error: I18n.t(:label_billing_error_no_employee) }
|
||||||
|
return
|
||||||
logger.info "Quickbooks is calling webhook"
|
|
||||||
|
|
||||||
# check the payload
|
|
||||||
signature = request.headers['intuit-signature']
|
|
||||||
key = Setting.plugin_redmine_qbo['settingsWebhookToken']
|
|
||||||
data = request.body.read
|
|
||||||
hash = Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest::Digest.new('sha256'), key, data)).strip()
|
|
||||||
|
|
||||||
# proceed if the request is good
|
|
||||||
if hash.eql? signature
|
|
||||||
Thread.new do
|
|
||||||
if request.headers['content-type'] == 'application/json'
|
|
||||||
data = JSON.parse(data)
|
|
||||||
else
|
|
||||||
# application/x-www-form-urlencoded
|
|
||||||
data = params.as_json
|
|
||||||
end
|
|
||||||
# Process the information
|
|
||||||
entities = data['eventNotifications'][0]['dataChangeEvent']['entities']
|
|
||||||
entities.each do |entity|
|
|
||||||
id = entity['id'].to_i
|
|
||||||
name = entity['name']
|
|
||||||
|
|
||||||
logger.info "Casting #{name.constantize} to obj"
|
|
||||||
|
|
||||||
# Magicly initialize the correct class
|
|
||||||
obj = name.constantize
|
|
||||||
|
|
||||||
# for merge events
|
|
||||||
obj.destroy(entity['deletedId']) if entity['deletedId']
|
|
||||||
|
|
||||||
#Check to see if we are deleting a record
|
|
||||||
if entity['operation'].eql? "Delete"
|
|
||||||
obj.destroy(id)
|
|
||||||
#if not then update!
|
|
||||||
else
|
|
||||||
begin
|
|
||||||
obj.sync_by_id(id)
|
|
||||||
rescue => e
|
|
||||||
logger.error "Failed to call sync_by_id on obj"
|
|
||||||
logger.error e.message
|
|
||||||
logger.error e.backtrace.join("\n")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Record that last time we updated
|
unless Qbo.first
|
||||||
Qbo.update_time_stamp
|
redirect_to issue, flash: { error: I18n.t(:label_billing_error_no_qbo) }
|
||||||
ActiveRecord::Base.connection.close
|
return
|
||||||
end
|
|
||||||
# The webhook doesn't require a response but let's make sure we don't send anything
|
|
||||||
render nothing: true, status: 200
|
|
||||||
else
|
|
||||||
render nothing: true, status: 400
|
|
||||||
end
|
end
|
||||||
|
|
||||||
logger.info "Quickbooks webhook complete"
|
BillIssueTimeJob.perform_later(issue.id)
|
||||||
|
|
||||||
|
redirect_to issue, flash: {
|
||||||
|
notice: I18n.t(:label_billing_enqueued) + " #{issue.customer.name}"
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
#
|
#
|
||||||
# Synchronizes the QboCustomer table with QBO
|
# Synchronizes the QboCustomer table with QBO
|
||||||
#
|
#
|
||||||
def sync
|
def sync
|
||||||
logger.info "Syncing EVERYTHING"
|
log "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
|
CustomerSyncJob.perform_later(full_sync: true)
|
||||||
Qbo.update_time_stamp
|
EstimateSyncJob.perform_later(full_sync: true)
|
||||||
end
|
InvoiceSyncJob.perform_later(full_sync: true)
|
||||||
ActiveRecord::Base.connection.close
|
EmployeeSyncJob.perform_later(full_sync: true)
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to :home, flash: { notice: I18n.t(:label_syncing) }
|
redirect_to :home, flash: { notice: I18n.t(:label_syncing) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# QuickBooks Webhook Callback
|
||||||
|
def webhook
|
||||||
|
log "Webhook received"
|
||||||
|
|
||||||
|
signature = request.headers['intuit-signature']
|
||||||
|
key = Setting.plugin_redmine_qbo['settingsWebhookToken']
|
||||||
|
body = request.raw_post
|
||||||
|
|
||||||
|
digest = OpenSSL::Digest.new('sha256')
|
||||||
|
computed = Base64.strict_encode64(OpenSSL::HMAC.digest(digest, key, body))
|
||||||
|
|
||||||
|
unless secure_compare(computed, signature)
|
||||||
|
log "Invalid webhook signature"
|
||||||
|
head :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
WebhookProcessJob.perform_later(body)
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|||||||
121
app/jobs/bill_issue_time_job.rb
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
#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 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)
|
||||||
|
|
||||||
|
log "Starting billing for issue ##{issue.id}"
|
||||||
|
|
||||||
|
issue.with_lock do
|
||||||
|
unbilled_entries = issue.time_entries.where(billed: [false, nil]).lock
|
||||||
|
|
||||||
|
return if unbilled_entries.blank?
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
qbo.perform_authenticated_request do |access_token|
|
||||||
|
create_time_activities(issue, totals, access_token, qbo)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only mark billed AFTER successful QBO creation
|
||||||
|
unbilled_entries.update_all(billed: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
log "Completed billing for issue ##{issue.id}"
|
||||||
|
Qbo.update_time_stamp
|
||||||
|
rescue => e
|
||||||
|
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 }
|
||||||
|
.transform_values { |rows| rows.sum(&:hours) }
|
||||||
|
.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
|
||||||
|
)
|
||||||
|
|
||||||
|
item_service = Quickbooks::Service::Item.new(
|
||||||
|
company_id: qbo.realm_id,
|
||||||
|
access_token: access_token
|
||||||
|
)
|
||||||
|
|
||||||
|
totals.each do |activity_name, hours_float|
|
||||||
|
next if activity_name.blank?
|
||||||
|
next if hours_float.to_f <= 0
|
||||||
|
|
||||||
|
item = find_item(item_service, activity_name)
|
||||||
|
next unless item
|
||||||
|
|
||||||
|
hours, minutes = convert_hours(hours_float)
|
||||||
|
|
||||||
|
time_entry = Quickbooks::Model::TimeActivity.new
|
||||||
|
time_entry.description = build_description(issue)
|
||||||
|
time_entry.employee_id = issue.assigned_to.employee_id
|
||||||
|
time_entry.customer_id = issue.customer_id
|
||||||
|
time_entry.billable_status = "Billable"
|
||||||
|
time_entry.hours = hours
|
||||||
|
time_entry.minutes = minutes
|
||||||
|
time_entry.name_of = "Employee"
|
||||||
|
time_entry.txn_date = Date.today
|
||||||
|
time_entry.hourly_rate = item.unit_price
|
||||||
|
time_entry.item_id = item.id
|
||||||
|
|
||||||
|
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
|
||||||
|
minutes = total_minutes % 60
|
||||||
|
[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
|
||||||
36
app/jobs/customer_sync_job.rb
Normal 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
|
||||||
35
app/jobs/employee_sync_job.rb
Normal 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
|
||||||
37
app/jobs/estimate_sync_job.rb
Normal 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
|
||||||
35
app/jobs/invoice_sync_job.rb
Normal 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
|
||||||
67
app/jobs/webhook_process_job.rb
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
#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 WebhookProcessJob < ActiveJob::Base
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
ALLOWED_ENTITIES = %w[
|
||||||
|
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|
|
||||||
|
entities = notification.dig('dataChangeEvent', 'entities') || []
|
||||||
|
|
||||||
|
entities.each do |entity|
|
||||||
|
process_entity(entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Qbo.update_time_stamp
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return unless ALLOWED_ENTITIES.include?(name)
|
||||||
|
|
||||||
|
model = name.safe_constantize
|
||||||
|
return unless model
|
||||||
|
|
||||||
|
if entity['deletedId']
|
||||||
|
model.delete(entity['deletedId'])
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if entity['operation'] == "Delete"
|
||||||
|
model.delete(id)
|
||||||
|
else
|
||||||
|
model.sync_by_id(id)
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
log "#{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def log(msg)
|
||||||
|
Rails.logger.info "[WebhookProcessJob] #{msg}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -13,12 +13,13 @@ module QuickbooksOauth
|
|||||||
|
|
||||||
#== Instance Methods
|
#== 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)
|
def perform_authenticated_request(&block)
|
||||||
attempts = 0
|
attempts = 0
|
||||||
begin
|
begin
|
||||||
yield oauth_access_token
|
yield oauth_access_token
|
||||||
rescue OAuth2::Error, Quickbooks::AuthorizationFailure => ex
|
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...
|
# to prevent an infinite loop here keep a counter and bail out after N times...
|
||||||
attempts += 1
|
attempts += 1
|
||||||
@@ -32,8 +33,9 @@ module QuickbooksOauth
|
|||||||
end
|
end
|
||||||
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!
|
def refresh_token!
|
||||||
Rails.logger.info("QuickbooksOauth.refresh_token!")
|
log "refresh_token!"
|
||||||
t = oauth_access_token
|
t = oauth_access_token
|
||||||
refreshed = t.refresh!
|
refreshed = t.refresh!
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ module QuickbooksOauth
|
|||||||
oauth2_refresh_token_expires_at = 100.days.from_now
|
oauth2_refresh_token_expires_at = 100.days.from_now
|
||||||
end
|
end
|
||||||
|
|
||||||
Rails.logger.info("QuickbooksOauth.refresh_token!: #{oauth2_refresh_token_expires_at}")
|
log "refresh_token!: #{oauth2_refresh_token_expires_at}"
|
||||||
|
|
||||||
update!(
|
update!(
|
||||||
oauth2_access_token: refreshed.token,
|
oauth2_access_token: refreshed.token,
|
||||||
@@ -53,20 +55,24 @@ module QuickbooksOauth
|
|||||||
)
|
)
|
||||||
end
|
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
|
def oauth_client
|
||||||
self.class.construct_oauth2_client
|
self.class.construct_oauth2_client
|
||||||
end
|
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
|
def oauth_access_token
|
||||||
OAuth2::AccessToken.new(oauth_client, oauth2_access_token, refresh_token: oauth2_refresh_token)
|
OAuth2::AccessToken.new(oauth_client, oauth2_access_token, refresh_token: oauth2_refresh_token)
|
||||||
end
|
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
|
def consumer
|
||||||
oauth_access_token
|
oauth_access_token
|
||||||
end
|
end
|
||||||
|
|
||||||
module ClassMethods
|
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
|
def construct_oauth2_client
|
||||||
|
|
||||||
oauth_consumer_key = Setting.plugin_redmine_qbo['settingsOAuthConsumerKey']
|
oauth_consumer_key = Setting.plugin_redmine_qbo['settingsOAuthConsumerKey']
|
||||||
@@ -74,7 +80,7 @@ module QuickbooksOauth
|
|||||||
|
|
||||||
# Are we are playing in the sandbox?
|
# Are we are playing in the sandbox?
|
||||||
Quickbooks.sandbox_mode = Setting.plugin_redmine_qbo[:sandbox] ? true : false
|
Quickbooks.sandbox_mode = Setting.plugin_redmine_qbo[:sandbox] ? true : false
|
||||||
logger.info "Sandbox mode: #{Quickbooks.sandbox_mode}"
|
log "Sandbox mode: #{Quickbooks.sandbox_mode}"
|
||||||
|
|
||||||
options = {
|
options = {
|
||||||
site: "https://appcenter.intuit.com/connect/oauth2",
|
site: "https://appcenter.intuit.com/connect/oauth2",
|
||||||
@@ -85,4 +91,11 @@ module QuickbooksOauth
|
|||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def log(msg)
|
||||||
|
Rails.logger.info "[QuickbooksOauth] #{msg}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
|
|
||||||
class Customer < ActiveRecord::Base
|
class Customer < ActiveRecord::Base
|
||||||
|
|
||||||
|
include Redmine::Acts::Searchable
|
||||||
|
include Redmine::Acts::Event
|
||||||
|
|
||||||
has_many :issues
|
has_many :issues
|
||||||
has_many :invoices
|
has_many :invoices
|
||||||
has_many :estimates
|
has_many :estimates
|
||||||
@@ -18,10 +21,15 @@ class Customer < ActiveRecord::Base
|
|||||||
|
|
||||||
self.primary_key = :id
|
self.primary_key = :id
|
||||||
|
|
||||||
# returns a human readable string
|
acts_as_searchable columns: %w[name phone_number mobile_phone_number ],
|
||||||
def to_s
|
scope: ->(_context) { left_joins(:project) },
|
||||||
return "#{self[:name]} - #{phone_number.split(//).last(4).join unless phone_number.nil?}"
|
date_column: :updated_at
|
||||||
end
|
|
||||||
|
acts_as_event :title => Proc.new {|o| "#{o}"},
|
||||||
|
:url => Proc.new {|o| { :controller => 'customers', :action => 'show', :id => o.id} },
|
||||||
|
:type => :to_s,
|
||||||
|
:description => Proc.new {|o| "#{I18n.t :label_primary_phone}: #{o.phone_number} #{I18n.t:label_mobile_phone}: #{o.mobile_phone_number}"},
|
||||||
|
:datetime => Proc.new {|o| o.updated_at || o.created_at}
|
||||||
|
|
||||||
# Convenience Method
|
# Convenience Method
|
||||||
# returns the customer's email
|
# returns the customer's email
|
||||||
@@ -63,6 +71,12 @@ class Customer < ActiveRecord::Base
|
|||||||
update_phone_number
|
update_phone_number
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Customers are not bound by a project
|
||||||
|
# but we need to implement this method for the Redmine::Acts::Searchable interface
|
||||||
|
def project
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
# Convenience Method
|
# Convenience Method
|
||||||
# returns the customer's mobile phone
|
# returns the customer's mobile phone
|
||||||
def mobile_phone
|
def mobile_phone
|
||||||
@@ -136,68 +150,39 @@ class Customer < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# proforms a bruteforce sync operation
|
# Seach for customers by name or phone number
|
||||||
# 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
|
|
||||||
|
|
||||||
# Searchs the database for a customer by name or phone number with out special chars
|
|
||||||
def self.search(search)
|
def self.search(search)
|
||||||
customers = where("name LIKE ? OR phone_number LIKE ? OR mobile_phone_number LIKE ?", "%#{search}%", "%#{search}%", "%#{search}%")
|
search = sanitize_sql_like(search)
|
||||||
return customers.order(:name)
|
where("name LIKE ? OR phone_number LIKE ? OR mobile_phone_number LIKE ?", "%#{search}%", "%#{search}%", "%#{search}%")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Override the defult redmine seach method to rank results by id
|
||||||
|
def self.search_result_ranks_and_ids(tokens, user, project = nil, options = {})
|
||||||
|
return {} if tokens.blank?
|
||||||
|
|
||||||
|
scope = self.all
|
||||||
|
|
||||||
|
tokens.each do |token|
|
||||||
|
scope = scope.search(token)
|
||||||
|
end
|
||||||
|
|
||||||
|
ids = scope.distinct.limit(options[:limit] || 100).pluck(:id)
|
||||||
|
ids.index_with { |id| id }
|
||||||
|
end
|
||||||
|
|
||||||
|
# proforms a bruteforce sync operation
|
||||||
|
def self.sync
|
||||||
|
CustomerSyncJob.perform_later(full_sync: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
# proforms a bruteforce sync operation
|
# proforms a bruteforce sync operation
|
||||||
# This needs to be simplified
|
|
||||||
def self.sync_by_id(id)
|
def self.sync_by_id(id)
|
||||||
qbo = Qbo.first
|
CustomerSyncJob.perform_later(id: id)
|
||||||
c = qbo.perform_authenticated_request do |access_token|
|
|
||||||
service = Quickbooks::Service::Customer.new(company_id: qbo.realm_id, access_token: access_token)
|
|
||||||
service.fetch_by_id(id)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return unless c
|
# returns a human readable string
|
||||||
|
def to_s
|
||||||
customer = Customer.find_or_create_by(id: c.id)
|
return "#{self[:name]} - #{phone_number.split(//).last(4).join unless phone_number.nil?}"
|
||||||
if c.active?
|
|
||||||
#if not customer.name.eql? c.display_name
|
|
||||||
customer.name = c.display_name
|
|
||||||
customer.id = c.id
|
|
||||||
customer.phone_number = c.primary_phone.free_form_number.tr('^0-9', '') unless c.primary_phone.nil?
|
|
||||||
customer.mobile_phone_number = c.mobile_phone.free_form_number.tr('^0-9', '') unless c.mobile_phone.nil?
|
|
||||||
customer.save_without_push
|
|
||||||
#end
|
|
||||||
else
|
|
||||||
if not customer.new_record?
|
|
||||||
customer.delete
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Push the updates
|
# Push the updates
|
||||||
@@ -205,14 +190,19 @@ class Customer < ActiveRecord::Base
|
|||||||
begin
|
begin
|
||||||
qbo = Qbo.first
|
qbo = Qbo.first
|
||||||
@details = qbo.perform_authenticated_request do |access_token|
|
@details = qbo.perform_authenticated_request do |access_token|
|
||||||
service = Quickbooks::Service::Customer.new(company_id: qbo.realm_id, access_token: access_token)
|
service = Quickbooks::Service::Customer.new(
|
||||||
|
company_id: qbo.realm_id,
|
||||||
|
access_token: access_token
|
||||||
|
)
|
||||||
service.update(@details)
|
service.update(@details)
|
||||||
end
|
end
|
||||||
#raise "QBO Fault" if @details.fault?
|
|
||||||
self.id = @details.id
|
self.id = @details.id
|
||||||
rescue Exception => e
|
rescue => e
|
||||||
errors.add(e.message)
|
errors.add(:base, e.message)
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
save_without_push
|
save_without_push
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -8,54 +8,49 @@
|
|||||||
#
|
#
|
||||||
#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.
|
#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 CustomerToken < ActiveRecord::Base
|
class CustomerToken < ApplicationRecord
|
||||||
|
belongs_to :issue
|
||||||
|
|
||||||
has_many :issues
|
validates :issue_id, presence: true
|
||||||
validates_presence_of :issue_id
|
validates :token, presence: true, uniqueness: true
|
||||||
before_create :generate_token, :generate_expire_date
|
|
||||||
attr_accessor :destroyed
|
|
||||||
after_destroy :mark_as_destroyed
|
|
||||||
|
|
||||||
OAUTH_CONSUMER_SECRET = Setting.plugin_redmine_qbo['settingsOAuthConsumerSecret'] || 'CONFIGURE__' + SecureRandom.uuid
|
before_validation :generate_token, on: :create
|
||||||
|
before_validation :generate_expire_date, on: :create
|
||||||
|
|
||||||
# generates a random token using the plugin setting settingsOAuthConsumerSecret for salt
|
scope :active, -> { where("expires_at > ?", Time.current) }
|
||||||
def generate_token
|
|
||||||
self.token = SecureRandom.base64(15).tr('+/=lIO0', OAUTH_CONSUMER_SECRET)
|
|
||||||
end
|
|
||||||
|
|
||||||
# generates an expiring date
|
TOKEN_EXPIRATION = 1.month
|
||||||
def generate_expire_date
|
|
||||||
self.expires_at = Time.now + 1.month
|
|
||||||
end
|
|
||||||
|
|
||||||
# set destroyed flag
|
# Check if the token has expired
|
||||||
def mark_as_destroyed
|
|
||||||
self.destroyed = true
|
|
||||||
end
|
|
||||||
|
|
||||||
# purge expired tokens
|
|
||||||
def self.remove_expired_tokens
|
|
||||||
where("expires_at < ?", Time.now).destroy_all
|
|
||||||
end
|
|
||||||
|
|
||||||
# has the token expired?
|
|
||||||
def expired?
|
def expired?
|
||||||
self.expires_at < Time.now
|
expires_at.present? && expires_at <= Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
# Getter convenience method for tokens
|
# 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)
|
def self.get_token(issue)
|
||||||
|
return unless issue
|
||||||
|
return unless User.current.allowed_to?(:view_issues, issue.project)
|
||||||
|
|
||||||
# check to see if token exists & if it is expired
|
token = active.find_by(issue_id: issue.id)
|
||||||
token = find_by_issue_id issue.id
|
return token if token
|
||||||
unless token.nil?
|
|
||||||
return token unless token.expired?
|
create!(issue: issue)
|
||||||
# remove expired tokens
|
|
||||||
token.destroy
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# only create new token if we have an issue to attach it to
|
private
|
||||||
return create(issue_id: issue.id) if User.current.logged?
|
|
||||||
|
# Generate a unique token for the customer
|
||||||
|
def generate_token
|
||||||
|
self.token ||= SecureRandom.urlsafe_base64(32)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Generate an expiration date for the token
|
||||||
|
def generate_expire_date
|
||||||
|
self.expires_at ||= Time.current + TOKEN_EXPIRATION
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -13,37 +13,16 @@ class Employee < ActiveRecord::Base
|
|||||||
has_many :users
|
has_many :users
|
||||||
validates_presence_of :id, :name
|
validates_presence_of :id, :name
|
||||||
|
|
||||||
|
self.primary_key = :id
|
||||||
|
|
||||||
|
# Sync all employees, typically triggered by a scheduled task or manual sync request
|
||||||
def self.sync
|
def self.sync
|
||||||
qbo = Qbo.first
|
EmployeeSyncJob.perform_later(full_sync: true)
|
||||||
employees = qbo.perform_authenticated_request do |access_token|
|
|
||||||
service = Quickbooks::Service::Employee.new(company_id: qbo.realm_id, access_token: access_token)
|
|
||||||
service.all
|
|
||||||
end
|
|
||||||
|
|
||||||
return unless employees
|
|
||||||
|
|
||||||
transaction do
|
|
||||||
employees.each { |e|
|
|
||||||
logger.info "Processing employee #{e.id}"
|
|
||||||
employee = find_or_create_by(id: e.id)
|
|
||||||
employee.name = e.display_name
|
|
||||||
employee.id = e.id
|
|
||||||
employee.save!
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Sync a single employee by ID, typically triggered by a webhook notification or manual sync request
|
||||||
def self.sync_by_id(id)
|
def self.sync_by_id(id)
|
||||||
qbo = Qbo.first
|
EmployeeSyncJob.perform_later(id: id)
|
||||||
employee = qbo.perform_authenticated_request do |access_token|
|
|
||||||
service = Quickbooks::Service::Employee.new(company_id: qbo.realm_id, access_token: access_token)
|
|
||||||
service.fetch_by_id(id)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return unless employee
|
|
||||||
employee = find_or_create_by(id: employee.id)
|
|
||||||
employee.name = employee.display_name
|
|
||||||
employee.id = employee.id
|
|
||||||
employee.save!
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,72 +22,22 @@ class Estimate < ActiveRecord::Base
|
|||||||
|
|
||||||
# sync all estimates
|
# sync all estimates
|
||||||
def self.sync
|
def self.sync
|
||||||
logger.info "Syncing ALL estimates"
|
EstimateSyncJob.perform_later(full_sync: false)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# sync only one estimate
|
# sync only one estimate
|
||||||
def self.sync_by_id(id)
|
def self.sync_by_id(id)
|
||||||
logger.info "Syncing estimate #{id}"
|
EstimateSyncJob.perform_later(id: id)
|
||||||
qbo = Qbo.first
|
|
||||||
qbo.perform_authenticated_request do |access_token|
|
|
||||||
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
|
|
||||||
process_estimate(service.fetch_by_id(id))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# sync only one estimate
|
# sync only one estimate
|
||||||
def self.sync_by_doc_number(number)
|
def self.sync_by_doc_number(number)
|
||||||
logger.info "Syncing estimate by doc number #{number}"
|
EstimateSyncJob.perform_later(doc_number: number)
|
||||||
qbo = Qbo.first
|
|
||||||
qbo.perform_authenticated_request do |access_token|
|
|
||||||
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
|
|
||||||
process_estimate(service.find_by( :doc_number, number).first)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# update an estimate
|
|
||||||
def self.update(id)
|
|
||||||
qbo = Qbo.first
|
|
||||||
estimate = qbo.perform_authenticated_request do |access_token|
|
|
||||||
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
|
|
||||||
service.fetch_by_id(id)
|
|
||||||
end
|
|
||||||
|
|
||||||
return unless estimate
|
|
||||||
|
|
||||||
e = find_or_create_by(id: id)
|
|
||||||
e.doc_number = estimate.doc_number
|
|
||||||
e.txn_date = estimate.txn_date
|
|
||||||
e.save!
|
|
||||||
end
|
|
||||||
|
|
||||||
# process an estimate into the database
|
|
||||||
def self.process_estimate(qbo_estimate)
|
|
||||||
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!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# download the pdf from quickbooks
|
# download the pdf from quickbooks
|
||||||
def pdf
|
def pdf
|
||||||
|
log "Downloading PDF for estimate ##{self.id}..."
|
||||||
qbo = Qbo.first
|
qbo = Qbo.first
|
||||||
qbo.perform_authenticated_request do |access_token|
|
qbo.perform_authenticated_request do |access_token|
|
||||||
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
|
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
|
||||||
@@ -118,6 +68,7 @@ class Estimate < ActiveRecord::Base
|
|||||||
|
|
||||||
# pull the details
|
# pull the details
|
||||||
def pull
|
def pull
|
||||||
|
log "Pulling details for estimate ##{self.id}..."
|
||||||
begin
|
begin
|
||||||
raise Exception unless self.id
|
raise Exception unless self.id
|
||||||
qbo = Qbo.first
|
qbo = Qbo.first
|
||||||
@@ -130,4 +81,8 @@ class Estimate < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def log(msg)
|
||||||
|
Rails.logger.info "[Estimate] #{msg}"
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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.
|
#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
|
class Invoice < ActiveRecord::Base
|
||||||
|
|
||||||
has_and_belongs_to_many :issues
|
has_and_belongs_to_many :issues
|
||||||
belongs_to :customer
|
belongs_to :customer
|
||||||
validates_presence_of :doc_number, :id, :customer_id, :txn_date
|
|
||||||
|
validates :id, presence: true, uniqueness: true
|
||||||
|
validates :doc_number, :txn_date, presence: true
|
||||||
|
|
||||||
self.primary_key = :id
|
self.primary_key = :id
|
||||||
|
|
||||||
# returns a human readable string
|
# Return the invoice's document number as its string representation
|
||||||
def to_s
|
def to_s
|
||||||
return self[:doc_number]
|
doc_number
|
||||||
end
|
end
|
||||||
|
|
||||||
# sync ALL the invoices
|
# Sync all invoices from QuickBooks, typically triggered by a scheduled task or manual sync request
|
||||||
def self.sync
|
def self.sync
|
||||||
logger.info "Syncing all invoices"
|
InvoiceSyncJob.perform_later(full_sync: true)
|
||||||
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
|
end
|
||||||
|
|
||||||
return unless invoices
|
# Sync a single invoice by ID, typically triggered by a webhook notification or manual sync request
|
||||||
|
|
||||||
invoices.each { | invoice |
|
|
||||||
process_invoice invoice
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
#sync by invoice ID
|
|
||||||
def self.sync_by_id(id)
|
def self.sync_by_id(id)
|
||||||
logger.info "Syncing invoice #{id}"
|
InvoiceSyncJob.perform_later(id: 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
|
|
||||||
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
|
end
|
||||||
@@ -16,7 +16,7 @@ class Qbo < ActiveRecord::Base
|
|||||||
# Updates last sync time stamp
|
# Updates last sync time stamp
|
||||||
def self.update_time_stamp
|
def self.update_time_stamp
|
||||||
date = DateTime.now
|
date = DateTime.now
|
||||||
logger.info "Updating QBO timestamp to #{date}"
|
log "Updating QBO timestamp to #{date}"
|
||||||
qbo = Qbo.first
|
qbo = Qbo.first
|
||||||
qbo.last_sync = date
|
qbo.last_sync = date
|
||||||
qbo.save
|
qbo.save
|
||||||
@@ -25,4 +25,11 @@ class Qbo < ActiveRecord::Base
|
|||||||
def self.last_sync
|
def self.last_sync
|
||||||
format_time(Qbo.first.last_sync)
|
format_time(Qbo.first.last_sync)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def self.log(msg)
|
||||||
|
logger.info "[QBO] #{msg}"
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
95
app/services/customer_sync_service.rb
Normal 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
|
||||||
93
app/services/employee_sync_service.rb
Normal 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
|
||||||
102
app/services/estimate_sync_service.rb
Normal 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
|
||||||
62
app/services/invoice_attachment_service.rb
Normal 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
|
||||||
69
app/services/invoice_custom_field_sync_service.rb
Normal 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
|
||||||
47
app/services/invoice_push_service.rb
Normal 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
|
||||||
95
app/services/invoice_sync_service.rb
Normal 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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<%= form_tag(customers_path, method: "get", id: "search-form") do %>
|
<%= form_tag(customers_path, method: "get", id: "customer-search-form") do %>
|
||||||
<%= text_field_tag :search, params[:search], placeholder: t(:label_search_customers), autocomplete: "off" %>
|
<%= text_field_tag :search, params[:search], placeholder: t(:label_search_customers), autocomplete: "off" %>
|
||||||
<%= submit_tag t(:label_search) %>
|
<%= submit_tag t(:label_search) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<%= form_tag(estimate_doc_path, method: "get") do %>
|
<%= form_tag(estimate_doc_path, method: "get", id: "estimate-search-form") do %>
|
||||||
<%= text_field_tag :search, params[:search], placeholder: t(:label_search_estimates), autocomplete: "off" %>
|
<%= text_field_tag :search, params[:search], placeholder: t(:label_search_estimates), autocomplete: "off" %>
|
||||||
<%= submit_tag t(:label_search), formtarget: "_blank" %>
|
<%= submit_tag t(:label_search), formtarget: "_blank" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"<div id='footer' align='center'>
|
<div id='footer' align='center'>
|
||||||
<b><%=I18n.translate(:label_last_sync)%>: </b> <%=Qbo.last_sync if Qbo.exists?%>
|
<%= render partial: 'qbo/last_sync' %>
|
||||||
</div>"
|
</div>
|
||||||
@@ -16,7 +16,5 @@ for (const item of appointent_extras) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// You can return the array or use it as needed
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,94 +11,98 @@
|
|||||||
# English strings go here for Rails i18n
|
# English strings go here for Rails i18n
|
||||||
# Usage I18n.t(:label)
|
# Usage I18n.t(:label)
|
||||||
en:
|
en:
|
||||||
field_customer: "Customer"
|
button_bulk_pdf: "Bulk PDF"
|
||||||
field_employee: "Employee"
|
customer_details: "Customer Details"
|
||||||
field_invoice: "Invoice"
|
|
||||||
field_estimate: "Estimate"
|
|
||||||
field_notes: "Notes"
|
|
||||||
field_billed: "Billed"
|
field_billed: "Billed"
|
||||||
label_week: "Week"
|
field_customer: "Customer"
|
||||||
label_search_estimates: "Search Estimates"
|
field_customers: "Customers"
|
||||||
label_search: "Search"
|
field_employee: "Employee"
|
||||||
label_estimates: "Estimates"
|
field_estimate: "Estimate"
|
||||||
warn_ru_sure: "You sure?"
|
field_invoice: "Invoice"
|
||||||
label_delete: "Delete"
|
field_notes: "Notes"
|
||||||
label_edit: "Edit"
|
|
||||||
label_year: "Year"
|
|
||||||
label_make: " Make"
|
|
||||||
label_model: "Model"
|
|
||||||
label_no_customers: "There are no customers containing the term(s)"
|
|
||||||
label_matching: "Matching "
|
|
||||||
label_open_issues: "Open Issues"
|
|
||||||
label_closed_issues: "Closed Issues"
|
|
||||||
label_sync: "Sync"
|
|
||||||
label_new_customer: "New Customer"
|
|
||||||
label_search_customers: "Search Customers"
|
|
||||||
label_customers: "Customers"
|
|
||||||
label_edit_customer: "Edit Customer"
|
|
||||||
label_email: "Email"
|
|
||||||
label_primary_phone: "Primary Phone"
|
|
||||||
label_mobile_phone: "Mobile Phone"
|
|
||||||
label_billing_address: "Billing Address"
|
|
||||||
label_shipping_address: "Shipping Address"
|
|
||||||
label_account_balance: "Account Balance"
|
label_account_balance: "Account Balance"
|
||||||
label_balance_with_jobs: "Balance With Jobs"
|
label_actions: "Actions"
|
||||||
label_display_name: "Display Name"
|
|
||||||
label_details: "Details"
|
|
||||||
label_customer_link_expires: "This customer link expires in"
|
|
||||||
label_amount: "Amount"
|
label_amount: "Amount"
|
||||||
label_deposit_into: "Deposit to Account"
|
label_appointment: "Add Appointment"
|
||||||
label_last_sync: "Last Sync"
|
label_balance_with_jobs: "Balance With Jobs"
|
||||||
label_redmine_qbo: "Redmine Quickbooks"
|
label_bill_time: "Bill Time"
|
||||||
label_customer_count: "Customer Count"
|
label_billing_address: "Billing Address"
|
||||||
label_invoice_count: "Invoice Count"
|
label_billing_error: "Customer could not be billed. Check for Customer or Assignee and try again."
|
||||||
label_estimate_count: "Estimate Count"
|
label_billing_error_no_customer: "Cannot bill without an assigned customer."
|
||||||
label_employee_count: "Employee Count"
|
label_billing_error_no_employee: "Cannot bill without an assigned employee."
|
||||||
|
label_billing_error_no_qbo: "Cannot bill without a QuickBooks connection. Please connect to QuickBooks and try again."
|
||||||
|
label_billing_enqueued: "Billing has been enqueued for issue"
|
||||||
|
label_billed_success: "Successfully billed "
|
||||||
label_client_id: "Intuit QBO OAuth2 Client ID"
|
label_client_id: "Intuit QBO OAuth2 Client ID"
|
||||||
label_client_secret: "Intuit QBO OAuth2 Client Secret"
|
label_client_secret: "Intuit QBO OAuth2 Client Secret"
|
||||||
label_webhook_token: "Intuit QBO Webhook Token"
|
label_closed_issues: "Closed Issues"
|
||||||
label_oauth_expires: "OAuth2 Access Token Expires At"
|
label_connected: "Successfully connected to QuickBooks"
|
||||||
label_oauth_note: "Note: You need to authenticate with Quickbooks after saving your key and secret above"
|
label_create_estimate: "Create Estimate"
|
||||||
field_customers: "Customers"
|
label_customer_count: "Customer Count"
|
||||||
|
label_customer_link_expires: "This customer link expires in"
|
||||||
|
label_customers: "Customers"
|
||||||
|
label_delete: "Delete"
|
||||||
|
label_deposit_into: "Deposit to Account"
|
||||||
|
label_details: "Details"
|
||||||
|
label_display_name: "Display Name"
|
||||||
|
label_door: "Door"
|
||||||
|
label_edit: "Edit"
|
||||||
|
label_edit_customer: "Edit Customer"
|
||||||
|
label_email: "Email"
|
||||||
|
label_employee_count: "Employee Count"
|
||||||
|
label_error: "Error"
|
||||||
|
label_estimate_404: "Estimate not found"
|
||||||
|
label_estimate_count: "Estimate Count"
|
||||||
|
label_estimates: "Estimates"
|
||||||
|
label_hours: "Hours"
|
||||||
|
label_invoice_404: "Invoice not found"
|
||||||
|
label_invoice_count: "Invoice Count"
|
||||||
|
label_invoices: "Invoices"
|
||||||
|
label_last_sync: "Last Sync"
|
||||||
|
label_load_customer: "Load Customer"
|
||||||
|
label_make: "Make"
|
||||||
|
label_matching: "Matching"
|
||||||
|
label_mobile_phone: "Mobile Phone"
|
||||||
|
label_model: "Model"
|
||||||
|
label_name: "Name"
|
||||||
|
label_new_customer: "New Customer"
|
||||||
|
label_no_customers: "There are no customers matching the search term(s)."
|
||||||
label_no_estimates: "No Estimates"
|
label_no_estimates: "No Estimates"
|
||||||
label_no_invoices: "No Invoices"
|
label_no_invoices: "No Invoices"
|
||||||
label_invoices: "Invoices"
|
|
||||||
label_load_customer: "Load Customer"
|
|
||||||
label_door: "Door"
|
|
||||||
label_trim: "Trim"
|
|
||||||
label_bill_time: "Bill Time"
|
|
||||||
label_share: "Share"
|
|
||||||
label_sync_now: "Sync Now"
|
|
||||||
label_invoice_404: "Invoice not found"
|
|
||||||
label_estimate_404: "Estimate not found"
|
|
||||||
label_connected: "Successfully connected to Quickbooks"
|
|
||||||
label_error: "Error"
|
|
||||||
label_billed_success: "Successfully Billed "
|
|
||||||
label_billing_error: "Cannot bill without a customer assigned"
|
|
||||||
label_qbo_sync_success: "Successfully synced to Quickbooks"
|
|
||||||
label_hours: "Hours"
|
|
||||||
label_oauth2_refresh_token_expires_at: "Refresh Token Expires At"
|
label_oauth2_refresh_token_expires_at: "Refresh Token Expires At"
|
||||||
label_name: "Name"
|
label_oauth_expires: "OAuth2 Access Token Expires At"
|
||||||
label_appointment: "Add Appointment"
|
label_oauth_note: "Note: You need to authenticate with QuickBooks after saving your key and secret above."
|
||||||
label_actions: "Actions"
|
label_open_issues: "Open Issues"
|
||||||
label_create_estimate: "Create Estimate"
|
label_primary_phone: "Primary Phone"
|
||||||
label_syncing: "Syncing Quickbooks"
|
label_qbo_sync_success: "Successfully synced to QuickBooks"
|
||||||
|
label_redmine_qbo: "Redmine QuickBooks"
|
||||||
label_sandbox: "Sandbox"
|
label_sandbox: "Sandbox"
|
||||||
button_bulk_pdf: "Bulk PDF"
|
label_search: "Search"
|
||||||
|
label_search_customers: "Search Customers"
|
||||||
|
label_search_estimates: "Search Estimates"
|
||||||
label_select_all: "Select All"
|
label_select_all: "Select All"
|
||||||
notice_customer_created: "Customer created in Quickbooks"
|
label_share: "Share"
|
||||||
notice_customer_updated: "Customer updated in Quickbooks"
|
label_shipping_address: "Shipping Address"
|
||||||
notice_customer_not_found: "Customer not found in Quickbooks"
|
label_sync: "Sync"
|
||||||
notice_customer_not_deleted: "Customer could not be deleted in Quickbooks"
|
label_sync_now: "Sync Now"
|
||||||
notice_customer_deleted: "Customer deleted in Quickbooks"
|
label_syncing: "Syncing QuickBooks"
|
||||||
notice_estimate_created: "Estimate created in Quickbooks"
|
label_trim: "Trim"
|
||||||
notice_estimate_updated: "Estimate updated in Quickbooks"
|
label_webhook_token: "Intuit QBO Webhook Token"
|
||||||
notice_estimate_not_found: "Estimate not found"
|
label_week: "Week"
|
||||||
notice_invoice_created: "Invoice created in Quickbooks"
|
label_year: "Year"
|
||||||
notice_invoice_updated: "Invoice updated in Quickbooks"
|
notice_customer_created: "Customer created in QuickBooks"
|
||||||
|
notice_customer_deleted: "Customer deleted in QuickBooks"
|
||||||
|
notice_customer_not_deleted: "Customer could not be deleted in QuickBooks"
|
||||||
|
notice_customer_not_found: "Customer not found in QuickBooks"
|
||||||
|
notice_customer_updated: "Customer updated in QuickBooks"
|
||||||
|
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, 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"
|
||||||
notice_invoice_not_found: "Invoice not found"
|
notice_invoice_not_found: "Invoice not found"
|
||||||
notice_forbidden: "You do not have permission to access this resource"
|
notice_invoice_updated: "Invoice updated in QuickBooks"
|
||||||
notice_issue_not_found: "Issue not found"
|
notice_issue_not_found: "Issue not found"
|
||||||
customer_details: "Customer Details"
|
warn_ru_sure: "Are you sure?"
|
||||||
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: "
|
|
||||||
15
db/migrate/038_add_customers_timestamp.rb
Normal 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 AddCustomersTimestamp < ActiveRecord::Migration[5.1]
|
||||||
|
def change
|
||||||
|
add_timestamps(:customers, null: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
16
db/migrate/039_add_full_text_index_to_customers.rb
Normal 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 AddFullTextIndexToCustomers < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
# This creates a combined index for name and phone fields
|
||||||
|
add_index :customers, [:name, :phone_number, :mobile_phone_number], type: :fulltext, name: 'ft_search_idx'
|
||||||
|
end
|
||||||
|
end
|
||||||
16
db/migrate/040_add_doc_timestamp.rb
Normal 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
|
||||||
24
db/migrate/041_add_invoice_fields.rb
Normal 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
|
||||||
15
db/migrate/042_add_employee_timestamp.rb
Normal 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
|
||||||
6
init.rb
@@ -14,7 +14,7 @@ Redmine::Plugin.register :redmine_qbo do
|
|||||||
name 'Redmine QBO plugin'
|
name 'Redmine QBO plugin'
|
||||||
author 'Rick Barrette'
|
author 'Rick Barrette'
|
||||||
description 'A pluging for Redmine to connect with QuickBooks Online to create Time Activity Entries for billable hours logged when an Issue is closed'
|
description 'A pluging for Redmine to connect with QuickBooks Online to create Time Activity Entries for billable hours logged when an Issue is closed'
|
||||||
version '2026.2.5'
|
version '2026.2.16'
|
||||||
url 'https://github.com/rickbarrette/redmine_qbo'
|
url 'https://github.com/rickbarrette/redmine_qbo'
|
||||||
author_url 'https://barrettefabrication.com'
|
author_url 'https://barrettefabrication.com'
|
||||||
settings default: {empty: true}, partial: 'qbo/settings'
|
settings default: {empty: true}, partial: 'qbo/settings'
|
||||||
@@ -37,6 +37,10 @@ Redmine::Plugin.register :redmine_qbo do
|
|||||||
# Register top menu items
|
# Register top menu items
|
||||||
menu :top_menu, :customers, { controller: :customers, action: :index }, caption: :label_customers, if: Proc.new {User.current.logged?}
|
menu :top_menu, :customers, { controller: :customers, action: :index }, caption: :label_customers, if: Proc.new {User.current.logged?}
|
||||||
|
|
||||||
|
Redmine::Search.map do |search|
|
||||||
|
search.register :customers
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Dynamically load all Hooks & Patches recursively
|
# Dynamically load all Hooks & Patches recursively
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ module RedmineQbo
|
|||||||
# Edit Issue Form
|
# Edit Issue Form
|
||||||
# Here we build the required form components before passing them to a partial view formatting.
|
# Here we build the required form components before passing them to a partial view formatting.
|
||||||
def view_issues_form_details_bottom(context={})
|
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]
|
f = context[:form]
|
||||||
issue = context[:issue]
|
issue = context[:issue]
|
||||||
project = context[:project]
|
project = context[:project]
|
||||||
Rails.logger.debug issue.inspect
|
log issue.inspect
|
||||||
Rails.logger.debug project.inspect
|
log project.inspect
|
||||||
|
|
||||||
# Customer Name Text Box with database backed autocomplete
|
# Customer Name Text Box with database backed autocomplete
|
||||||
# onchange event will update the hidden customer_id field
|
# 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.
|
# 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)"
|
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
|
# 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
|
# the onchange event will reload the issue form via ajax to update the available estimates
|
||||||
@@ -94,6 +94,12 @@ module RedmineQbo
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def log(msg)
|
||||||
|
Rails.logger.info "[IssuesHookListener] #{msg}"
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -15,10 +15,11 @@ module RedmineQbo
|
|||||||
|
|
||||||
# Load the javascript to support the autocomplete forms
|
# Load the javascript to support the autocomplete forms
|
||||||
def view_layouts_base_html_head(context = {})
|
def view_layouts_base_html_head(context = {})
|
||||||
js = javascript_include_tag 'application.js', plugin: :redmine_qbo
|
safe_join([
|
||||||
js += javascript_include_tag 'autocomplete-rails.js', plugin: :redmine_qbo
|
javascript_include_tag( 'application.js', plugin: :redmine_qbo),
|
||||||
js += javascript_include_tag 'checkbox_controller.js', plugin: :redmine_qbo
|
javascript_include_tag( 'autocomplete-rails.js', plugin: :redmine_qbo),
|
||||||
return js
|
javascript_include_tag( 'checkbox_controller.js', plugin: :redmine_qbo)
|
||||||
|
])
|
||||||
end
|
end
|
||||||
|
|
||||||
render_on :view_layouts_base_sidebar, partial: "qbo/sidebar"
|
render_on :view_layouts_base_sidebar, partial: "qbo/sidebar"
|
||||||
|
|||||||
@@ -12,25 +12,20 @@ require_dependency 'issue'
|
|||||||
|
|
||||||
module RedmineQbo
|
module RedmineQbo
|
||||||
module Patches
|
module Patches
|
||||||
|
|
||||||
# Patches Redmine's Issues dynamically.
|
|
||||||
# Adds relationships for customers, estimates, invoices, customer_tokens
|
|
||||||
# Adds before and after save hooks
|
|
||||||
module IssuePatch
|
module IssuePatch
|
||||||
|
|
||||||
def self.included(base) # :nodoc:
|
def self.included(base)
|
||||||
base.extend(ClassMethods)
|
base.extend(ClassMethods)
|
||||||
|
|
||||||
base.send(:include, InstanceMethods)
|
base.send(:include, InstanceMethods)
|
||||||
|
|
||||||
# Same as typing in the class
|
|
||||||
base.class_eval do
|
base.class_eval do
|
||||||
belongs_to :customer, primary_key: :id
|
belongs_to :customer, class_name: 'Customer', foreign_key: :customer_id, optional: true
|
||||||
belongs_to :customer_token, primary_key: :id
|
belongs_to :customer_token, primary_key: :id
|
||||||
belongs_to :estimate, primary_key: :id
|
belongs_to :estimate, primary_key: :id
|
||||||
has_and_belongs_to_many :invoices
|
has_and_belongs_to_many :invoices
|
||||||
|
|
||||||
before_save :titlize_subject
|
before_save :titlize_subject
|
||||||
after_save :bill_time
|
after_commit :enqueue_billing, on: :update
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -41,94 +36,45 @@ module RedmineQbo
|
|||||||
|
|
||||||
module InstanceMethods
|
module InstanceMethods
|
||||||
|
|
||||||
# Create billable time entries
|
# 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 bill_time
|
def enqueue_billing
|
||||||
logger.debug "QBO: Billing time for issue ##{id}"
|
log "Checking if issue needs to be billed for issue ##{id}"
|
||||||
return unless status.is_closed?
|
#return unless saved_change_to_status_id?
|
||||||
return if assigned_to.nil?
|
return unless closed?
|
||||||
|
return unless customer.present?
|
||||||
|
return unless assigned_to&.employee_id.present?
|
||||||
return unless Qbo.first
|
return unless Qbo.first
|
||||||
return unless customer
|
|
||||||
|
|
||||||
Thread.new do
|
log "Enqueuing billing for issue ##{id}"
|
||||||
spent_time = time_entries.where(billed: [false, nil])
|
BillIssueTimeJob.perform_later(id)
|
||||||
spent_hours ||= spent_time.sum(:hours) || 0
|
|
||||||
|
|
||||||
if spent_hours > 0 then
|
|
||||||
|
|
||||||
# Prepare to create a new Time Activity
|
|
||||||
qbo = Qbo.first
|
|
||||||
qbo.perform_authenticated_request do |access_token|
|
|
||||||
time_service = Quickbooks::Service::TimeActivity.new(company_id: qbo.realm_id, access_token: access_token)
|
|
||||||
item_service = Quickbooks::Service::Item.new(company_id: qbo.realm_id, access_token: access_token)
|
|
||||||
time_entry = Quickbooks::Model::TimeActivity.new
|
|
||||||
|
|
||||||
# Lets total up each activity before billing.
|
|
||||||
# This will simpify the invoicing with a single billable time entry per time activity
|
|
||||||
h = Hash.new(0)
|
|
||||||
spent_time.each do |entry|
|
|
||||||
h[entry.activity.name] += entry.hours
|
|
||||||
# update time entries billed status
|
|
||||||
entry.billed = true
|
|
||||||
entry.save
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Now letes upload our totals for each activity as their own billable time entry
|
# Titlize the subject of the issue before saving to ensure consistent formatting for billing descriptions in Quickbooks
|
||||||
h.each do |key, val|
|
|
||||||
|
|
||||||
# Convert float spent time to hours and minutes
|
|
||||||
hours = val.to_i
|
|
||||||
minutesDecimal = (( val - hours) * 60)
|
|
||||||
minutes = minutesDecimal.to_i
|
|
||||||
|
|
||||||
# Lets match the activity to an qbo item
|
|
||||||
item = item_service.query("SELECT * FROM Item WHERE Name = '#{key}' ").first
|
|
||||||
next if item.nil?
|
|
||||||
|
|
||||||
# Create the new billable time entry and upload it
|
|
||||||
time_entry.description = "#{tracker} ##{id}: #{subject} #{"(Partial @ #{done_ratio}%)" if not closed?}"
|
|
||||||
time_entry.employee_id = assigned_to.employee_id
|
|
||||||
time_entry.customer_id = customer_id
|
|
||||||
time_entry.billable_status = "Billable"
|
|
||||||
time_entry.hours = hours
|
|
||||||
time_entry.minutes = minutes
|
|
||||||
time_entry.name_of = "Employee"
|
|
||||||
time_entry.txn_date = Date.today
|
|
||||||
time_entry.hourly_rate = item.unit_price
|
|
||||||
time_entry.item_id = item.id
|
|
||||||
time_entry.start_time = start_date
|
|
||||||
time_entry.end_time = Time.now
|
|
||||||
time_service.create(time_entry)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create a shareable link for a customer
|
|
||||||
def share_token
|
|
||||||
CustomerToken.get_token self
|
|
||||||
end
|
|
||||||
|
|
||||||
# Titleize the subject before save , but keep words containing numbers mixed with letters capitalized
|
|
||||||
def titlize_subject
|
def titlize_subject
|
||||||
logger.debug "QBO: Titlizing subject for issue ##{self.id}"
|
log "Titlizing subject for issue ##{id}"
|
||||||
self.subject = self.subject.split(/\s+/).map do |word|
|
|
||||||
# If word is NOT purely alphanumeric (contains special chars),
|
self.subject = subject.split(/\s+/).map do |word|
|
||||||
# or is all upper/lower, we can handle it.
|
|
||||||
# excluding alphanumeric strings with mixed case and numbers (e.g., "ID555ABC") from being altered.
|
|
||||||
if word =~ /[A-Z]/ && word =~ /[0-9]/
|
if word =~ /[A-Z]/ && word =~ /[0-9]/
|
||||||
word
|
word
|
||||||
else
|
else
|
||||||
word.downcase
|
|
||||||
word.capitalize
|
word.capitalize
|
||||||
end
|
end
|
||||||
end.join(' ')
|
end.join(' ')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add module to Issue
|
# 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.
|
||||||
Issue.send(:include, IssuePatch)
|
def share_token
|
||||||
|
CustomerToken.get_token(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def log(msg)
|
||||||
|
Rails.logger.info "[IssuePatch] #{msg}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Issue.send(:include, IssuePatch)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -58,7 +58,7 @@ module RedmineQbo
|
|||||||
#left << [l(:field_category), issue.category] unless issue.disabled_core_fields.include?(:category_id)
|
#left << [l(:field_category), issue.category] unless issue.disabled_core_fields.include?(:category_id)
|
||||||
#left << [l(:field_fixed_version), issue.fixed_version] unless issue.disabled_core_fields.include?(:fixed_version_id)
|
#left << [l(:field_fixed_version), issue.fixed_version] unless issue.disabled_core_fields.include?(:fixed_version_id)
|
||||||
|
|
||||||
logger.debug "Calling :pdf_left hook"
|
log "Calling :pdf_left hook"
|
||||||
left_hook_output = Redmine::Hook.call_hook :pdf_left, { issue: issue }
|
left_hook_output = Redmine::Hook.call_hook :pdf_left, { issue: issue }
|
||||||
unless left_hook_output.nil?
|
unless left_hook_output.nil?
|
||||||
left_hook_output.each do |l|
|
left_hook_output.each do |l|
|
||||||
@@ -73,7 +73,7 @@ module RedmineQbo
|
|||||||
right << [l(:field_estimated_hours), l_hours(issue.estimated_hours)] unless issue.disabled_core_fields.include?(:estimated_hours)
|
right << [l(:field_estimated_hours), l_hours(issue.estimated_hours)] unless issue.disabled_core_fields.include?(:estimated_hours)
|
||||||
right << [l(:label_spent_time), l_hours(issue.total_spent_hours)] if User.current.allowed_to?(:view_time_entries, issue.project)
|
right << [l(:label_spent_time), l_hours(issue.total_spent_hours)] if User.current.allowed_to?(:view_time_entries, issue.project)
|
||||||
|
|
||||||
logger.debug "Calling :pdf_right hook"
|
log "Calling :pdf_right hook"
|
||||||
right_hook_output = Redmine::Hook.call_hook :pdf_right, { issue: issue }
|
right_hook_output = Redmine::Hook.call_hook :pdf_right, { issue: issue }
|
||||||
unless right_hook_output.nil?
|
unless right_hook_output.nil?
|
||||||
right_hook_output.each do |r|
|
right_hook_output.each do |r|
|
||||||
@@ -269,6 +269,13 @@ module RedmineQbo
|
|||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def log(msg)
|
||||||
|
Rails.logger.info "[PdfPatch] #{msg}"
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Redmine::Export::PDF::IssuesPdfHelper.send(:include, PdfPatch)
|
Redmine::Export::PDF::IssuesPdfHelper.send(:include, PdfPatch)
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ module RedmineQbo
|
|||||||
module Patches
|
module Patches
|
||||||
module QueryPatch
|
module QueryPatch
|
||||||
|
|
||||||
|
def base_scope
|
||||||
|
scope = super
|
||||||
|
|
||||||
|
if filters['customer_name'].present?
|
||||||
|
scope = scope.left_outer_joins(:customer)
|
||||||
|
end
|
||||||
|
|
||||||
|
if has_column?(:customer) || filters['customer_name'].present?
|
||||||
|
scope = scope.includes(:customer)
|
||||||
|
end
|
||||||
|
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
|
||||||
# Add qbo options to the aviable columns
|
# Add qbo options to the aviable columns
|
||||||
def available_columns
|
def available_columns
|
||||||
unless @available_columns
|
unless @available_columns
|
||||||
@@ -26,10 +40,27 @@ module RedmineQbo
|
|||||||
|
|
||||||
# Add customers to filters
|
# Add customers to filters
|
||||||
def initialize_available_filters
|
def initialize_available_filters
|
||||||
#add_available_filter "customer", type: :text
|
#add_available_filter "customer_id", type: :list, name: l(:field_customer), :values => lambda {Customer.pluck(:name, :id).map {|name, id| [name, id.to_s]}}
|
||||||
|
add_available_filter( 'customer_name', type: :text, name: l(:field_customer))
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sql_for_customer_name_field(field, operator, value)
|
||||||
|
pattern = "%#{value.first}%"
|
||||||
|
|
||||||
|
sql = case operator
|
||||||
|
when '~'
|
||||||
|
"#{Customer.table_name}.name LIKE ?"
|
||||||
|
when '!~'
|
||||||
|
"#{Customer.table_name}.name NOT LIKE ?"
|
||||||
|
else
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
Issue.joins(:customer).sanitize_sql_for_conditions([sql, pattern])
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add module to Issue
|
# Add module to Issue
|
||||||
|
|||||||