Compare commits
128 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 | |||
| 331c1eabeb | |||
| 167385bb99 | |||
| 11b9876d4f | |||
| 9cf72821b0 | |||
| 57adcce431 | |||
| 7fdb15f7e8 | |||
| 6e11e05a24 | |||
| a6751d3f41 | |||
| 8944e92ffc | |||
| f0c0a42c96 | |||
| a4b51457bb | |||
| fb4a883b43 | |||
| c24ec93335 | |||
| df49964bf9 | |||
| 502ba94465 | |||
| ff038fe5ae | |||
| 3eed122598 | |||
| d8d34540a9 | |||
| c01cc5ca97 | |||
| 6a2f7a1146 | |||
| f4c844f097 | |||
| 1135c69e1b | |||
| ef86d222cb | |||
| be88a601ae | |||
| e6c4e81df2 | |||
| f4a979672f | |||
| 8a4d64ffc0 | |||
| ac05d38763 | |||
| 548dc4fba8 | |||
| 7a73b7e8a9 | |||
| b38bd951f7 | |||
| 0e3318efdd | |||
| d063494bd2 | |||
| e35a2148eb | |||
| c8f115ae02 | |||
| d59e52b111 | |||
| 2c3548d1ac | |||
| d80007bc84 | |||
| 5d7d9a81bb | |||
| b030f85b74 | |||
| 2f0ee6a6d6 | |||
| 637cfa89b4 | |||
| c36f4c905b | |||
| 83fb20044d | |||
| 928e632dd3 | |||
| 8b9cf5066e | |||
| 45bfce87d8 | |||
| 6f33e9d23d | |||
| 92460392b9 | |||
| f1bdf59697 | |||
| 60e2f1d2b0 | |||
| 6c9ae82f81 | |||
| 42e4494f6e | |||
| 7e0b2c9d09 | |||
| 5ca68b01b6 | |||
| ebd4fa7363 | |||
| e6818958ae | |||
| 5b31459629 | |||
| 92de2928f6 | |||
| a8af180de2 | |||
| e621dc9e3a | |||
| c3d7c1c867 | |||
| defeec7f8e | |||
| 37c302e274 | |||
| 006e907b35 | |||
| f1f77a8022 |
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]
|
||||
skip_before_action :verify_authenticity_token, :check_if_login_required, only: [:view]
|
||||
|
||||
default_search_scope :names
|
||||
|
||||
autocomplete :customer, :name, full: true, extra_data: [:id]
|
||||
|
||||
def allowed_params
|
||||
@@ -53,7 +51,7 @@ class CustomersController < ApplicationController
|
||||
# display a list of all customers
|
||||
def index
|
||||
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)
|
||||
redirect_to @customers.first
|
||||
end
|
||||
@@ -69,7 +67,7 @@ class CustomersController < ApplicationController
|
||||
def create
|
||||
@customer = Customer.new(allowed_params)
|
||||
if @customer.save
|
||||
flash[:notice] = "New Customer Created"
|
||||
flash[:notice] = t :notice_customer_created
|
||||
redirect_to @customer
|
||||
else
|
||||
flash[:error] = @customer.errors.full_messages.to_sentence
|
||||
@@ -90,6 +88,7 @@ class CustomersController < ApplicationController
|
||||
@issues.open.each { |i| @hours+= i.total_spent_hours }
|
||||
@closed_issues.each { |i| @closed_hours+= i.total_spent_hours }
|
||||
rescue
|
||||
flash[:error] = t :notice_customer_not_found
|
||||
render_404
|
||||
end
|
||||
end
|
||||
@@ -99,6 +98,7 @@ class CustomersController < ApplicationController
|
||||
begin
|
||||
@customer = Customer.find_by_id(params[:id])
|
||||
rescue
|
||||
flash[:error] = t :notice_customer_not_found
|
||||
render_404
|
||||
end
|
||||
end
|
||||
@@ -108,13 +108,14 @@ class CustomersController < ApplicationController
|
||||
begin
|
||||
@customer = Customer.find_by_id(params[:id])
|
||||
if @customer.update(allowed_params)
|
||||
flash[:notice] = "Customer updated"
|
||||
flash[:notice] = t :notice_customer_updated
|
||||
redirect_to @customer
|
||||
else
|
||||
redirect_to edit_customer_path
|
||||
flash[:error] = @customer.errors.full_messages.to_sentence if @customer.errors
|
||||
end
|
||||
rescue
|
||||
flash[:error] = t :notice_customer_not_found
|
||||
render_404
|
||||
end
|
||||
end
|
||||
@@ -123,67 +124,70 @@ class CustomersController < ApplicationController
|
||||
def destroy
|
||||
begin
|
||||
Customer.find_by_id(params[:id]).destroy
|
||||
flash[:notice] = "Customer deleted successfully"
|
||||
flash[:notice] = t :notice_customer_deleted
|
||||
redirect_to action: :index
|
||||
rescue
|
||||
flash[:error] = t :notice_customer_not_deleted
|
||||
render_404
|
||||
end
|
||||
end
|
||||
|
||||
# creates new customer view tokens, removes expired tokens & redirects to newly created customer view with new token.
|
||||
def share
|
||||
issue = Issue.find(params[:id])
|
||||
|
||||
Thread.new do
|
||||
logger.info "Removing expired customer tokens"
|
||||
CustomerToken.remove_expired_tokens
|
||||
ActiveRecord::Base.connection.close
|
||||
end
|
||||
token = issue.share_token
|
||||
redirect_to view_path(token.token)
|
||||
|
||||
begin
|
||||
issue = Issue.find_by_id(params[:id])
|
||||
redirect_to view_path issue.share_token.token
|
||||
rescue
|
||||
render_404
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
flash[:error] = t(:notice_issue_not_found)
|
||||
render_404
|
||||
end
|
||||
|
||||
# displays an issue for a customer with a provided security CustomerToken
|
||||
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]
|
||||
begin
|
||||
@token.destroy if @token.expired?
|
||||
raise "Token Expired" if @token.destroyed
|
||||
# Load associated issue
|
||||
@issue = @token.issue
|
||||
return render_403 unless @issue
|
||||
|
||||
session[:token] = @token.token
|
||||
@issue = Issue.find @token.issue_id
|
||||
@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.reject!(&:private_notes?) unless User.current.allowed_to?(:view_private_notes, @issue.project)
|
||||
Journal.preload_journals_details_custom_fields(@journals)
|
||||
@journals.select! {|journal| journal.notes? || journal.visible_details.any?}
|
||||
@journals.reverse! if User.current.wants_comments_in_reverse_order?
|
||||
# Optional: enforce token belongs to the issue's customer
|
||||
return render_403 unless @issue.customer_id == @token.issue.customer_id
|
||||
|
||||
@changesets = @issue.changesets.visible.preload(:repository, :user).to_a
|
||||
@changesets.reverse! if User.current.wants_comments_in_reverse_order?
|
||||
# Store token in session for subsequent requests if needed
|
||||
session[:token] = @token.token
|
||||
|
||||
@relations = @issue.relations.select {|r| r.other_issue(@issue) && r.other_issue(@issue).visible? }
|
||||
@allowed_statuses = @issue.new_statuses_allowed_to(User.current)
|
||||
@priorities = IssuePriority.active
|
||||
@time_entry = TimeEntry.new(issue: @issue, project: @issue.project)
|
||||
@relation = IssueRelation.new
|
||||
rescue
|
||||
render_403
|
||||
end
|
||||
load_issue_data
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render_403
|
||||
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.reject!(&:private_notes?) unless User.current.allowed_to?(:view_private_notes, @issue.project)
|
||||
Journal.preload_journals_details_custom_fields(@journals)
|
||||
@journals.select! { |journal| journal.notes? || journal.visible_details.any? }
|
||||
@journals.reverse! if User.current.wants_comments_in_reverse_order?
|
||||
|
||||
@changesets = @issue.changesets.visible.preload(:repository, :user).to_a
|
||||
@changesets.reverse! if User.current.wants_comments_in_reverse_order?
|
||||
|
||||
@relations = @issue.relations.select { |r| r.other_issue(@issue)&.visible? }
|
||||
@allowed_statuses = @issue.new_statuses_allowed_to(User.current)
|
||||
@priorities = IssuePriority.active
|
||||
@time_entry = TimeEntry.new(issue: @issue, project: @issue.project)
|
||||
@relation = IssueRelation.new
|
||||
end
|
||||
|
||||
# redmine permission - add customers
|
||||
def add_customer
|
||||
global_check_permission(:add_customers)
|
||||
@@ -221,4 +225,8 @@ class CustomersController < ApplicationController
|
||||
return string
|
||||
end
|
||||
|
||||
def log(msg)
|
||||
Rails.logger.info "[CustomersController] #{msg}"
|
||||
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? }
|
||||
|
||||
def get_estimate
|
||||
log "Searching for estimate with params: #{params.inspect}"
|
||||
|
||||
e = Estimate.find_by_doc_number(params[:search]) if params[:search]
|
||||
e = Estimate.find_by_id(params[:id]) if params[:id]
|
||||
|
||||
# Force sync for estimate by doc number if not found
|
||||
if Estimate.find_by_doc_number(params[:search]).nil?
|
||||
if e.nil? && params[:search]
|
||||
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
|
||||
logger.info "Estimate.find_by_doc_number failed"
|
||||
log "Estimate.find_by_doc_number failed"
|
||||
end
|
||||
end
|
||||
|
||||
estimate = Estimate.find_by_id(params[:id]) if params[:id]
|
||||
estimate = Estimate.find_by_doc_number(params[:search]) if params[:search]
|
||||
return estimate
|
||||
# Force sync for estimate by id if not found
|
||||
if e.nil? && params[:id]
|
||||
begin
|
||||
Estimate.sync_by_id(params[:id])
|
||||
e = Estimate.find_by_id(params[:id])
|
||||
rescue
|
||||
log "Estimate.find_by_id failed"
|
||||
end
|
||||
end
|
||||
|
||||
return e
|
||||
end
|
||||
|
||||
#
|
||||
@@ -36,9 +50,9 @@ class EstimateController < ApplicationController
|
||||
estimate = get_estimate
|
||||
|
||||
begin
|
||||
send_data estimate.pdf, filename: "estimate #{estimate.doc_number}.pdf", disposition: 'inline', type: "application/pdf"
|
||||
send_data estimate.pdf, filename: "estimate #{estimate.doc_number}.pdf", disposition: :inline, type: "application/pdf"
|
||||
rescue
|
||||
redirect_to :back, flash: { error: "Estimate not found" }
|
||||
redirect_to :back, flash: { error: I18n.t(:notice_estimate_not_found) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,10 +63,16 @@ class EstimateController < ApplicationController
|
||||
estimate = get_estimate
|
||||
|
||||
begin
|
||||
send_data estimate.pdf, filename: "estimate #{estimate.doc_number}.pdf", disposition: 'inline', type: "application/pdf"
|
||||
send_data estimate.pdf, filename: "estimate #{estimate.doc_number}.pdf", disposition: :inline, type: "application/pdf"
|
||||
rescue
|
||||
redirect_to :back, flash: { error: "Estimate not found" }
|
||||
redirect_to :back, flash: { error: I18n.t(:notice_estimate_not_found) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log(msg)
|
||||
Rails.logger.info "[EstimateController] #{msg}"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -19,7 +19,7 @@ class InvoiceController < ApplicationController
|
||||
# Downloads and forwards the invoice pdf
|
||||
#
|
||||
def show
|
||||
logger.info("Processing request for URL: #{request.original_url}")
|
||||
log "Processing request for URL: #{request.original_url}"
|
||||
begin
|
||||
qbo = Qbo.first
|
||||
qbo.perform_authenticated_request do |access_token|
|
||||
@@ -27,10 +27,10 @@ class InvoiceController < ApplicationController
|
||||
|
||||
# If multiple id's then pull each pdf & combine them
|
||||
if params[:invoice_ids]
|
||||
logger.info("Grabbing pdfs for " + params[:invoice_ids].join(', '))
|
||||
log "Grabbing pdfs for " + params[:invoice_ids].join(', ')
|
||||
ref = ""
|
||||
params[:invoice_ids].each do |i|
|
||||
logger.info("processing " + i)
|
||||
log "processing " + i
|
||||
invoice = service.fetch_by_id(i)
|
||||
ref += " #{invoice.doc_number}"
|
||||
@pdf << CombinePDF.parse(service.pdf(invoice)) unless @pdf.nil?
|
||||
@@ -45,10 +45,16 @@ class InvoiceController < ApplicationController
|
||||
ref = invoice.doc_number
|
||||
end
|
||||
|
||||
send_data @pdf, filename: "invoice #{ref}.pdf", disposition: 'inline', type: "application/pdf"
|
||||
send_data @pdf, filename: "invoice #{ref}.pdf", disposition: :inline, type: "application/pdf"
|
||||
end
|
||||
rescue
|
||||
redirect_to :back, flash: { error: "Invoice not found" }
|
||||
redirect_to :back, flash: { error: I18n.t(:notice_invoice_not_found) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log(msg)
|
||||
Rails.logger.info "[InvoiceController] #{msg}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,7 +26,7 @@ class QboController < ApplicationController
|
||||
#
|
||||
def authenticate
|
||||
redirect_uri = "#{Setting.protocol}://#{Setting.host_name + qbo_oauth_callback_path}"
|
||||
logger.info "redirect_uri: #{redirect_uri}"
|
||||
log "redirect_uri: #{redirect_uri}"
|
||||
oauth2_client = Qbo.construct_oauth2_client
|
||||
grant_url = oauth2_client.auth_code.authorize_url(redirect_uri: redirect_uri, response_type: "code", state: SecureRandom.hex(12), scope: "com.intuit.quickbooks.accounting")
|
||||
redirect_to grant_url
|
||||
@@ -62,96 +62,76 @@ class QboController < ApplicationController
|
||||
|
||||
# Manual Billing
|
||||
def bill
|
||||
i = Issue.find_by_id params[:id]
|
||||
if i.customer
|
||||
i.bill_time
|
||||
redirect_to i, flash: { notice: I18n.t(:label_billed_success) + i.customer.name }
|
||||
else
|
||||
redirect_to i, flash: { error: I18n.t(:label_billing_error) }
|
||||
end
|
||||
end
|
||||
issue = Issue.find_by(id: params[:id])
|
||||
return render_404 unless issue
|
||||
|
||||
# Quickbooks Webhook Callback
|
||||
def webhook
|
||||
|
||||
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
|
||||
|
||||
# Record that last time we updated
|
||||
Qbo.update_time_stamp
|
||||
ActiveRecord::Base.connection.close
|
||||
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
|
||||
unless issue.customer
|
||||
redirect_to issue, flash: { error: I18n.t(:label_billing_error_no_customer) }
|
||||
return
|
||||
end
|
||||
|
||||
logger.info "Quickbooks webhook complete"
|
||||
unless issue.assigned_to&.employee_id.present?
|
||||
redirect_to issue, flash: { error: I18n.t(:label_billing_error_no_employee) }
|
||||
return
|
||||
end
|
||||
|
||||
unless Qbo.first
|
||||
redirect_to issue, flash: { error: I18n.t(:label_billing_error_no_qbo) }
|
||||
return
|
||||
end
|
||||
|
||||
BillIssueTimeJob.perform_later(issue.id)
|
||||
|
||||
redirect_to issue, flash: {
|
||||
notice: I18n.t(:label_billing_enqueued) + " #{issue.customer.name}"
|
||||
}
|
||||
end
|
||||
|
||||
#
|
||||
# Synchronizes the QboCustomer table with QBO
|
||||
#
|
||||
def sync
|
||||
logger.info "Syncing EVERYTHING"
|
||||
# Update info in background
|
||||
Thread.new do
|
||||
if Qbo.exists?
|
||||
Customer.sync
|
||||
Invoice.sync
|
||||
Employee.sync
|
||||
Estimate.sync
|
||||
log "Syncing EVERYTHING"
|
||||
|
||||
# Record the last sync time
|
||||
Qbo.update_time_stamp
|
||||
end
|
||||
ActiveRecord::Base.connection.close
|
||||
end
|
||||
CustomerSyncJob.perform_later(full_sync: true)
|
||||
EstimateSyncJob.perform_later(full_sync: true)
|
||||
InvoiceSyncJob.perform_later(full_sync: true)
|
||||
EmployeeSyncJob.perform_later(full_sync: true)
|
||||
|
||||
redirect_to :home, flash: { notice: I18n.t(:label_syncing) }
|
||||
end
|
||||
|
||||
# QuickBooks Webhook Callback
|
||||
def webhook
|
||||
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
|
||||
|
||||
@@ -13,6 +13,7 @@ module AuthHelper
|
||||
def require_user
|
||||
return unless session[:token].nil?
|
||||
if !User.current.logged?
|
||||
flash[:error] = t :notice_forbidden
|
||||
render_403
|
||||
end
|
||||
end
|
||||
@@ -27,6 +28,7 @@ module AuthHelper
|
||||
|
||||
def check_permission(permission)
|
||||
if !allowed_to?(permission)
|
||||
flash[:error] = t :notice_forbidden
|
||||
render_403
|
||||
end
|
||||
end
|
||||
@@ -34,6 +36,7 @@ module AuthHelper
|
||||
|
||||
def global_check_permission(permission)
|
||||
if !globaly_allowed_to?(permission)
|
||||
flash[:error] = t :notice_forbidden
|
||||
render_403
|
||||
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
|
||||
@@ -8,22 +8,30 @@
|
||||
#
|
||||
#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.
|
||||
|
||||
module Hooks
|
||||
class EstimateSyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
retry_on StandardError, wait: 5.minutes, attempts: 5
|
||||
|
||||
class UsersShowHookListener < Redmine::Hook::ViewListener
|
||||
def perform(full_sync: false, id: nil, doc_number: nil)
|
||||
qbo = Qbo.first
|
||||
return unless qbo
|
||||
|
||||
# View User
|
||||
def view_users_form(context={})
|
||||
log "Starting #{full_sync ? 'full' : 'incremental'} sync for estimate ##{id || doc_number || 'all'}..."
|
||||
|
||||
# Update the users
|
||||
#Employee.update_all
|
||||
service = EstimateSyncService.new(qbo: qbo)
|
||||
|
||||
# Check to see if there is a quickbooks user attached to the issue
|
||||
@selected = context[:user].employee.id if context[:user].employee
|
||||
|
||||
# Generate the drop down list of quickbooks contacts
|
||||
return "<p>#{context[:form].select :employee_id, Employee.all.pluck(:name, :id), selected: @selected, include_blank: true}</p>"
|
||||
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
|
||||
@@ -8,18 +8,28 @@
|
||||
#
|
||||
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
module Hooks
|
||||
class InvoiceSyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
retry_on StandardError, wait: 5.minutes, attempts: 5
|
||||
|
||||
class ViewLayoutsHookListener < Redmine::Hook::ViewListener
|
||||
def perform(full_sync: false, id: nil)
|
||||
qbo = Qbo.first
|
||||
return unless qbo
|
||||
|
||||
# Load the javascript to support the autocomplete forms
|
||||
def view_layouts_base_html_head(context = {})
|
||||
js = javascript_include_tag 'application.js', plugin: 'redmine_qbo'
|
||||
js += javascript_include_tag 'autocomplete-rails.js', plugin: 'redmine_qbo'
|
||||
js += javascript_include_tag 'checkbox_controller.js', plugin: 'redmine_qbo'
|
||||
return js
|
||||
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
|
||||
|
||||
# This method will attempt to execute the block and if it encounters an OAuth2::Error or Quickbooks::AuthorizationFailure it will attempt to refresh the token and retry the block. It will try this up to 3 times before giving up and raising an exception.
|
||||
def perform_authenticated_request(&block)
|
||||
attempts = 0
|
||||
begin
|
||||
yield oauth_access_token
|
||||
rescue OAuth2::Error, Quickbooks::AuthorizationFailure => ex
|
||||
Rails.logger.error("QuickbooksOauth.perform: #{ex.message}")
|
||||
log "perform_authenticated_request: #{ex.message}"
|
||||
|
||||
# to prevent an infinite loop here keep a counter and bail out after N times...
|
||||
attempts += 1
|
||||
@@ -32,8 +33,9 @@ module QuickbooksOauth
|
||||
end
|
||||
end
|
||||
|
||||
# This method will attempt to refresh the access token and update the record with the new access token, refresh token and their respective expiration times. If the refresh token expires in more than 0 seconds then we will set the refresh token expiration time to that value, otherwise we will set it to 100 days from now.
|
||||
def refresh_token!
|
||||
Rails.logger.info("QuickbooksOauth.refresh_token!")
|
||||
log "refresh_token!"
|
||||
t = oauth_access_token
|
||||
refreshed = t.refresh!
|
||||
|
||||
@@ -43,7 +45,7 @@ module QuickbooksOauth
|
||||
oauth2_refresh_token_expires_at = 100.days.from_now
|
||||
end
|
||||
|
||||
Rails.logger.info("QuickbooksOauth.refresh_token!: #{oauth2_refresh_token_expires_at}")
|
||||
log "refresh_token!: #{oauth2_refresh_token_expires_at}"
|
||||
|
||||
update!(
|
||||
oauth2_access_token: refreshed.token,
|
||||
@@ -53,28 +55,32 @@ module QuickbooksOauth
|
||||
)
|
||||
end
|
||||
|
||||
# This method will return an instance of the OAuth2::Client class that is configured with the consumer key, consumer secret and the appropriate URLs for the Intuit OAuth2 service. It will also set the sandbox mode based on the plugin settings.
|
||||
def oauth_client
|
||||
self.class.construct_oauth2_client
|
||||
end
|
||||
|
||||
# This method will return an instance of the OAuth2::AccessToken class that is configured with the current access token, refresh token and the OAuth2 client. This access token can be used to make authenticated requests to the Intuit API.
|
||||
def oauth_access_token
|
||||
OAuth2::AccessToken.new(oauth_client, oauth2_access_token, refresh_token: oauth2_refresh_token)
|
||||
end
|
||||
|
||||
# This method is an alias for the oauth_access_token method and is used to provide a more intuitive name for the access token when making authenticated requests.
|
||||
def consumer
|
||||
oauth_access_token
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
|
||||
# This method will construct and return an instance of the OAuth2::Client class that is configured with the consumer key, consumer secret and the appropriate URLs for the Intuit OAuth2 service. It will also set the sandbox mode based on the plugin settings. This method is used by the instance method oauth_client to create a new OAuth2 client for each instance of the model that includes this concern.
|
||||
def construct_oauth2_client
|
||||
|
||||
oauth_consumer_key = Setting.plugin_redmine_qbo['settingsOAuthConsumerKey']
|
||||
oauth_consumer_secret = Setting.plugin_redmine_qbo['settingsOAuthConsumerSecret']
|
||||
|
||||
# Are we are playing in the sandbox?
|
||||
Quickbooks.sandbox_mode = Setting.plugin_redmine_qbo['sandbox'] ? true : false
|
||||
logger.info "Sandbox mode: #{Quickbooks.sandbox_mode}"
|
||||
Quickbooks.sandbox_mode = Setting.plugin_redmine_qbo[:sandbox] ? true : false
|
||||
log "Sandbox mode: #{Quickbooks.sandbox_mode}"
|
||||
|
||||
options = {
|
||||
site: "https://appcenter.intuit.com/connect/oauth2",
|
||||
@@ -85,4 +91,11 @@ module QuickbooksOauth
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def log(msg)
|
||||
Rails.logger.info "[QuickbooksOauth] #{msg}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
|
||||
class Customer < ActiveRecord::Base
|
||||
|
||||
include Redmine::Acts::Searchable
|
||||
include Redmine::Acts::Event
|
||||
|
||||
has_many :issues
|
||||
has_many :invoices
|
||||
has_many :estimates
|
||||
@@ -18,10 +21,15 @@ class Customer < ActiveRecord::Base
|
||||
|
||||
self.primary_key = :id
|
||||
|
||||
# returns a human readable string
|
||||
def to_s
|
||||
return "#{self[:name]} - #{phone_number.split(//).last(4).join unless phone_number.nil?}"
|
||||
end
|
||||
acts_as_searchable columns: %w[name phone_number mobile_phone_number ],
|
||||
scope: ->(_context) { left_joins(:project) },
|
||||
date_column: :updated_at
|
||||
|
||||
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
|
||||
# returns the customer's email
|
||||
@@ -63,6 +71,12 @@ class Customer < ActiveRecord::Base
|
||||
update_phone_number
|
||||
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
|
||||
# returns the customer's mobile phone
|
||||
def mobile_phone
|
||||
@@ -136,68 +150,39 @@ class Customer < ActiveRecord::Base
|
||||
end
|
||||
end
|
||||
|
||||
# proforms a bruteforce sync operation
|
||||
# This needs to be simplified
|
||||
def self.sync
|
||||
# Sync ALL customers if the database is empty
|
||||
qbo = Qbo.first
|
||||
customers = qbo.perform_authenticated_request do |access_token|
|
||||
service = Quickbooks::Service::Customer.new(company_id: qbo.realm_id, access_token: access_token)
|
||||
service.all
|
||||
end
|
||||
|
||||
return unless customers
|
||||
|
||||
customers.each do |c|
|
||||
logger.info "Processing customer #{c.id}"
|
||||
customer = Customer.find_or_create_by(id: c.id)
|
||||
if c.active?
|
||||
#if not customer.name.eql? c.display_name
|
||||
customer.name = c.display_name
|
||||
customer.id = c.id
|
||||
customer.phone_number = c.primary_phone.free_form_number.tr('^0-9', '') unless c.primary_phone.nil?
|
||||
customer.mobile_phone_number = c.mobile_phone.free_form_number.tr('^0-9', '') unless c.mobile_phone.nil?
|
||||
customer.save_without_push
|
||||
#end
|
||||
else
|
||||
if not c.new_record?
|
||||
customer.delete
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Searchs the database for a customer by name or phone number with out special chars
|
||||
# Seach for customers by name or phone number
|
||||
def self.search(search)
|
||||
customers = where("name LIKE ? OR phone_number LIKE ? OR mobile_phone_number LIKE ?", "%#{search}%", "%#{search}%", "%#{search}%")
|
||||
return customers.order(:name)
|
||||
search = sanitize_sql_like(search)
|
||||
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
|
||||
|
||||
# proforms a bruteforce sync operation
|
||||
# This needs to be simplified
|
||||
def self.sync_by_id(id)
|
||||
qbo = Qbo.first
|
||||
c = qbo.perform_authenticated_request do |access_token|
|
||||
service = Quickbooks::Service::Customer.new(company_id: qbo.realm_id, access_token: access_token)
|
||||
service.fetch_by_id(id)
|
||||
end
|
||||
CustomerSyncJob.perform_later(id: id)
|
||||
end
|
||||
|
||||
return unless c
|
||||
|
||||
customer = Customer.find_or_create_by(id: c.id)
|
||||
if c.active?
|
||||
#if not customer.name.eql? c.display_name
|
||||
customer.name = c.display_name
|
||||
customer.id = c.id
|
||||
customer.phone_number = c.primary_phone.free_form_number.tr('^0-9', '') unless c.primary_phone.nil?
|
||||
customer.mobile_phone_number = c.mobile_phone.free_form_number.tr('^0-9', '') unless c.mobile_phone.nil?
|
||||
customer.save_without_push
|
||||
#end
|
||||
else
|
||||
if not customer.new_record?
|
||||
customer.delete
|
||||
end
|
||||
end
|
||||
# returns a human readable string
|
||||
def to_s
|
||||
return "#{self[:name]} - #{phone_number.split(//).last(4).join unless phone_number.nil?}"
|
||||
end
|
||||
|
||||
# Push the updates
|
||||
@@ -205,14 +190,19 @@ class Customer < ActiveRecord::Base
|
||||
begin
|
||||
qbo = Qbo.first
|
||||
@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)
|
||||
end
|
||||
#raise "QBO Fault" if @details.fault?
|
||||
|
||||
self.id = @details.id
|
||||
rescue Exception => e
|
||||
errors.add(e.message)
|
||||
rescue => e
|
||||
errors.add(:base, e.message)
|
||||
return false
|
||||
end
|
||||
|
||||
save_without_push
|
||||
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.
|
||||
|
||||
class CustomerToken < ActiveRecord::Base
|
||||
class CustomerToken < ApplicationRecord
|
||||
belongs_to :issue
|
||||
|
||||
has_many :issues
|
||||
validates_presence_of :issue_id
|
||||
before_create :generate_token, :generate_expire_date
|
||||
attr_accessor :destroyed
|
||||
after_destroy :mark_as_destroyed
|
||||
validates :issue_id, presence: true
|
||||
validates :token, presence: true, uniqueness: true
|
||||
|
||||
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
|
||||
def generate_token
|
||||
self.token = SecureRandom.base64(15).tr('+/=lIO0', OAUTH_CONSUMER_SECRET)
|
||||
end
|
||||
scope :active, -> { where("expires_at > ?", Time.current) }
|
||||
|
||||
# generates an expiring date
|
||||
def generate_expire_date
|
||||
self.expires_at = Time.now + 1.month
|
||||
end
|
||||
TOKEN_EXPIRATION = 1.month
|
||||
|
||||
# set destroyed flag
|
||||
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?
|
||||
# Check if the token has expired
|
||||
def expired?
|
||||
self.expires_at < Time.now
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
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)
|
||||
return unless issue
|
||||
return unless User.current.allowed_to?(:view_issues, issue.project)
|
||||
|
||||
# check to see if token exists & if it is expired
|
||||
token = find_by_issue_id issue.id
|
||||
unless token.nil?
|
||||
return token unless token.expired?
|
||||
# remove expired tokens
|
||||
token.destroy
|
||||
end
|
||||
token = active.find_by(issue_id: issue.id)
|
||||
return token if token
|
||||
|
||||
# only create new token if we have an issue to attach it to
|
||||
return create(issue_id: issue.id) if User.current.logged?
|
||||
create!(issue: issue)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Generate a unique token for the customer
|
||||
def generate_token
|
||||
self.token ||= SecureRandom.urlsafe_base64(32)
|
||||
end
|
||||
|
||||
# Generate an expiration date for the token
|
||||
def generate_expire_date
|
||||
self.expires_at ||= Time.current + TOKEN_EXPIRATION
|
||||
end
|
||||
end
|
||||
@@ -13,37 +13,16 @@ class Employee < ActiveRecord::Base
|
||||
has_many :users
|
||||
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
|
||||
qbo = Qbo.first
|
||||
employees = qbo.perform_authenticated_request do |access_token|
|
||||
service = Quickbooks::Service::Employee.new(company_id: qbo.realm_id, access_token: access_token)
|
||||
service.all
|
||||
end
|
||||
|
||||
return unless employees
|
||||
|
||||
transaction do
|
||||
employees.each { |e|
|
||||
logger.info "Processing employee #{e.id}"
|
||||
employee = find_or_create_by(id: e.id)
|
||||
employee.name = e.display_name
|
||||
employee.id = e.id
|
||||
employee.save!
|
||||
}
|
||||
end
|
||||
EmployeeSyncJob.perform_later(full_sync: true)
|
||||
end
|
||||
|
||||
# Sync a single employee by ID, typically triggered by a webhook notification or manual sync request
|
||||
def self.sync_by_id(id)
|
||||
qbo = Qbo.first
|
||||
employee = qbo.perform_authenticated_request do |access_token|
|
||||
service = Quickbooks::Service::Employee.new(company_id: qbo.realm_id, access_token: access_token)
|
||||
service.fetch_by_id(id)
|
||||
end
|
||||
|
||||
return unless employee
|
||||
employee = find_or_create_by(id: employee.id)
|
||||
employee.name = employee.display_name
|
||||
employee.id = employee.id
|
||||
employee.save!
|
||||
EmployeeSyncJob.perform_later(id: id)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -22,72 +22,22 @@ class Estimate < ActiveRecord::Base
|
||||
|
||||
# sync all estimates
|
||||
def self.sync
|
||||
logger.info "Syncing ALL estimates"
|
||||
qbo = Qbo.first
|
||||
estimates = qbo.perform_authenticated_request do |access_token|
|
||||
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
|
||||
service.all
|
||||
end
|
||||
|
||||
return unless estimates
|
||||
|
||||
estimates.each { |estimate|
|
||||
process_estimate(estimate)
|
||||
}
|
||||
|
||||
#remove deleted estimates
|
||||
where.not(estimates.map(&:id)).destroy_all
|
||||
EstimateSyncJob.perform_later(full_sync: false)
|
||||
end
|
||||
|
||||
# sync only one estimate
|
||||
def self.sync_by_id(id)
|
||||
logger.info "Syncing estimate #{id}"
|
||||
qbo = Qbo.first
|
||||
qbo.perform_authenticated_request do |access_token|
|
||||
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
|
||||
process_estimate(service.fetch_by_id(id))
|
||||
end
|
||||
EstimateSyncJob.perform_later(id: id)
|
||||
end
|
||||
|
||||
# sync only one estimate
|
||||
def self.sync_by_doc_number(number)
|
||||
logger.info "Syncing estimate by doc number #{number}"
|
||||
qbo = Qbo.first
|
||||
qbo.perform_authenticated_request do |access_token|
|
||||
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
|
||||
process_estimate(service.find_by( :doc_number, number).first)
|
||||
end
|
||||
end
|
||||
|
||||
# update an estimate
|
||||
def self.update(id)
|
||||
qbo = Qbo.first
|
||||
estimate = qbo.perform_authenticated_request do |access_token|
|
||||
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
|
||||
service.fetch_by_id(id)
|
||||
end
|
||||
|
||||
return unless estimate
|
||||
|
||||
e = find_or_create_by(id: id)
|
||||
e.doc_number = estimate.doc_number
|
||||
e.txn_date = estimate.txn_date
|
||||
e.save!
|
||||
end
|
||||
|
||||
# process an estimate into the database
|
||||
def self.process_estimate(qbo_estimate)
|
||||
logger.info "Processing estimate #{qbo_estimate.id}"
|
||||
estimate = find_or_create_by(id: qbo_estimate.id)
|
||||
estimate.doc_number = qbo_estimate.doc_number
|
||||
estimate.customer_id = qbo_estimate.customer_ref.value
|
||||
estimate.id = qbo_estimate.id
|
||||
estimate.txn_date = qbo_estimate.txn_date
|
||||
estimate.save!
|
||||
EstimateSyncJob.perform_later(doc_number: number)
|
||||
end
|
||||
|
||||
# download the pdf from quickbooks
|
||||
def pdf
|
||||
log "Downloading PDF for estimate ##{self.id}..."
|
||||
qbo = Qbo.first
|
||||
qbo.perform_authenticated_request do |access_token|
|
||||
service = Quickbooks::Service::Estimate.new(company_id: qbo.realm_id, access_token: access_token)
|
||||
@@ -118,6 +68,7 @@ class Estimate < ActiveRecord::Base
|
||||
|
||||
# pull the details
|
||||
def pull
|
||||
log "Pulling details for estimate ##{self.id}..."
|
||||
begin
|
||||
raise Exception unless self.id
|
||||
qbo = Qbo.first
|
||||
@@ -130,4 +81,8 @@ class Estimate < ActiveRecord::Base
|
||||
end
|
||||
end
|
||||
|
||||
def log(msg)
|
||||
Rails.logger.info "[Estimate] #{msg}"
|
||||
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.
|
||||
|
||||
class Invoice < ActiveRecord::Base
|
||||
|
||||
has_and_belongs_to_many :issues
|
||||
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
|
||||
|
||||
# returns a human readable string
|
||||
# Return the invoice's document number as its string representation
|
||||
def to_s
|
||||
return self[:doc_number]
|
||||
doc_number
|
||||
end
|
||||
|
||||
# sync ALL the invoices
|
||||
# Sync all invoices from QuickBooks, typically triggered by a scheduled task or manual sync request
|
||||
def self.sync
|
||||
logger.info "Syncing all invoices"
|
||||
last = Qbo.first.last_sync
|
||||
|
||||
query = "SELECT Id, DocNumber FROM Invoice"
|
||||
query << " WHERE Metadata.LastUpdatedTime >= '#{last.iso8601}' " if last
|
||||
|
||||
# TODO actually do something with the above query
|
||||
# .all() is never called since count is never initialized
|
||||
qbo = Qbo.first
|
||||
invoices = qbo.perform_authenticated_request do |access_token|
|
||||
service = Quickbooks::Service::Invoice.new(company_id: qbo.realm_id, access_token: access_token)
|
||||
service.all
|
||||
end
|
||||
|
||||
return unless invoices
|
||||
|
||||
invoices.each { | invoice |
|
||||
process_invoice invoice
|
||||
}
|
||||
InvoiceSyncJob.perform_later(full_sync: true)
|
||||
end
|
||||
|
||||
#sync by invoice ID
|
||||
# Sync a single invoice by ID, typically triggered by a webhook notification or manual sync request
|
||||
def self.sync_by_id(id)
|
||||
logger.info "Syncing invoice #{id}"
|
||||
qbo = Qbo.first
|
||||
qbo.perform_authenticated_request do |access_token|
|
||||
service = Quickbooks::Service::Invoice.new(company_id: qbo.realm_id, access_token: access_token)
|
||||
invoice = service.fetch_by_id(id)
|
||||
process_invoice invoice
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Attach the invoice to the issue
|
||||
def self.attach_to_issue(issue, invoice)
|
||||
return if issue.nil?
|
||||
|
||||
# skip this issue if the issue customer is not the same as the invoice customer
|
||||
return if issue.customer_id != invoice.customer_ref.value.to_i
|
||||
|
||||
logger.info "Attaching invoice #{invoice.id} to issue #{issue.id}"
|
||||
|
||||
invoice = Invoice.find_or_create_by(id: invoice.id)
|
||||
|
||||
unless issue.invoices.include?(invoice)
|
||||
issue.invoices << invoice
|
||||
issue.save!
|
||||
end
|
||||
|
||||
compare_custom_fields(issue, invoice)
|
||||
end
|
||||
|
||||
# processes the invoice into the database
|
||||
def self.process_invoice(i)
|
||||
logger.info "Processing invoice #{i.id}"
|
||||
|
||||
# Load the invoice into the database
|
||||
invoice = Invoice.find_or_create_by(id: i.id)
|
||||
invoice.doc_number = i.doc_number
|
||||
invoice.id = i.id
|
||||
invoice.customer_id = i.customer_ref
|
||||
invoice.txn_date = i.txn_date
|
||||
invoice.save!
|
||||
|
||||
# Scan the private notes for hashtags and attach to the applicable issues
|
||||
if not i.private_note.nil?
|
||||
i.private_note.scan(/#(\w+)/).flatten.each { |issue|
|
||||
attach_to_issue(Issue.find_by_id(issue.to_i), invoice)
|
||||
}
|
||||
end
|
||||
|
||||
# Scan the line items for hashtags and attach to the applicable issues
|
||||
i.line_items.each { |line|
|
||||
if line.description
|
||||
line.description.scan(/#(\w+)/).flatten.each { |issue|
|
||||
attach_to_issue(Issue.find_by_id(issue.to_i), invoice)
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
# compares the custome fields on invoices & issues and updates the invoice as needed
|
||||
#
|
||||
# the issue here is when two or more issues share an invoice with the same custom field, but diffrent values
|
||||
# this condions causes an infinite loop as the webhook is called when an invoice is updated
|
||||
# TODO maybe add a cf_sync_confict flag to invoices
|
||||
def self.compare_custom_fields(issue, invoice)
|
||||
logger.info "Comparing custom fields"
|
||||
# TODO break if Invoice.find(invoice.id).cf_sync_confict
|
||||
is_changed = false
|
||||
|
||||
logger.debug "Calling :process_invoice_custom_fields hook"
|
||||
context = Redmine::Hook.call_hook :process_invoice_custom_fields, { issue: issue, invoice: invoice }
|
||||
|
||||
# Process updates from the hooks
|
||||
context.each do |c|
|
||||
unless c.nil?
|
||||
logger.debug "Invoice.compare_custom_fields: We have a responce from a hook"
|
||||
push_updates c[:invoice] if c[:is_changed]
|
||||
end
|
||||
end
|
||||
|
||||
# Process Issue Custom Values
|
||||
begin
|
||||
value = issue.custom_values.find_by(custom_field_id: CustomField.find_by_name(cf.name).id)
|
||||
|
||||
# Check to see if the value is blank...
|
||||
if not value.value.to_s.blank?
|
||||
# Check to see if the value is diffrent
|
||||
if not cf.string_value.to_s.eql? value.value.to_s
|
||||
# update the custom field on the invoice
|
||||
cf.string_value = value.value.to_s
|
||||
is_changed = true
|
||||
end
|
||||
end
|
||||
rescue
|
||||
# Nothing to do here, there is no match
|
||||
end
|
||||
|
||||
push_updates invoice if is_changed
|
||||
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
|
||||
InvoiceSyncJob.perform_later(id: id)
|
||||
end
|
||||
|
||||
end
|
||||
@@ -16,7 +16,7 @@ class Qbo < ActiveRecord::Base
|
||||
# Updates last sync time stamp
|
||||
def self.update_time_stamp
|
||||
date = DateTime.now
|
||||
logger.info "Updating QBO timestamp to #{date}"
|
||||
log "Updating QBO timestamp to #{date}"
|
||||
qbo = Qbo.first
|
||||
qbo.last_sync = date
|
||||
qbo.save
|
||||
@@ -25,4 +25,11 @@ class Qbo < ActiveRecord::Base
|
||||
def self.last_sync
|
||||
format_time(Qbo.first.last_sync)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.log(msg)
|
||||
logger.info "[QBO] #{msg}"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
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 @@
|
||||
<%= link_to t(:label_appointment), "https://calendar.google.com/calendar/render?action=TEMPLATE&text=#{@customer.name}+-&details=#{ link_to "Customer Details", "https://#{Setting.host_name}#{customer_path @customer.id}"}%0A#{@customer.primary_phone}&dates=#{Time.now.strftime("%Y%m%d")}T090000/#{Time.now.strftime("%Y%m%d")}T170000", target: :_blank %>
|
||||
<%= link_to t(:label_appointment), "https://calendar.google.com/calendar/render?action=TEMPLATE&text=#{@customer.name}+-&details=#{ link_to t(:customer_details), "https://#{Setting.host_name}#{customer_path @customer.id}"}%0A#{@customer.primary_phone}%3Cbr/%3E+&dates=#{Time.now.strftime("%Y%m%d")}T090000/#{Time.now.strftime("%Y%m%d")}T170000", target: :_blank, id: :appointment_link %>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<%=t(:field_notes)%>:
|
||||
<div class="input">
|
||||
<p>
|
||||
<%= content_tag 'span', id: "issue_description_and_toolbar" do %>
|
||||
<%= content_tag :span, id: "issue_description_and_toolbar" do %>
|
||||
<%= f.text_area :notes,
|
||||
cols: 60,
|
||||
rows: 10,
|
||||
@@ -45,7 +45,7 @@
|
||||
no_label: true %>
|
||||
<% end %>
|
||||
</p>
|
||||
<%= wikitoolbar_for 'issue_description' %>
|
||||
<%= wikitoolbar_for :issue_description %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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" %>
|
||||
<%= submit_tag t(:label_search) %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h2><%=t(:field_customer)%> #<%= @customer.id %> - <%= link_to @customer.to_s, "https://#{Setting.plugin_redmine_qbo['sandbox'] ? "sandbox" : "app"}.qbo.intuit.com/app/customerdetail?nameId=#{@customer.id}", target: :_blank %> </h2>
|
||||
<h2><%=t(:field_customer)%> #<%= @customer.id %> - <%= link_to @customer.to_s, "https://#{Setting.plugin_redmine_qbo[:sandbox] ? "sandbox" : "app"}.qbo.intuit.com/app/customerdetail?nameId=#{@customer.id}", target: :_blank %> </h2>
|
||||
<div class="issue">
|
||||
|
||||
<div class="splitcontent">
|
||||
@@ -27,12 +27,12 @@
|
||||
<div class="splitcontent">
|
||||
<div class="splitcontentleft">
|
||||
<h4><%=t(:estimates)%>:</h4>
|
||||
<%= render partial: 'estimates/list', locals: {customer: @customer} %>
|
||||
<%= render partial: 'estimates/list', locals: {estimates: @customer.estimates} %>
|
||||
</div>
|
||||
|
||||
<div class="splitcontentleft">
|
||||
<h4><%=t(:label_invoices)%>:</h4>
|
||||
<%= render partial: 'invoices/list', locals: {customer: @customer} %>
|
||||
<%= render partial: 'invoices/list', locals: {invoices: @customer.invoices} %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,27 +19,27 @@
|
||||
|
||||
<div class="attributes">
|
||||
<%= issue_fields_rows do |rows|
|
||||
rows.left l(:field_status), @issue.status.name, class: 'status'
|
||||
rows.left l(:field_priority), @issue.priority.name, class: 'priority'
|
||||
# unless @issue.disabled_core_fields.include?('assigned_to_id')
|
||||
rows.left l(:field_status), @issue.status.name, class: :status
|
||||
rows.left l(:field_priority), @issue.priority.name, class: :priority
|
||||
# unless @issue.disabled_core_fields.include?(:assigned_to_id)
|
||||
# rows.left l(:field_assigned_to), avatar(@issue.assigned_to, size: "14").to_s.html_safe + (@issue.assigned_to ? @issue.assigned_to : "-"), class: 'assigned-to'
|
||||
# end
|
||||
unless @issue.disabled_core_fields.include?('category_id') || (@issue.category.nil? && @issue.project.issue_categories.none?)
|
||||
rows.left l(:field_category), (@issue.category ? @issue.category.name : "-"), class: 'category'
|
||||
unless @issue.disabled_core_fields.include?(:category_id) || (@issue.category.nil? && @issue.project.issue_categories.none?)
|
||||
rows.left l(:field_category), (@issue.category ? @issue.category.name : "-"), class: :category
|
||||
end
|
||||
unless @issue.disabled_core_fields.include?('fixed_version_id') || (@issue.fixed_version.nil? && @issue.assignable_versions.none?)
|
||||
unless @issue.disabled_core_fields.include?(:fixed_version_id) || (@issue.fixed_version.nil? && @issue.assignable_versions.none?)
|
||||
rows.left l(:field_fixed_version), (@issue.fixed_version ? @issue.fixed_version : "-"), class: 'fixed-version'
|
||||
end
|
||||
unless @issue.disabled_core_fields.include?('start_date')
|
||||
unless @issue.disabled_core_fields.include?(:start_date)
|
||||
rows.right l(:field_start_date), format_date(@issue.start_date), class: 'start-date'
|
||||
end
|
||||
unless @issue.disabled_core_fields.include?('due_date')
|
||||
unless @issue.disabled_core_fields.include?(:due_date)
|
||||
rows.right l(:field_due_date), format_date(@issue.due_date), class: 'due-date'
|
||||
end
|
||||
unless @issue.disabled_core_fields.include?('done_ratio')
|
||||
rows.right l(:field_done_ratio), progress_bar(@issue.done_ratio, legend: "#{@issue.done_ratio}%"), class: 'progress'
|
||||
unless @issue.disabled_core_fields.include?(:done_ratio)
|
||||
rows.right l(:field_done_ratio), progress_bar(@issue.done_ratio, legend: "#{@issue.done_ratio}%"), class: :progress
|
||||
end
|
||||
unless @issue.disabled_core_fields.include?('estimated_hours')
|
||||
unless @issue.disabled_core_fields.include?(:estimated_hours)
|
||||
if @issue.estimated_hours.present? || @issue.total_estimated_hours.to_f > 0
|
||||
rows.right l(:field_estimated_hours), issue_estimated_hours_details(@issue), class: 'estimated-hours'
|
||||
end
|
||||
@@ -59,7 +59,7 @@ end %>
|
||||
<% if @issue.description? %>
|
||||
<div class="description">
|
||||
<div class="contextual">
|
||||
<%= link_to l(:button_quote), quoted_issue_path(@issue), remote: true, method: 'post', class: 'icon icon-comment' if @issue.notes_addable? %>
|
||||
<%= link_to l(:button_quote), quoted_issue_path(@issue), remote: true, method: :post, class: 'icon icon-comment' if @issue.notes_addable? %>
|
||||
</div>
|
||||
|
||||
<p><strong><%=l(:field_description)%></strong></p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<% if @customer.present? %>
|
||||
<% unless estimates.empty? %>
|
||||
|
||||
<% @customer.estimates.order(id: :desc).each do |estimate| %>
|
||||
<% estimates.sort.reverse.each do |estimate| %>
|
||||
<div class="row">
|
||||
<%= check_box_tag "estimate_ids[]", estimate.id, false, onchange: "updateLink()", data: { url: estimate_path(estimate), text: "Estimate ##{estimate.to_s}" }, class: "estimate-checkbox appointment" %>
|
||||
<b><%= link_to "##{estimate.doc_number}", estimate_path(estimate), target: :_blank %></b> <%= estimate.txn_date %>
|
||||
</div>
|
||||
<% 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" %>
|
||||
<%= submit_tag t(:label_search), formtarget: "_blank" %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
<% if @customer.present? %>
|
||||
<% unless invoices.empty? %>
|
||||
|
||||
<%= form_with(url: invoice_path, method: :get) do |form| %>
|
||||
|
||||
<% if @customer.invoices.count > 1 %>
|
||||
<% if invoices.count > 1 %>
|
||||
<div class="form-check">
|
||||
<%= check_box_tag "select-all-invoices", "1", false, id: "select-all-invoices" %>
|
||||
<%= label_tag "select-all-invoices", t(:label_select_all) %>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<% end %>
|
||||
|
||||
<% @customer.invoices.order(id: :desc).each do |invoice| %>
|
||||
<% invoices.sort.reverse.each do |invoice| %>
|
||||
<div class="row">
|
||||
<%= check_box_tag "invoice_ids[]", invoice.id, false, class: "invoice-checkbox" %>
|
||||
<%= check_box_tag "invoice_ids[]", invoice.id, false, onchange: "updateLink()", data: { url: invoice_path(invoice), text: "Invoice ##{invoice.to_s}" }, class: "invoice-checkbox appointment" if invoices.count > 1 %>
|
||||
<b><%= link_to "##{invoice.doc_number}", invoice_path(invoice), target: :_blank %></b> <%= invoice.txn_date %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @customer.invoices.count > 1 %>
|
||||
<% if invoices.count > 1 %>
|
||||
<%= form.submit t(:button_bulk_pdf) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<% for issue in issues %>
|
||||
<tr id="issue-<%= h(issue.id) %>" class="hascontextmenu <%= cycle('odd', 'even') %> <%= issue.css_classes %>">
|
||||
<tr id="issue-<%= h(issue.id) %>" class="hascontextmenu <%= cycle(:odd, :even) %> <%= issue.css_classes %>">
|
||||
<td class="id">
|
||||
<%= check_box_tag("ids[]", issue.id, false, style: 'display:none;', id: nil) %>
|
||||
<%= link_to(issue.id, issue_path(issue)) %>
|
||||
|
||||
3
app/views/qbo/_footer.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<div id='footer' align='center'>
|
||||
<%= render partial: 'qbo/last_sync' %>
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
||||
<tr>
|
||||
<th><%=t(:label_sandbox)%></th>
|
||||
<td>
|
||||
<%= check_box_tag 'settings[sandbox]', @settings['sandbox'], @settings['sandbox'] %>
|
||||
<%= check_box_tag 'settings[sandbox]', @settings[:sandbox], @settings[:sandbox] %>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
$(function() {
|
||||
$("input#issue_customer_id").on("change", function() {
|
||||
function updateLink() {
|
||||
console.log("updateLink called");
|
||||
const linkElement = document.getElementById("appointment_link");
|
||||
const regex = /((?:<br\/>|%3Cbr\/?%3E))([\s\S]*?)(&dates)/gi;
|
||||
linkElement.href = linkElement.href.replace(regex, `$1${getSelectedDocs()}$3`);
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: "/filter_estimates_by_customer",
|
||||
type: "GET",
|
||||
data: { selected_customer: $("input#issue_customer_id").val() }
|
||||
});
|
||||
});
|
||||
function getSelectedDocs() {
|
||||
const appointent_extras = document.querySelectorAll('.appointment');
|
||||
|
||||
});
|
||||
let output = '';
|
||||
for (const item of appointent_extras) {
|
||||
if (item.checked) {
|
||||
console.log(`Checked item: ${item.dataset.text} with URL: ${item.dataset.url}`);
|
||||
output += `%0A`+ encodeURIComponent(`<a href="${window.location.origin}${item.dataset.url}">${item.dataset.text}</a>`) +`%0A`;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -11,78 +11,98 @@
|
||||
# English strings go here for Rails i18n
|
||||
# Usage I18n.t(:label)
|
||||
en:
|
||||
field_customer: "Customer"
|
||||
field_employee: "Employee"
|
||||
field_invoice: "Invoice"
|
||||
field_estimate: "Estimate"
|
||||
field_notes: "Notes"
|
||||
button_bulk_pdf: "Bulk PDF"
|
||||
customer_details: "Customer Details"
|
||||
field_billed: "Billed"
|
||||
label_week: "Week"
|
||||
label_search_estimates: "Search Estimates"
|
||||
label_search: "Search"
|
||||
label_estimates: "Estimates"
|
||||
warn_ru_sure: "You sure?"
|
||||
label_delete: "Delete"
|
||||
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"
|
||||
field_customer: "Customer"
|
||||
field_customers: "Customers"
|
||||
field_employee: "Employee"
|
||||
field_estimate: "Estimate"
|
||||
field_invoice: "Invoice"
|
||||
field_notes: "Notes"
|
||||
label_account_balance: "Account Balance"
|
||||
label_balance_with_jobs: "Balance With Jobs"
|
||||
label_display_name: "Display Name"
|
||||
label_details: "Details"
|
||||
label_customer_link_expires: "This customer link expires in"
|
||||
label_actions: "Actions"
|
||||
label_amount: "Amount"
|
||||
label_deposit_into: "Deposit to Account"
|
||||
label_last_sync: "Last Sync"
|
||||
label_redmine_qbo: "Redmine Quickbooks"
|
||||
label_customer_count: "Customer Count"
|
||||
label_invoice_count: "Invoice Count"
|
||||
label_estimate_count: "Estimate Count"
|
||||
label_employee_count: "Employee Count"
|
||||
label_appointment: "Add Appointment"
|
||||
label_balance_with_jobs: "Balance With Jobs"
|
||||
label_bill_time: "Bill Time"
|
||||
label_billing_address: "Billing Address"
|
||||
label_billing_error: "Customer could not be billed. Check for Customer or Assignee and try again."
|
||||
label_billing_error_no_customer: "Cannot bill without an assigned customer."
|
||||
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_secret: "Intuit QBO OAuth2 Client Secret"
|
||||
label_webhook_token: "Intuit QBO Webhook Token"
|
||||
label_oauth_expires: "OAuth2 Access Token Expires At"
|
||||
label_oauth_note: "Note: You need to authenticate with Quickbooks after saving your key and secret above"
|
||||
field_customers: "Customers"
|
||||
label_closed_issues: "Closed Issues"
|
||||
label_connected: "Successfully connected to QuickBooks"
|
||||
label_create_estimate: "Create Estimate"
|
||||
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_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_name: "Name"
|
||||
label_appointment: "Add Appointment"
|
||||
label_actions: "Actions"
|
||||
label_create_estimate: "Create Estimate"
|
||||
label_syncing: "Syncing Quickbooks"
|
||||
label_oauth_expires: "OAuth2 Access Token Expires At"
|
||||
label_oauth_note: "Note: You need to authenticate with QuickBooks after saving your key and secret above."
|
||||
label_open_issues: "Open Issues"
|
||||
label_primary_phone: "Primary Phone"
|
||||
label_qbo_sync_success: "Successfully synced to QuickBooks"
|
||||
label_redmine_qbo: "Redmine QuickBooks"
|
||||
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_share: "Share"
|
||||
label_shipping_address: "Shipping Address"
|
||||
label_sync: "Sync"
|
||||
label_sync_now: "Sync Now"
|
||||
label_syncing: "Syncing QuickBooks"
|
||||
label_trim: "Trim"
|
||||
label_webhook_token: "Intuit QBO Webhook Token"
|
||||
label_week: "Week"
|
||||
label_year: "Year"
|
||||
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_updated: "Invoice updated in QuickBooks"
|
||||
notice_issue_not_found: "Issue not found"
|
||||
warn_ru_sure: "Are you sure?"
|
||||
@@ -8,12 +8,8 @@
|
||||
#
|
||||
#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.
|
||||
|
||||
module Hooks
|
||||
|
||||
class ViewHookListener < Redmine::Hook::ViewListener
|
||||
|
||||
render_on :view_layouts_base_sidebar, partial: "qbo/sidebar"
|
||||
|
||||
class AddCustomersTimestamp < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
add_timestamps(:customers, null: true)
|
||||
end
|
||||
|
||||
end
|
||||
@@ -8,11 +8,9 @@
|
||||
#
|
||||
#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.
|
||||
|
||||
require File.expand_path('../../test_helper', __FILE__)
|
||||
|
||||
class QboControllerTest < ActionController::TestCase
|
||||
# Replace this with your real tests.
|
||||
def test_truth
|
||||
assert true
|
||||
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
|
||||
@@ -8,12 +8,9 @@
|
||||
#
|
||||
#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.
|
||||
|
||||
require File.expand_path('../../test_helper', __FILE__)
|
||||
|
||||
class QboTest < ActiveSupport::TestCase
|
||||
|
||||
# Replace this with your real tests.
|
||||
def test_truth
|
||||
assert true
|
||||
class AddDocTimestamp < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
add_timestamps(:invoices, null: true)
|
||||
add_timestamps(:estimates, null: true)
|
||||
end
|
||||
end
|
||||
@@ -8,12 +8,17 @@
|
||||
#
|
||||
#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.
|
||||
|
||||
module Hooks
|
||||
|
||||
class HeaderFooterHookListener < Redmine::Hook::ViewListener
|
||||
def view_layouts_base_body_bottom(context = {})
|
||||
return "<div id='footer' align='center'><b>#{I18n.translate(:label_last_sync)}: </b> #{Qbo.last_sync if Qbo.exists?}</div>"
|
||||
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
|
||||
end
|
||||
|
||||
add_index :invoices, :qbo_updated_at
|
||||
add_index :invoices, :qbo_sync_locked
|
||||
end
|
||||
end
|
||||
@@ -8,12 +8,8 @@
|
||||
#
|
||||
#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.
|
||||
|
||||
require File.expand_path('../../test_helper', __FILE__)
|
||||
|
||||
class QboCustomersTest < ActiveSupport::TestCase
|
||||
|
||||
# Replace this with your real tests.
|
||||
def test_truth
|
||||
assert true
|
||||
class AddEmployeeTimestamp < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_timestamps(:employees, null: true)
|
||||
end
|
||||
end
|
||||
22
init.rb
@@ -13,19 +13,19 @@ Redmine::Plugin.register :redmine_qbo do
|
||||
# About
|
||||
name 'Redmine QBO plugin'
|
||||
author 'Rick Barrette'
|
||||
description 'This is a plugin for Redmine to intergrate with Quickbooks Online to allow for seamless intergration CRM and invoicing of completed issues'
|
||||
version '2026.1.5'
|
||||
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.16'
|
||||
url 'https://github.com/rickbarrette/redmine_qbo'
|
||||
author_url 'https://barrettefabrication.com'
|
||||
settings default: {'empty' => true}, partial: 'qbo/settings'
|
||||
settings default: {empty: true}, partial: 'qbo/settings'
|
||||
requires_redmine version_or_higher: '6.1.0'
|
||||
|
||||
# Add safe attributes for core models
|
||||
Issue.safe_attributes 'customer_id'
|
||||
Issue.safe_attributes 'estimate_id'
|
||||
Issue.safe_attributes 'invoice_id'
|
||||
User.safe_attributes 'employee_id'
|
||||
TimeEntry.safe_attributes 'billed'
|
||||
Issue.safe_attributes :customer_id
|
||||
Issue.safe_attributes :estimate_id
|
||||
Issue.safe_attributes :invoice_id
|
||||
User.safe_attributes :employee_id
|
||||
TimeEntry.safe_attributes :billed
|
||||
|
||||
# set per_page globally
|
||||
WillPaginate.per_page = 20
|
||||
@@ -35,7 +35,11 @@ Redmine::Plugin.register :redmine_qbo do
|
||||
permission :add_customers, customers: :new, public: false
|
||||
|
||||
# Register top menu items
|
||||
menu :top_menu, :customers, { controller: :customers, action: :index }, caption: '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
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
#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.
|
||||
|
||||
module Hooks
|
||||
|
||||
class IssuesFormHookListener < Redmine::Hook::ViewListener
|
||||
|
||||
include IssuesHelper
|
||||
|
||||
# Edit Issue Form
|
||||
# Here we build the required form components before passing them to a partial view formatting.
|
||||
def view_issues_form_details_bottom(context={})
|
||||
f = context[:form]
|
||||
issue = context[:issue]
|
||||
|
||||
# Customer Name Text Box with database backed autocomplete
|
||||
# onchange event will update the hidden customer_id field
|
||||
search_customer = f.autocomplete_field :customer,
|
||||
autocomplete_customer_name_customers_path,
|
||||
selected: issue.customer,
|
||||
update_elements: {
|
||||
id: '#issue_customer_id',
|
||||
value: '#issue_customer'
|
||||
}
|
||||
|
||||
# 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
|
||||
customer_id = f.hidden_field :customer_id,
|
||||
id: "issue_customer_id",
|
||||
onchange: "updateIssueFrom('#{escape_javascript update_issue_form_path(issue.project, issue)}', this)".html_safe
|
||||
|
||||
# Generate the drop down list of quickbooks estimates owned by the selected customer
|
||||
select_estimate = f.select :estimate_id,
|
||||
issue.customer ? issue.customer.estimates.pluck(:doc_number, :id).sort! {|x, y| y <=> x} : [],
|
||||
selected: issue.estimate,
|
||||
include_blank: true
|
||||
|
||||
# Pass all prebuilt form components to our partial
|
||||
context[:controller].send(:render_to_string, {
|
||||
partial: 'issues/form_hook',
|
||||
locals: {
|
||||
search_customer: search_customer,
|
||||
customer_id: customer_id,
|
||||
select_estimate: select_estimate
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,41 +0,0 @@
|
||||
#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.
|
||||
|
||||
module Hooks
|
||||
|
||||
class IssuesShowHookListener < Redmine::Hook::ViewListener
|
||||
|
||||
# View Issue
|
||||
# Displays the quickbooks customer, estimate, & invoices attached to the issue
|
||||
def view_issues_show_details_bottom(context={})
|
||||
issue = context[:issue]
|
||||
|
||||
# Build a list of invoice links
|
||||
invoice_link = ""
|
||||
if issue.invoices
|
||||
issue.invoices.each do |i|
|
||||
invoice_link += "#{link_to i, i, target: :_blank}<br/>"
|
||||
end
|
||||
end
|
||||
|
||||
context[:controller].send(:render_to_string, {
|
||||
partial: 'issues/show_details',
|
||||
locals: {
|
||||
customer: issue.customer ? link_to(issue.customer): nill,
|
||||
estimate_link: issue.estimate ? link_to(issue.estimate, issue.estimate, target: :_blank) : nil,
|
||||
invoice_link: invoice_link.html_safe,
|
||||
issue: issue
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,114 +0,0 @@
|
||||
#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.
|
||||
|
||||
require_dependency 'issue'
|
||||
|
||||
module Patches
|
||||
|
||||
|
||||
# Patches Redmine's Issues dynamically.
|
||||
# Adds a relationships
|
||||
module IssuePatch
|
||||
|
||||
def self.included(base) # :nodoc:
|
||||
base.extend(ClassMethods)
|
||||
|
||||
base.send(:include, InstanceMethods)
|
||||
|
||||
# Same as typing in the class
|
||||
base.class_eval do
|
||||
belongs_to :customer, primary_key: :id
|
||||
belongs_to :customer_token, primary_key: :id
|
||||
belongs_to :estimate, primary_key: :id
|
||||
has_and_belongs_to_many :invoices
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
|
||||
# Create billable time entries
|
||||
def bill_time
|
||||
|
||||
# Check to see if we have everything we need to bill the customer
|
||||
return if assigned_to.nil?
|
||||
return unless Qbo.first
|
||||
return unless customer
|
||||
|
||||
# Get unbilled time entries
|
||||
spent_time = time_entries.where(billed: [false, nil])
|
||||
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
|
||||
|
||||
# Now letes upload our totals for each activity as their own billable time entry
|
||||
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
|
||||
|
||||
# Create a shareable link for a customer
|
||||
def share_token
|
||||
CustomerToken.get_token self
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# Add module to Issue
|
||||
Issue.send(:include, IssuePatch)
|
||||
|
||||
end
|
||||
@@ -1,276 +0,0 @@
|
||||
#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.
|
||||
|
||||
require_dependency 'redmine/export/pdf'
|
||||
require_dependency 'redmine/export/pdf/issues_pdf_helper'
|
||||
|
||||
module Patches
|
||||
|
||||
module PdfPatch
|
||||
|
||||
def self.included(base)
|
||||
base.send(:include, InstanceMethods)
|
||||
base.class_eval do
|
||||
alias_method :issue_to_pdf, :issue_to_pdf_with_patch
|
||||
alias_method :issue_to_pdf_with_patch, :issue_to_pdf
|
||||
end
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
|
||||
def issue_to_pdf_with_patch(issue, assoc={})
|
||||
pdf = ::Redmine::Export::PDF::ITCPDF.new(current_language)
|
||||
pdf.set_title("#{issue.project} - #{issue.tracker} ##{issue.id}")
|
||||
pdf.alias_nb_pages
|
||||
pdf.footer_date = format_date(Date.today)
|
||||
pdf.add_page
|
||||
pdf.SetFontStyle('B',11)
|
||||
buf = "#{issue.project} - #{issue.tracker} ##{issue.id}"
|
||||
pdf.RDMMultiCell(190, 5, buf)
|
||||
pdf.SetFontStyle('',8)
|
||||
base_x = pdf.get_x
|
||||
i = 1
|
||||
issue.ancestors.visible.each do |ancestor|
|
||||
pdf.set_x(base_x + i)
|
||||
buf = "#{ancestor.tracker} # #{ancestor.id} (#{ancestor.status.to_s}): #{ancestor.subject}"
|
||||
pdf.RDMMultiCell(190 - i, 5, buf)
|
||||
i += 1 if i < 35
|
||||
end
|
||||
pdf.SetFontStyle('B',11)
|
||||
pdf.RDMMultiCell(190 - i, 5, issue.subject.to_s)
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMMultiCell(190, 5, "#{format_time(issue.created_on)} - #{issue.author}")
|
||||
pdf.ln
|
||||
|
||||
customer = issue.customer.name if issue.customer
|
||||
left = []
|
||||
left << [l(:field_status), issue.status]
|
||||
left << [l(:field_priority), issue.priority]
|
||||
left << [l(:field_customer), customer]
|
||||
left << [l(:field_assigned_to), issue.assigned_to] unless issue.disabled_core_fields.include?('assigned_to_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')
|
||||
|
||||
logger.debug "Calling :pdf_left hook"
|
||||
left_hook_output = Redmine::Hook.call_hook :pdf_left, { issue: issue }
|
||||
unless left_hook_output.nil?
|
||||
left_hook_output.each do |l|
|
||||
left.concat l unless l.nil?
|
||||
end
|
||||
end
|
||||
|
||||
right = []
|
||||
right << [l(:field_start_date), format_date(issue.start_date)] unless issue.disabled_core_fields.include?('start_date')
|
||||
right << [l(:field_due_date), format_date(issue.due_date)] unless issue.disabled_core_fields.include?('due_date')
|
||||
right << [l(:field_done_ratio), "#{issue.done_ratio}%"] unless issue.disabled_core_fields.include?('done_ratio')
|
||||
right << [l(:field_estimated_hours), l_hours(issue.estimated_hours)] unless issue.disabled_core_fields.include?('estimated_hours')
|
||||
right << [l(:label_spent_time), l_hours(issue.total_spent_hours)] if User.current.allowed_to?(:view_time_entries, issue.project)
|
||||
|
||||
logger.debug "Calling :pdf_right hook"
|
||||
right_hook_output = Redmine::Hook.call_hook :pdf_right, { issue: issue }
|
||||
unless right_hook_output.nil?
|
||||
right_hook_output.each do |r|
|
||||
right.concat r unless r.nil?
|
||||
end
|
||||
end
|
||||
|
||||
rows = left.size > right.size ? left.size : right.size
|
||||
while left.size < rows
|
||||
left << nil
|
||||
end
|
||||
while right.size < rows
|
||||
right << nil
|
||||
end
|
||||
|
||||
half = (issue.visible_custom_field_values.size / 2.0).ceil
|
||||
issue.visible_custom_field_values.each_with_index do |custom_value, i|
|
||||
(i < half ? left : right) << [custom_value.custom_field.name, show_value(custom_value, false)]
|
||||
end
|
||||
|
||||
if pdf.get_rtl
|
||||
border_first_top = 'RT'
|
||||
border_last_top = 'LT'
|
||||
border_first = 'R'
|
||||
border_last = 'L'
|
||||
else
|
||||
border_first_top = 'LT'
|
||||
border_last_top = 'RT'
|
||||
border_first = 'L'
|
||||
border_last = 'R'
|
||||
end
|
||||
|
||||
rows = left.size > right.size ? left.size : right.size
|
||||
rows.times do |i|
|
||||
heights = []
|
||||
pdf.SetFontStyle('B',9)
|
||||
item = left[i]
|
||||
heights << pdf.get_string_height(35, item ? "#{item.first}:" : "")
|
||||
item = right[i]
|
||||
heights << pdf.get_string_height(35, item ? "#{item.first}:" : "")
|
||||
pdf.SetFontStyle('',9)
|
||||
item = left[i]
|
||||
heights << pdf.get_string_height(60, item ? item.last.to_s : "")
|
||||
item = right[i]
|
||||
heights << pdf.get_string_height(60, item ? item.last.to_s : "")
|
||||
height = heights.max
|
||||
|
||||
item = left[i]
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "", (i == 0 ? border_first_top : border_first), '', 0, 0)
|
||||
pdf.SetFontStyle('',9)
|
||||
pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", (i == 0 ? border_last_top : border_last), '', 0, 0)
|
||||
|
||||
item = right[i]
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "", (i == 0 ? border_first_top : border_first), '', 0, 0)
|
||||
pdf.SetFontStyle('',9)
|
||||
pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", (i == 0 ? border_last_top : border_last), '', 0, 2)
|
||||
|
||||
pdf.set_x(base_x)
|
||||
end
|
||||
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(35+155, 5, l(:field_description), "LRT", 1)
|
||||
pdf.SetFontStyle('',9)
|
||||
|
||||
# Set resize image scale
|
||||
pdf.set_image_scale(1.6)
|
||||
text = textilizable(issue, :description,
|
||||
only_path: false,
|
||||
edit_section_links: false,
|
||||
headings: false,
|
||||
inline_attachments: false
|
||||
)
|
||||
pdf.RDMwriteFormattedCell(35+155, 5, '', '', text, issue.attachments, "LRB")
|
||||
|
||||
unless issue.leaf?
|
||||
truncate_length = (!is_cjk? ? 90 : 65)
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(35+155,5, l(:label_subtask_plural) + ":", "LTR")
|
||||
pdf.ln
|
||||
issue_list(issue.descendants.visible.sort_by(&:lft)) do |child, level|
|
||||
buf = "#{child.tracker} # #{child.id}: #{child.subject}".
|
||||
truncate(truncate_length)
|
||||
level = 10 if level >= 10
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMCell(35+135,5, (level >=1 ? " " * level : "") + buf, border_first)
|
||||
pdf.SetFontStyle('B',8)
|
||||
pdf.RDMCell(20,5, child.status.to_s, border_last)
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
|
||||
relations = issue.relations.select { |r| r.other_issue(issue).visible? }
|
||||
unless relations.empty?
|
||||
truncate_length = (!is_cjk? ? 80 : 60)
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(35+155,5, l(:label_related_issues) + ":", "LTR")
|
||||
pdf.ln
|
||||
relations.each do |relation|
|
||||
buf = relation.to_s(issue) {|other|
|
||||
text = ""
|
||||
if Setting.cross_project_issue_relations?
|
||||
text += "#{relation.other_issue(issue).project} - "
|
||||
end
|
||||
text += "#{other.tracker} ##{other.id}: #{other.subject}"
|
||||
text
|
||||
}
|
||||
buf = buf.truncate(truncate_length)
|
||||
pdf.SetFontStyle('', 8)
|
||||
pdf.RDMCell(35+155-60, 5, buf, border_first)
|
||||
pdf.SetFontStyle('B',8)
|
||||
pdf.RDMCell(20,5, relation.other_issue(issue).status.to_s, "")
|
||||
pdf.RDMCell(20,5, format_date(relation.other_issue(issue).start_date), "")
|
||||
pdf.RDMCell(20,5, format_date(relation.other_issue(issue).due_date), border_last)
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
pdf.RDMCell(190,5, "", "T")
|
||||
pdf.ln
|
||||
|
||||
if issue.changesets.any? &&
|
||||
User.current.allowed_to?(:view_changesets, issue.project)
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(190,5, l(:label_associated_revisions), "B")
|
||||
pdf.ln
|
||||
for changeset in issue.changesets
|
||||
pdf.SetFontStyle('B',8)
|
||||
csstr = "#{l(:label_revision)} #{changeset.format_identifier} - "
|
||||
csstr += format_time(changeset.committed_on) + " - " + changeset.author.to_s
|
||||
pdf.RDMCell(190, 5, csstr)
|
||||
pdf.ln
|
||||
unless changeset.comments.blank?
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMwriteHTMLCell(190,5,'','',
|
||||
changeset.comments.to_s, issue.attachments, "")
|
||||
end
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
|
||||
if assoc[:journals].present?
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(190,5, l(:label_history), "B")
|
||||
pdf.ln
|
||||
assoc[:journals].each do |journal|
|
||||
pdf.SetFontStyle('B',8)
|
||||
title = "##{journal.indice} - #{format_time(journal.created_on)} - #{journal.user}"
|
||||
title << " (#{l(:field_private_notes)})" if journal.private_notes?
|
||||
pdf.RDMCell(190,5, title)
|
||||
pdf.ln
|
||||
pdf.SetFontStyle('I',8)
|
||||
details_to_strings(journal.visible_details, true).each do |string|
|
||||
pdf.RDMMultiCell(190,5, "- " + string)
|
||||
end
|
||||
if journal.notes?
|
||||
pdf.ln unless journal.details.empty?
|
||||
pdf.SetFontStyle('',8)
|
||||
text = textilizable(journal, :notes,
|
||||
only_path: false,
|
||||
edit_section_links: false,
|
||||
headings: false,
|
||||
inline_attachments: false
|
||||
)
|
||||
pdf.RDMwriteFormattedCell(190,5,'','', text, issue.attachments, "")
|
||||
end
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
|
||||
if issue.attachments.any?
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(190,5, l(:label_attachment_plural), "B")
|
||||
pdf.ln
|
||||
for attachment in issue.attachments
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMCell(80,5, attachment.filename)
|
||||
pdf.RDMCell(20,5, number_to_human_size(attachment.filesize),0,0,"R")
|
||||
pdf.RDMCell(25,5, format_date(attachment.created_on),0,0,"R")
|
||||
pdf.RDMCell(65,5, attachment.author.name,0,0,"R")
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
|
||||
# Check to see if there is an estimate attached, then combine them
|
||||
if issue.estimate
|
||||
pdf = CombinePDF.parse(pdf.output, allow_optional_content: true)
|
||||
pdf << CombinePDF.parse(issue.estimate.pdf)
|
||||
return pdf.to_pdf
|
||||
end
|
||||
|
||||
return pdf.output
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
Redmine::Export::PDF::IssuesPdfHelper.send(:include, PdfPatch)
|
||||
|
||||
end
|
||||
@@ -1,38 +0,0 @@
|
||||
#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.
|
||||
|
||||
require_dependency 'issue_query'
|
||||
|
||||
module Patches
|
||||
|
||||
module QueryPatch
|
||||
|
||||
# Add qbo options to the aviable columns
|
||||
def available_columns
|
||||
unless @available_columns
|
||||
@available_columns = self.class.available_columns.dup
|
||||
@available_columns << QueryColumn.new(:customer, sortable: "#{Issue.table_name}.customer_id", groupable: true, caption: :field_customer)
|
||||
@available_columns << QueryColumn.new(:billed, sortable: "#{TimeEntry.table_name}.billed", groupable: true, caption: :field_billed)
|
||||
end
|
||||
super
|
||||
end
|
||||
|
||||
# Add customers to filters
|
||||
def initialize_available_filters
|
||||
#add_available_filter "customer", type: :text
|
||||
super
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# Add module to Issue
|
||||
IssueQuery.send(:prepend, QueryPatch)
|
||||
|
||||
end
|
||||
105
lib/redmine_qbo/hooks/issues_hook_listener.rb
Normal file
@@ -0,0 +1,105 @@
|
||||
#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.
|
||||
|
||||
module RedmineQbo
|
||||
module Hooks
|
||||
class IssuesHookListener < Redmine::Hook::ViewListener
|
||||
|
||||
include IssuesHelper
|
||||
|
||||
# Edit Issue Form
|
||||
# Here we build the required form components before passing them to a partial view formatting.
|
||||
def view_issues_form_details_bottom(context={})
|
||||
log "view_issues_form_details_bottom: Building form components for quickbooks customer, estimate, and invoice data"
|
||||
f = context[:form]
|
||||
issue = context[:issue]
|
||||
project = context[:project]
|
||||
log issue.inspect
|
||||
log project.inspect
|
||||
|
||||
# Customer Name Text Box with database backed autocomplete
|
||||
# onchange event will update the hidden customer_id field
|
||||
search_customer = f.autocomplete_field :customer,
|
||||
autocomplete_customer_name_customers_path,
|
||||
selected: issue.customer,
|
||||
update_elements: {
|
||||
id: '#issue_customer_id',
|
||||
value: '#issue_customer'
|
||||
}
|
||||
|
||||
# We need to handle 3 cases for the onchange event of the customer name field:
|
||||
# 1. New issue Withough project: /issues/new.js
|
||||
# 2. New issue With project: /projects/rmt/issues/new.js
|
||||
# 3. Existing issue: /issues/<ID>/edit.js
|
||||
# The built in helper update_issue_form_path requires a project object to determine the correct path for new vs existing issues,
|
||||
# but it doesn't work for issue.project when creating new issues not in a project i.e. http://redmine.domain.com/issues/new .
|
||||
# So we need to figure out how to get a the @project from the controller calling the hook.
|
||||
#
|
||||
# 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)"
|
||||
log js_path
|
||||
|
||||
# This hidden field is used for the customer ID for the issue
|
||||
# the onchange event will reload the issue form via ajax to update the available estimates
|
||||
customer_id = f.hidden_field :customer_id,
|
||||
id: "issue_customer_id",
|
||||
onchange: js_path.html_safe
|
||||
|
||||
# Generate the drop down list of quickbooks estimates owned by the selected customer
|
||||
select_estimate = f.select :estimate_id,
|
||||
issue.customer ? issue.customer.estimates.pluck(:doc_number, :id).sort! {|x, y| y <=> x} : [],
|
||||
selected: issue.estimate ? issue.estimate.id : nil,
|
||||
include_blank: true
|
||||
|
||||
# Pass all prebuilt form components to our partial
|
||||
context[:controller].send(:render_to_string, {
|
||||
partial: 'issues/form_hook',
|
||||
locals: {
|
||||
search_customer: search_customer,
|
||||
customer_id: customer_id,
|
||||
select_estimate: select_estimate
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
# View Issue
|
||||
# Displays the quickbooks customer, estimate, & invoices attached to the issue
|
||||
def view_issues_show_details_bottom(context={})
|
||||
issue = context[:issue]
|
||||
|
||||
# Build a list of invoice links
|
||||
invoice_link = ""
|
||||
if issue.invoices
|
||||
issue.invoices.each do |i|
|
||||
invoice_link += "#{link_to i, i, target: :_blank}<br/>"
|
||||
end
|
||||
end
|
||||
|
||||
context[:controller].send(:render_to_string, {
|
||||
partial: 'issues/show_details',
|
||||
locals: {
|
||||
customer: issue.customer ? link_to(issue.customer) : nil,
|
||||
estimate_link: issue.estimate ? link_to(issue.estimate, issue.estimate, target: :_blank) : nil,
|
||||
invoice_link: invoice_link.html_safe,
|
||||
issue: issue
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log(msg)
|
||||
Rails.logger.info "[IssuesHookListener] #{msg}"
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,30 +8,24 @@
|
||||
#
|
||||
#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.
|
||||
|
||||
require_dependency 'time_entry_query'
|
||||
module RedmineQbo
|
||||
module Hooks
|
||||
|
||||
module Patches
|
||||
class UsersShowHookListener < Redmine::Hook::ViewListener
|
||||
|
||||
module TimeEntryQueryPatch
|
||||
# View User
|
||||
def view_users_form(context={})
|
||||
|
||||
# Add QBO options to columns
|
||||
def available_columns
|
||||
unless @available_columns
|
||||
@available_columns = self.class.available_columns.dup
|
||||
@available_columns << QueryColumn.new(:billed, sortable: "#{TimeEntry.table_name}.name", groupable: true, caption: :field_billed)
|
||||
# Update the users
|
||||
#Employee.update_all
|
||||
|
||||
# Check to see if there is a quickbooks user attached to the issue
|
||||
@selected = context[:user].employee.id if context[:user].employee
|
||||
|
||||
# Generate the drop down list of quickbooks contacts
|
||||
return "<p>#{context[:form].select :employee_id, Employee.all.pluck(:name, :id), selected: @selected, include_blank: true}</p>"
|
||||
end
|
||||
super
|
||||
end
|
||||
|
||||
# Add QBO options to the filter
|
||||
def initialize_available_filters
|
||||
add_available_filter "billed", type: :boolean
|
||||
super
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# Add module to TimeEntryQuery
|
||||
TimeEntryQuery.send(:prepend, QueryPatch)
|
||||
|
||||
end
|
||||
30
lib/redmine_qbo/hooks/view_hook_listener.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
#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.
|
||||
|
||||
module RedmineQbo
|
||||
module Hooks
|
||||
|
||||
class ViewHookListener < Redmine::Hook::ViewListener
|
||||
|
||||
# Load the javascript to support the autocomplete forms
|
||||
def view_layouts_base_html_head(context = {})
|
||||
safe_join([
|
||||
javascript_include_tag( 'application.js', plugin: :redmine_qbo),
|
||||
javascript_include_tag( 'autocomplete-rails.js', plugin: :redmine_qbo),
|
||||
javascript_include_tag( 'checkbox_controller.js', plugin: :redmine_qbo)
|
||||
])
|
||||
end
|
||||
|
||||
render_on :view_layouts_base_sidebar, partial: "qbo/sidebar"
|
||||
render_on :view_layouts_base_body_bottom, partial: "qbo/footer"
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -10,37 +10,39 @@
|
||||
|
||||
require_dependency 'attachments_controller'
|
||||
|
||||
module Patches
|
||||
module RedmineQbo
|
||||
module Patches
|
||||
|
||||
module AttachmentsControllerPatch
|
||||
module AttachmentsControllerPatch
|
||||
|
||||
def self.included(base)
|
||||
def self.included(base)
|
||||
|
||||
base.class_eval do
|
||||
base.class_eval do
|
||||
|
||||
# check if login is globally required to access the application
|
||||
def check_if_login_required
|
||||
# no check needed if user is already logged in
|
||||
return true if User.current.logged?
|
||||
# check if login is globally required to access the application
|
||||
def check_if_login_required
|
||||
# no check needed if user is already logged in
|
||||
return true if User.current.logged?
|
||||
|
||||
# Pull up the attachmet, & verify if we have a valid token for the Issue
|
||||
attachment = Attachment.find(params[:id])
|
||||
token = CustomerToken.where("token = ? and expires_at > ?", session[:token], Time.now)
|
||||
token = token.first
|
||||
unless token.nil?
|
||||
return true if token.issue_id == attachment.container_id
|
||||
# Pull up the attachmet, & verify if we have a valid token for the Issue
|
||||
attachment = Attachment.find(params[:id])
|
||||
token = CustomerToken.where("token = ? and expires_at > ?", session[:token], Time.now)
|
||||
token = token.first
|
||||
unless token.nil?
|
||||
return true if token.issue_id == attachment.container_id
|
||||
end
|
||||
|
||||
require_login if Setting.login_required?
|
||||
end
|
||||
|
||||
require_login if Setting.login_required?
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# Add module to AttachmentsController
|
||||
AttachmentsController.send(:include, AttachmentsControllerPatch)
|
||||
|
||||
end
|
||||
|
||||
# Add module to AttachmentsController
|
||||
AttachmentsController.send(:include, AttachmentsControllerPatch)
|
||||
|
||||
end
|
||||
80
lib/redmine_qbo/patches/issue_patch.rb
Normal file
@@ -0,0 +1,80 @@
|
||||
#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.
|
||||
|
||||
require_dependency 'issue'
|
||||
|
||||
module RedmineQbo
|
||||
module Patches
|
||||
module IssuePatch
|
||||
|
||||
def self.included(base)
|
||||
base.extend(ClassMethods)
|
||||
base.send(:include, InstanceMethods)
|
||||
|
||||
base.class_eval do
|
||||
belongs_to :customer, class_name: 'Customer', foreign_key: :customer_id, optional: true
|
||||
belongs_to :customer_token, primary_key: :id
|
||||
belongs_to :estimate, primary_key: :id
|
||||
has_and_belongs_to_many :invoices
|
||||
|
||||
before_save :titlize_subject
|
||||
after_commit :enqueue_billing, on: :update
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
|
||||
# Enqueue a background job to bill the time spent on this issue to the associated customer in Quickbooks, if the issue is closed and has a customer assigned.
|
||||
def enqueue_billing
|
||||
log "Checking if issue needs to be billed for issue ##{id}"
|
||||
#return unless saved_change_to_status_id?
|
||||
return unless closed?
|
||||
return unless customer.present?
|
||||
return unless assigned_to&.employee_id.present?
|
||||
return unless Qbo.first
|
||||
|
||||
log "Enqueuing billing for issue ##{id}"
|
||||
BillIssueTimeJob.perform_later(id)
|
||||
end
|
||||
|
||||
# Titlize the subject of the issue before saving to ensure consistent formatting for billing descriptions in Quickbooks
|
||||
def titlize_subject
|
||||
log "Titlizing subject for issue ##{id}"
|
||||
|
||||
self.subject = subject.split(/\s+/).map do |word|
|
||||
if word =~ /[A-Z]/ && word =~ /[0-9]/
|
||||
word
|
||||
else
|
||||
word.capitalize
|
||||
end
|
||||
end.join(' ')
|
||||
end
|
||||
end
|
||||
|
||||
# This method is used to generate a shareable token for the customer associated with this issue, which can be used to link the issue to the corresponding customer in Quickbooks for billing and tracking purposes.
|
||||
def share_token
|
||||
CustomerToken.get_token(self)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log(msg)
|
||||
Rails.logger.info "[IssuePatch] #{msg}"
|
||||
end
|
||||
end
|
||||
|
||||
Issue.send(:include, IssuePatch)
|
||||
end
|
||||
end
|
||||
@@ -9,30 +9,28 @@
|
||||
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
require_dependency 'issues_controller'
|
||||
|
||||
module Patches
|
||||
module RedmineQbo
|
||||
module Patches
|
||||
module IssuesControllerPatch
|
||||
|
||||
module IssuesControllerPatch
|
||||
module Helper
|
||||
def watcher_link(issue, user)
|
||||
link = ''
|
||||
link = link_to(I18n.t(:label_bill_time), bill_path( issue.id ), method: :get, class: 'icon icon-email-add') if user.admin?
|
||||
link << link_to(I18n.t(:label_share), share_path( issue.id ), method: :get, target: :_blank, class: 'icon icon-shared') if user.logged?
|
||||
link.html_safe + super
|
||||
end
|
||||
end
|
||||
|
||||
module Helper
|
||||
def watcher_link(issue, user)
|
||||
link = +''
|
||||
link << link_to(I18n.t(:label_bill_time), bill_path( issue.id ), method: :get, class: 'icon icon-email-add') if user.admin?
|
||||
link << link_to(I18n.t(:label_share), share_path( issue.id ), method: :get, target: :_blank, class: 'icon icon-shared') if user.logged?
|
||||
link.html_safe + super
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
helper Helper
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.included(base)
|
||||
|
||||
base.class_eval do
|
||||
helper Helper
|
||||
end
|
||||
|
||||
end
|
||||
# Add module to IssuessController
|
||||
IssuesController.send(:include, IssuesControllerPatch)
|
||||
|
||||
end
|
||||
|
||||
# Add module to IssuessController
|
||||
IssuesController.send(:include, IssuesControllerPatch)
|
||||
|
||||
end
|
||||
284
lib/redmine_qbo/patches/pdf_patch.rb
Normal file
@@ -0,0 +1,284 @@
|
||||
#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.
|
||||
|
||||
require_dependency 'redmine/export/pdf'
|
||||
require_dependency 'redmine/export/pdf/issues_pdf_helper'
|
||||
|
||||
module RedmineQbo
|
||||
module Patches
|
||||
module PdfPatch
|
||||
|
||||
def self.included(base)
|
||||
base.send(:include, InstanceMethods)
|
||||
base.class_eval do
|
||||
alias_method :issue_to_pdf, :issue_to_pdf_with_patch
|
||||
alias_method :issue_to_pdf_with_patch, :issue_to_pdf
|
||||
end
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
|
||||
def issue_to_pdf_with_patch(issue, assoc={})
|
||||
pdf = ::Redmine::Export::PDF::ITCPDF.new(current_language)
|
||||
pdf.set_title("#{issue.project} - #{issue.tracker} ##{issue.id}")
|
||||
pdf.alias_nb_pages
|
||||
pdf.footer_date = format_date(Date.today)
|
||||
pdf.add_page
|
||||
pdf.SetFontStyle('B',11)
|
||||
buf = "#{issue.project} - #{issue.tracker} ##{issue.id}"
|
||||
pdf.RDMMultiCell(190, 5, buf)
|
||||
pdf.SetFontStyle('',8)
|
||||
base_x = pdf.get_x
|
||||
i = 1
|
||||
issue.ancestors.visible.each do |ancestor|
|
||||
pdf.set_x(base_x + i)
|
||||
buf = "#{ancestor.tracker} # #{ancestor.id} (#{ancestor.status.to_s}): #{ancestor.subject}"
|
||||
pdf.RDMMultiCell(190 - i, 5, buf)
|
||||
i += 1 if i < 35
|
||||
end
|
||||
pdf.SetFontStyle('B',11)
|
||||
pdf.RDMMultiCell(190 - i, 5, issue.subject.to_s)
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMMultiCell(190, 5, "#{format_time(issue.created_on)} - #{issue.author}")
|
||||
pdf.ln
|
||||
|
||||
customer = issue.customer.name if issue.customer
|
||||
left = []
|
||||
left << [l(:field_status), issue.status]
|
||||
left << [l(:field_priority), issue.priority]
|
||||
left << [l(:field_customer), customer]
|
||||
left << [l(:field_assigned_to), issue.assigned_to] unless issue.disabled_core_fields.include?(:assigned_to_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)
|
||||
|
||||
log "Calling :pdf_left hook"
|
||||
left_hook_output = Redmine::Hook.call_hook :pdf_left, { issue: issue }
|
||||
unless left_hook_output.nil?
|
||||
left_hook_output.each do |l|
|
||||
left.concat l unless l.nil?
|
||||
end
|
||||
end
|
||||
|
||||
right = []
|
||||
right << [l(:field_start_date), format_date(issue.start_date)] unless issue.disabled_core_fields.include?(:start_date)
|
||||
right << [l(:field_due_date), format_date(issue.due_date)] unless issue.disabled_core_fields.include?(:due_date)
|
||||
right << [l(:field_done_ratio), "#{issue.done_ratio}%"] unless issue.disabled_core_fields.include?(:done_ratio)
|
||||
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)
|
||||
|
||||
log "Calling :pdf_right hook"
|
||||
right_hook_output = Redmine::Hook.call_hook :pdf_right, { issue: issue }
|
||||
unless right_hook_output.nil?
|
||||
right_hook_output.each do |r|
|
||||
right.concat r unless r.nil?
|
||||
end
|
||||
end
|
||||
|
||||
rows = left.size > right.size ? left.size : right.size
|
||||
while left.size < rows
|
||||
left << nil
|
||||
end
|
||||
while right.size < rows
|
||||
right << nil
|
||||
end
|
||||
|
||||
half = (issue.visible_custom_field_values.size / 2.0).ceil
|
||||
issue.visible_custom_field_values.each_with_index do |custom_value, i|
|
||||
(i < half ? left : right) << [custom_value.custom_field.name, show_value(custom_value, false)]
|
||||
end
|
||||
|
||||
if pdf.get_rtl
|
||||
border_first_top = 'RT'
|
||||
border_last_top = 'LT'
|
||||
border_first = 'R'
|
||||
border_last = 'L'
|
||||
else
|
||||
border_first_top = 'LT'
|
||||
border_last_top = 'RT'
|
||||
border_first = 'L'
|
||||
border_last = 'R'
|
||||
end
|
||||
|
||||
rows = left.size > right.size ? left.size : right.size
|
||||
rows.times do |i|
|
||||
heights = []
|
||||
pdf.SetFontStyle('B',9)
|
||||
item = left[i]
|
||||
heights << pdf.get_string_height(35, item ? "#{item.first}:" : "")
|
||||
item = right[i]
|
||||
heights << pdf.get_string_height(35, item ? "#{item.first}:" : "")
|
||||
pdf.SetFontStyle('',9)
|
||||
item = left[i]
|
||||
heights << pdf.get_string_height(60, item ? item.last.to_s : "")
|
||||
item = right[i]
|
||||
heights << pdf.get_string_height(60, item ? item.last.to_s : "")
|
||||
height = heights.max
|
||||
|
||||
item = left[i]
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "", (i == 0 ? border_first_top : border_first), '', 0, 0)
|
||||
pdf.SetFontStyle('',9)
|
||||
pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", (i == 0 ? border_last_top : border_last), '', 0, 0)
|
||||
|
||||
item = right[i]
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "", (i == 0 ? border_first_top : border_first), '', 0, 0)
|
||||
pdf.SetFontStyle('',9)
|
||||
pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", (i == 0 ? border_last_top : border_last), '', 0, 2)
|
||||
|
||||
pdf.set_x(base_x)
|
||||
end
|
||||
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(35+155, 5, l(:field_description), "LRT", 1)
|
||||
pdf.SetFontStyle('',9)
|
||||
|
||||
# Set resize image scale
|
||||
pdf.set_image_scale(1.6)
|
||||
text = textilizable(issue, :description,
|
||||
only_path: false,
|
||||
edit_section_links: false,
|
||||
headings: false,
|
||||
inline_attachments: false
|
||||
)
|
||||
pdf.RDMwriteFormattedCell(35+155, 5, '', '', text, issue.attachments, "LRB")
|
||||
|
||||
unless issue.leaf?
|
||||
truncate_length = (!is_cjk? ? 90 : 65)
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(35+155,5, l(:label_subtask_plural) + ":", "LTR")
|
||||
pdf.ln
|
||||
issue_list(issue.descendants.visible.sort_by(&:lft)) do |child, level|
|
||||
buf = "#{child.tracker} # #{child.id}: #{child.subject}".
|
||||
truncate(truncate_length)
|
||||
level = 10 if level >= 10
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMCell(35+135,5, (level >=1 ? " " * level : "") + buf, border_first)
|
||||
pdf.SetFontStyle('B',8)
|
||||
pdf.RDMCell(20,5, child.status.to_s, border_last)
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
|
||||
relations = issue.relations.select { |r| r.other_issue(issue).visible? }
|
||||
unless relations.empty?
|
||||
truncate_length = (!is_cjk? ? 80 : 60)
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(35+155,5, l(:label_related_issues) + ":", "LTR")
|
||||
pdf.ln
|
||||
relations.each do |relation|
|
||||
buf = relation.to_s(issue) {|other|
|
||||
text = ""
|
||||
if Setting.cross_project_issue_relations?
|
||||
text += "#{relation.other_issue(issue).project} - "
|
||||
end
|
||||
text += "#{other.tracker} ##{other.id}: #{other.subject}"
|
||||
text
|
||||
}
|
||||
buf = buf.truncate(truncate_length)
|
||||
pdf.SetFontStyle('', 8)
|
||||
pdf.RDMCell(35+155-60, 5, buf, border_first)
|
||||
pdf.SetFontStyle('B',8)
|
||||
pdf.RDMCell(20,5, relation.other_issue(issue).status.to_s, "")
|
||||
pdf.RDMCell(20,5, format_date(relation.other_issue(issue).start_date), "")
|
||||
pdf.RDMCell(20,5, format_date(relation.other_issue(issue).due_date), border_last)
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
pdf.RDMCell(190,5, "", "T")
|
||||
pdf.ln
|
||||
|
||||
if issue.changesets.any? &&
|
||||
User.current.allowed_to?(:view_changesets, issue.project)
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(190,5, l(:label_associated_revisions), "B")
|
||||
pdf.ln
|
||||
for changeset in issue.changesets
|
||||
pdf.SetFontStyle('B',8)
|
||||
csstr = "#{l(:label_revision)} #{changeset.format_identifier} - "
|
||||
csstr += format_time(changeset.committed_on) + " - " + changeset.author.to_s
|
||||
pdf.RDMCell(190, 5, csstr)
|
||||
pdf.ln
|
||||
unless changeset.comments.blank?
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMwriteHTMLCell(190,5,'','',
|
||||
changeset.comments.to_s, issue.attachments, "")
|
||||
end
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
|
||||
if assoc[:journals].present?
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(190,5, l(:label_history), "B")
|
||||
pdf.ln
|
||||
assoc[:journals].each do |journal|
|
||||
pdf.SetFontStyle('B',8)
|
||||
title = "##{journal.indice} - #{format_time(journal.created_on)} - #{journal.user}"
|
||||
title << " (#{l(:field_private_notes)})" if journal.private_notes?
|
||||
pdf.RDMCell(190,5, title)
|
||||
pdf.ln
|
||||
pdf.SetFontStyle('I',8)
|
||||
details_to_strings(journal.visible_details, true).each do |string|
|
||||
pdf.RDMMultiCell(190,5, "- " + string)
|
||||
end
|
||||
if journal.notes?
|
||||
pdf.ln unless journal.details.empty?
|
||||
pdf.SetFontStyle('',8)
|
||||
text = textilizable(journal, :notes,
|
||||
only_path: false,
|
||||
edit_section_links: false,
|
||||
headings: false,
|
||||
inline_attachments: false
|
||||
)
|
||||
pdf.RDMwriteFormattedCell(190,5,'','', text, issue.attachments, "")
|
||||
end
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
|
||||
if issue.attachments.any?
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(190,5, l(:label_attachment_plural), "B")
|
||||
pdf.ln
|
||||
for attachment in issue.attachments
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMCell(80,5, attachment.filename)
|
||||
pdf.RDMCell(20,5, number_to_human_size(attachment.filesize),0,0,"R")
|
||||
pdf.RDMCell(25,5, format_date(attachment.created_on),0,0,"R")
|
||||
pdf.RDMCell(65,5, attachment.author.name,0,0,"R")
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
|
||||
# Check to see if there is an estimate attached, then combine them
|
||||
if issue.estimate
|
||||
pdf = CombinePDF.parse(pdf.output, allow_optional_content: true)
|
||||
pdf << CombinePDF.parse(issue.estimate.pdf)
|
||||
return pdf.to_pdf
|
||||
end
|
||||
|
||||
return pdf.output
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log(msg)
|
||||
Rails.logger.info "[PdfPatch] #{msg}"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
Redmine::Export::PDF::IssuesPdfHelper.send(:include, PdfPatch)
|
||||
|
||||
end
|
||||
end
|
||||
70
lib/redmine_qbo/patches/query_patch.rb
Normal file
@@ -0,0 +1,70 @@
|
||||
#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.
|
||||
|
||||
require_dependency 'issue_query'
|
||||
|
||||
module RedmineQbo
|
||||
module Patches
|
||||
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
|
||||
def available_columns
|
||||
unless @available_columns
|
||||
@available_columns = self.class.available_columns.dup
|
||||
@available_columns << QueryColumn.new(:customer, sortable: "#{Issue.table_name}.customer_id", groupable: true, caption: :field_customer)
|
||||
@available_columns << QueryColumn.new(:billed, sortable: "#{TimeEntry.table_name}.billed", groupable: true, caption: :field_billed)
|
||||
end
|
||||
super
|
||||
end
|
||||
|
||||
# Add customers to filters
|
||||
def initialize_available_filters
|
||||
#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
|
||||
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
|
||||
|
||||
# Add module to Issue
|
||||
IssueQuery.send(:prepend, QueryPatch)
|
||||
|
||||
end
|
||||
end
|
||||
@@ -8,29 +8,31 @@
|
||||
#
|
||||
#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.
|
||||
|
||||
module Hooks
|
||||
require_dependency 'time_entry_query'
|
||||
|
||||
class IssuesSaveHookListener < Redmine::Hook::ViewListener
|
||||
module RedmineQbo
|
||||
module Patches
|
||||
module TimeEntryQueryPatch
|
||||
|
||||
# Called Before Issue Saved
|
||||
def controller_issues_edit_before_save(context={})
|
||||
return context[:issue].subject = context[:issue].subject.titleize
|
||||
end
|
||||
|
||||
def controller_issues_new_before_save(context={})
|
||||
return context[:issue].subject = context[:issue].subject.titleize
|
||||
end
|
||||
|
||||
# Called After Issue Saved
|
||||
def controller_issues_edit_after_save(context={})
|
||||
issue = context[:issue]
|
||||
begin
|
||||
issue.bill_time if issue.status.is_closed?
|
||||
rescue
|
||||
# TODO flash[:error] = "Unable to bill, check QBO Auth"
|
||||
# Add QBO options to columns
|
||||
def available_columns
|
||||
unless @available_columns
|
||||
@available_columns = self.class.available_columns.dup
|
||||
@available_columns << QueryColumn.new(:billed, sortable: "#{TimeEntry.table_name}.name", groupable: true, caption: :field_billed)
|
||||
end
|
||||
super
|
||||
end
|
||||
|
||||
# Add QBO options to the filter
|
||||
def initialize_available_filters
|
||||
add_available_filter "billed", type: :boolean
|
||||
super
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# Add module to TimeEntryQuery
|
||||
TimeEntryQuery.send(:prepend, QueryPatch)
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
@@ -10,33 +10,35 @@
|
||||
|
||||
require_dependency 'user'
|
||||
|
||||
module Patches
|
||||
module RedmineQbo
|
||||
module Patches
|
||||
|
||||
# Patches Redmine's User dynamically.
|
||||
# Adds a relationships
|
||||
module UserPatch
|
||||
def self.included(base) # :nodoc:
|
||||
base.extend(ClassMethods)
|
||||
# Patches Redmine's User dynamically.
|
||||
# Adds a relationships
|
||||
module UserPatch
|
||||
def self.included(base) # :nodoc:
|
||||
base.extend(ClassMethods)
|
||||
|
||||
base.send(:include, InstanceMethods)
|
||||
base.send(:include, InstanceMethods)
|
||||
|
||||
# Same as typing in the class
|
||||
base.class_eval do
|
||||
belongs_to :employee, primary_key: :id
|
||||
# Same as typing in the class
|
||||
base.class_eval do
|
||||
belongs_to :employee, primary_key: :id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
module ClassMethods
|
||||
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
|
||||
end
|
||||
# Add module to Issue
|
||||
User.send(:include, UserPatch)
|
||||
|
||||
end
|
||||
|
||||
# Add module to Issue
|
||||
User.send(:include, UserPatch)
|
||||
|
||||
end
|
||||
@@ -1,8 +0,0 @@
|
||||
require File.expand_path('../../test_helper', __FILE__)
|
||||
|
||||
class EstimateControllerTest < ActionController::TestCase
|
||||
# Replace this with your real tests.
|
||||
def test_truth
|
||||
assert true
|
||||
end
|
||||
end
|
||||
@@ -1,8 +0,0 @@
|
||||
require File.expand_path('../../test_helper', __FILE__)
|
||||
|
||||
class InvoiceControllerTest < ActionController::TestCase
|
||||
# Replace this with your real tests.
|
||||
def test_truth
|
||||
assert true
|
||||
end
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
# Load the Redmine helper
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper')
|
||||
@@ -1,9 +0,0 @@
|
||||
require File.expand_path('../../test_helper', __FILE__)
|
||||
|
||||
class CustomerTokenTest < ActiveSupport::TestCase
|
||||
|
||||
# Replace this with your real tests.
|
||||
def test_truth
|
||||
assert true
|
||||
end
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
require File.expand_path('../../test_helper', __FILE__)
|
||||
|
||||
class EmployeeTest < ActiveSupport::TestCase
|
||||
|
||||
# Replace this with your real tests.
|
||||
def test_truth
|
||||
assert true
|
||||
end
|
||||
end
|
||||